feat(antigravity): implement AI Credits overages fallback and dashboa… (#1190)

Integrated into release/v3.6.5. Fixed accountId key consistency between executor and fetcher. Added 13 unit tests for credit cache helpers, SSE parsing, and accountId derivation contract.
This commit is contained in:
SiFax
2026-04-13 01:48:22 +01:00
committed by GitHub
parent 1e2b7e728d
commit ed27ef4cee
6 changed files with 506 additions and 61 deletions

View File

@@ -4,9 +4,49 @@ import { PROVIDERS, OAUTH_ENDPOINTS, HTTP_STATUS } from "../config/constants.ts"
const MAX_RETRY_AFTER_MS = 60_000;
const LONG_RETRY_THRESHOLD_MS = 60_000;
const CREDITS_EXHAUSTED_TTL_MS = 5 * 60 * 60 * 1000; // 5 hours
const BARE_PRO_IDS = new Set(["gemini-3.1-pro"]);
/**
* Per-account GOOGLE_ONE_AI credits-exhausted tracker.
* Key: accountId (OAuth subject / email). Value: expiry timestamp.
* When credits hit 0 we skip the credit retry for CREDITS_EXHAUSTED_TTL_MS.
*/
const creditsExhaustedUntil = new Map<string, number>();
/**
* Per-account GOOGLE_ONE_AI remaining credit balance cache.
* Populated from the final SSE chunk's `remainingCredits` field after every
* successful credit-injected request. Keyed by accountId.
*/
const creditBalanceCache = new Map<string, number>();
/** Read the last-known GOOGLE_ONE_AI credit balance for a given account. */
export function getAntigravityRemainingCredits(accountId: string): number | null {
const balance = creditBalanceCache.get(accountId);
return balance !== undefined ? balance : null;
}
/** Update the balance cache — called when we parse `remainingCredits` from an SSE stream. */
export function updateAntigravityRemainingCredits(accountId: string, balance: number): void {
creditBalanceCache.set(accountId, balance);
}
function isCreditsExhausted(accountId: string): boolean {
const until = creditsExhaustedUntil.get(accountId);
if (!until) return false;
if (Date.now() >= until) {
creditsExhaustedUntil.delete(accountId);
return false;
}
return true;
}
function markCreditsExhausted(accountId: string): void {
creditsExhaustedUntil.set(accountId, Date.now() + CREDITS_EXHAUSTED_TTL_MS);
}
/**
* Strip provider prefixes (e.g. "antigravity/model" → "model").
* Ensures the model name sent to the upstream API never contains a routing prefix.
@@ -259,6 +299,7 @@ export class AntigravityExecutor extends BaseExecutor {
let textContent = "";
let finishReason = "stop";
let usage: Record<string, unknown> | null = null;
let remainingCredits: Array<{ creditType: string; creditAmount: string }> | null = null;
const lines = rawSSE.split("\n");
for (const line of lines) {
const trimmed = line.trim();
@@ -289,6 +330,10 @@ export class AntigravityExecutor extends BaseExecutor {
total_tokens: um.totalTokenCount || 0,
};
}
// Credit balance — arrives in the final chunk alongside consumedCredits
if (Array.isArray(parsed?.remainingCredits)) {
remainingCredits = parsed.remainingCredits;
}
} catch (e) {
log?.debug?.("SSE_PARSE", `Skipping malformed SSE line: ${payload.slice(0, 80)}`);
}
@@ -307,6 +352,8 @@ export class AntigravityExecutor extends BaseExecutor {
},
],
...(usage && { usage }),
// Expose credit balance for upstream consumers (usage service, dashboard)
...(remainingCredits && { _remainingCredits: remainingCredits }),
};
const syntheticStatus = timedOut ? 504 : response.status;
@@ -334,6 +381,12 @@ export class AntigravityExecutor extends BaseExecutor {
// non-streaming Response so chatCore's non-streaming path stays unchanged.
const upstreamStream = true;
// Account ID for credits-exhausted tracking.
// Key must match getAntigravityUsage() in fetcher.ts (providerSpecificData?.email || sub).
// credentials.email and credentials.sub are populated from the same OAuth token store,
// so the cache keys written here and read in the fetcher will always match.
const accountId: string = credentials?.email || credentials?.sub || "unknown";
for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) {
const url = this.buildUrl(model, upstreamStream, urlIndex);
const headers = this.buildHeaders(credentials, upstreamStream);
@@ -369,28 +422,105 @@ export class AntigravityExecutor extends BaseExecutor {
const errorBody = await response.clone().text();
const errorJson = JSON.parse(errorBody);
const errorMessage = errorJson?.error?.message || errorJson?.message || "";
retryMs = this.parseRetryFromErrorMessage(errorMessage);
const lowerMsg = errorMessage.toLowerCase();
if (!retryMs) {
// Dynamic quota interpretation logic for Free vs Pro accounts
const lowerMsg = errorMessage.toLowerCase();
// ── AI Credits Overages fallback ─────────────────────────────────
// MUST run BEFORE parseRetryFromErrorMessage: the API embeds the
// reset time in the same message ("reset after 141h22m11s"), so
// parseRetryFromErrorMessage fills retryMs and the !retryMs guard
// below would silently skip the credit injection.
if (
lowerMsg.includes("exhausted your capacity") ||
lowerMsg.includes("exhausted your") ||
lowerMsg.includes("daily limit") ||
lowerMsg.includes("quota exceeded")
) {
if (!isCreditsExhausted(accountId) && !transformedBody?.enabledCreditTypes) {
log?.info?.(
"CREDITS",
`Quota exhausted for ${model} — retrying with GOOGLE_ONE_AI credits (account: ${accountId})`
);
const creditBody = { ...transformedBody, enabledCreditTypes: ["GOOGLE_ONE_AI"] };
const creditRes = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify(creditBody),
signal,
});
if (
lowerMsg.includes("free tier") ||
lowerMsg.includes("exhausted your capacity") ||
lowerMsg.includes("daily limit") ||
lowerMsg.includes("quota exceeded")
) {
// Hard limit hit for Free accounts (or exhausting general capacity), fallback immediately.
// Setting a massive retryMs forces an instant fallback.
retryMs = 24 * 60 * 60 * 1000; // 24 hours
} else if (
lowerMsg.includes("pro") ||
lowerMsg.includes("per minute") ||
lowerMsg.includes("rpm")
) {
// RPM limit for Pro counts, backoff up to 1 minute, then fallback
retryMs = 60 * 1000; // 60s
if (creditRes.ok) {
if (!stream) {
const collected = await this.collectStreamToResponse(
creditRes,
model,
url,
headers,
creditBody,
log,
signal
);
// Parse _remainingCredits from the synthetic response and cache
try {
const syntheticJson = await collected.response.clone().json();
const rc = syntheticJson?._remainingCredits;
if (Array.isArray(rc)) {
const googleCredit = rc.find((c) => c.creditType === "GOOGLE_ONE_AI");
if (googleCredit) {
const balance = parseInt(googleCredit.creditAmount, 10);
if (!isNaN(balance))
updateAntigravityRemainingCredits(accountId, balance);
}
}
} catch {
/**/
}
return collected;
}
return { response: creditRes, url, headers, transformedBody: creditBody };
}
// Credit retry also failed — check if credits are exhausted
try {
const creditErrText = await creditRes.clone().text();
const creditErrJson = JSON.parse(creditErrText);
const creditErrMsg = (creditErrJson?.error?.message || "").toLowerCase();
if (
creditErrMsg.includes("credit") ||
creditErrMsg.includes("insufficient") ||
creditErrMsg.includes("exhausted")
) {
log?.warn?.(
"CREDITS",
`GOOGLE_ONE_AI credits exhausted for account ${accountId} — caching for 5h`
);
markCreditsExhausted(accountId);
}
} catch {
/**/
}
// Fall through to normal fallback logic below
} else if (!isCreditsExhausted(accountId) && transformedBody?.enabledCreditTypes) {
// Already had credits injected and still failed — mark exhausted
markCreditsExhausted(accountId);
log?.warn?.("CREDITS", `Credits exhausted for account ${accountId}`);
}
// Hard quota limit — force fallback to next account regardless
retryMs = 24 * 60 * 60 * 1000;
} else {
// Not a quota-exhaustion error — try to parse a Retry-After from the message
retryMs = this.parseRetryFromErrorMessage(errorMessage);
if (!retryMs) {
if (lowerMsg.includes("free tier") || lowerMsg.includes("free")) {
retryMs = 24 * 60 * 60 * 1000;
} else if (
lowerMsg.includes("pro") ||
lowerMsg.includes("per minute") ||
lowerMsg.includes("rpm")
) {
retryMs = 60 * 1000;
}
}
}
} catch (e) {

View File

@@ -612,48 +612,70 @@ export default function ProviderLimits() {
return (
<div
key={i}
className={`flex items-center gap-1.5 min-w-[200px] shrink-0 ${
className={`flex items-center gap-1.5 shrink-0 ${
i > 0 ? "border-l border-border/80 pl-3 ml-1" : ""
}`}
>
{/* Model label */}
<span
title={q.modelKey || q.name}
className="text-[11px] font-semibold py-0.5 px-2 rounded whitespace-nowrap min-w-[60px] text-center"
style={{ background: colors.bg, color: colors.text }}
>
{shortName}
</span>
{q.isCredits ? (
/* ── AI Credits counter ── */
<>
<span
className="text-[11px] font-semibold py-0.5 px-2 rounded whitespace-nowrap"
style={{ background: colors.bg, color: colors.text }}
>
🪙 {formatQuotaLabel(q.name)}
</span>
<span
className="text-[12px] font-bold tabular-nums"
style={{ color: colors.text }}
>
{q.creditCount ?? q.remaining}
</span>
<span className="text-[10px] text-text-muted">left</span>
</>
) : (
/* ── Standard quota bar ── */
<>
{/* Model label */}
<span
title={q.modelKey || q.name}
className="text-[11px] font-semibold py-0.5 px-2 rounded whitespace-nowrap min-w-[60px] text-center"
style={{ background: colors.bg, color: colors.text }}
>
{shortName}
</span>
{/* Countdown */}
{staleAfterReset ? (
<span className="text-[10px] text-text-muted whitespace-nowrap">
Refreshing...
</span>
) : cd ? (
<span className="text-[10px] text-text-muted whitespace-nowrap">
{cd}
</span>
) : null}
{/* Countdown */}
{staleAfterReset ? (
<span className="text-[10px] text-text-muted whitespace-nowrap">
Refreshing...
</span>
) : cd ? (
<span className="text-[10px] text-text-muted whitespace-nowrap">
{cd}
</span>
) : null}
{/* Progress bar */}
<div className="flex-1 h-1.5 rounded-sm bg-black/[0.06] dark:bg-white/[0.06] min-w-[60px] overflow-hidden">
<div
className="h-full rounded-sm transition-[width] duration-300 ease-out"
style={{
width: `${Math.min(remainingPercentage, 100)}%`,
background: colors.bar,
}}
/>
</div>
{/* Progress bar */}
<div className="flex-1 h-1.5 rounded-sm bg-black/[0.06] dark:bg-white/[0.06] min-w-[60px] overflow-hidden">
<div
className="h-full rounded-sm transition-[width] duration-300 ease-out"
style={{
width: `${Math.min(remainingPercentage, 100)}%`,
background: colors.bar,
}}
/>
</div>
{/* Percentage */}
<span
className="text-[11px] font-semibold min-w-[32px] text-right"
style={{ color: colors.text }}
>
{remainingPercentage}%
</span>
{/* Percentage */}
<span
className="text-[11px] font-semibold min-w-[32px] text-right"
style={{ color: colors.text }}
>
{remainingPercentage}%
</span>
</>
)}
</div>
);
})

View File

@@ -19,6 +19,8 @@ const QUOTA_LABEL_MAP: Record<string, string> = {
code_review: "Code Review",
agentic_request: "Agentic",
agentic_request_freetrial: "Agentic (Trial)",
credits: "AI Credits",
models: "Models",
};
function toRecord(value: unknown): Record<string, unknown> {
@@ -203,6 +205,27 @@ export function parseQuotaData(provider, data) {
case "antigravity":
if (data.quotas) {
Object.entries(data.quotas).forEach(([modelKey, quota]: [string, any]) => {
if (modelKey === "credits") {
// Credit balance: render as "N credits remaining" counter, not a progress bar
const remaining = Number(quota?.remaining ?? 0);
normalizedQuotas.push({
name: "credits",
used: 0,
total: 0,
remaining,
resetAt: null,
unlimited: false,
isCredits: true,
// Show green if >50, yellow if >10, red if ≤10
remainingPercentage: remaining > 50 ? 100 : remaining > 10 ? 60 : 20,
creditCount: remaining,
});
return;
}
if (modelKey === "models") {
// Summary row: skip — individual models are shown via modelQuotas if needed
return;
}
if (quota?.unlimited && (!quota?.total || quota.total <= 0)) {
return;
}

View File

@@ -131,6 +131,7 @@ export const ANTIGRAVITY_CONFIG = {
apiVersion: "v1internal",
loadCodeAssistEndpoint: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
onboardUserEndpoint: "https://cloudcode-pa.googleapis.com/v1internal:onboardUser",
fetchAvailableModelsEndpoint: "https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels",
loadCodeAssistUserAgent: "google-api-nodejs-client/9.15.1",
loadCodeAssistApiClient: "google-cloud-sdk vscode_cloudshelleditor/0.1",
loadCodeAssistClientMetadata: `{"ideType":"IDE_UNSPECIFIED","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}`,

View File

@@ -3,6 +3,7 @@
*/
import { GITHUB_CONFIG, GEMINI_CONFIG, ANTIGRAVITY_CONFIG } from "@/lib/oauth/constants/oauth";
import { getAntigravityRemainingCredits } from "@omniroute/open-sse/executors/antigravity.ts";
/**
* Get usage data for a provider connection
@@ -18,7 +19,7 @@ export async function getUsageForProvider(connection) {
case "gemini-cli":
return await getGeminiUsage(accessToken);
case "antigravity":
return await getAntigravityUsage(accessToken);
return await getAntigravityUsage(accessToken, providerSpecificData);
case "claude":
return await getClaudeUsage(accessToken);
case "codex":
@@ -146,13 +147,107 @@ async function getGeminiUsage(accessToken) {
/**
* Antigravity Usage
* Calls fetchAvailableModels to get per-model quota fractions.
* Credit balance (GOOGLE_ONE_AI) is read from the executor's in-memory cache,
* which is populated automatically after each successful credit-injected SSE call.
*/
async function getAntigravityUsage(accessToken) {
async function getAntigravityUsage(accessToken: string, providerSpecificData: Record<string, unknown> = {}) {
try {
// Similar to Gemini, uses Google Cloud
return { message: "Antigravity connected. Usage tracked via Google Cloud Console." };
// Derive accountId (same key used in AntigravityExecutor.execute)
const accountId: string =
(providerSpecificData?.email as string) ||
(providerSpecificData?.sub as string) ||
"unknown";
// Read cached credit balance from executor module (populated from SSE remainingCredits)
const creditBalance = getAntigravityRemainingCredits(accountId);
// fetchAvailableModels — resolves project from token, no projectId needed
const res = await fetch(ANTIGRAVITY_CONFIG.fetchAvailableModelsEndpoint, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
"User-Agent": "antigravity/1.11.3 Darwin/arm64",
},
body: JSON.stringify({}),
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) {
return {
plan: "Antigravity",
message: "Antigravity connected. Unable to fetch model quotas.",
...(creditBalance !== null && {
quotas: {
credits: {
used: 0,
total: 0,
remaining: creditBalance,
unlimited: false,
resetAt: null,
},
},
}),
};
}
const data = await res.json();
const models: Record<string, unknown> = data?.models ?? {};
// Walk quota-based models (those with remainingFraction in quotaInfo)
let quotaModelsTotal = 0;
let quotaModelsAvailable = 0;
const modelQuotas: Record<string, { remaining: number; resetAt: string | null; limited: boolean }> = {};
for (const [modelId, rawInfo] of Object.entries(models)) {
const info = rawInfo as Record<string, unknown>;
if (info.isInternal) continue;
const quotaInfo = (info.quotaInfo as Record<string, unknown>) ?? {};
if ("remainingFraction" in quotaInfo) {
const fraction = typeof quotaInfo.remainingFraction === "number" ? quotaInfo.remainingFraction : 1;
const resetTime = typeof quotaInfo.resetTime === "string" ? quotaInfo.resetTime : null;
modelQuotas[modelId] = {
remaining: Math.round(fraction * 100),
resetAt: resetTime,
limited: fraction <= 0,
};
quotaModelsTotal++;
if (fraction > 0) quotaModelsAvailable++;
}
// Credit-based models have no remainingFraction — their availability is
// tracked via the GOOGLE_ONE_AI credit balance cached from SSE responses.
}
const allLimited = quotaModelsTotal > 0 && quotaModelsAvailable === 0;
return {
plan: "Antigravity",
quotas: {
models: {
used: quotaModelsTotal - quotaModelsAvailable,
total: quotaModelsTotal,
remaining: quotaModelsAvailable,
limited: allLimited,
unlimited: false,
resetAt: null,
},
...(creditBalance !== null && {
credits: {
used: 0,
total: 0,
remaining: creditBalance,
unlimited: false,
resetAt: null,
},
}),
},
modelQuotas,
limitReached: allLimited,
};
} catch (error) {
return { message: "Unable to fetch Antigravity usage." };
return { message: `Unable to fetch Antigravity usage: ${(error as Error).message}` };
}
}

View File

@@ -0,0 +1,174 @@
/**
* Tests for antigravity.ts — AI Credits overages fallback.
*
* Verifies:
* 1. Credit balance cache read/write (getAntigravityRemainingCredits / updateAntigravityRemainingCredits)
* 2. SSE remainingCredits extraction logic from collectStreamToResponse
* 3. accountId consistency: executor and fetcher must use the same key (email || sub)
* 4. Balance updates are correctly reflected in subsequent reads
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
// ── Import credit cache helpers ───────────────────────────────────────────────
import {
getAntigravityRemainingCredits,
updateAntigravityRemainingCredits,
} from "../../open-sse/executors/antigravity.ts";
// ── Credit balance cache tests ────────────────────────────────────────────────
describe("getAntigravityRemainingCredits / updateAntigravityRemainingCredits", () => {
it("returns null for an account with no cached balance", () => {
const accountId = `test-unknown-${Date.now()}`;
assert.equal(
getAntigravityRemainingCredits(accountId),
null,
"should return null before any update"
);
});
it("returns the balance after updateAntigravityRemainingCredits", () => {
const accountId = `test-write-${Date.now()}`;
updateAntigravityRemainingCredits(accountId, 42);
assert.equal(getAntigravityRemainingCredits(accountId), 42, "stored balance should be 42");
});
it("overwrites a previous balance with a new value", () => {
const accountId = `test-overwrite-${Date.now()}`;
updateAntigravityRemainingCredits(accountId, 100);
updateAntigravityRemainingCredits(accountId, 55);
assert.equal(getAntigravityRemainingCredits(accountId), 55, "should reflect the latest update");
});
it("stores balance=0 correctly (not treated as null/falsy)", () => {
const accountId = `test-zero-${Date.now()}`;
updateAntigravityRemainingCredits(accountId, 0);
assert.equal(
getAntigravityRemainingCredits(accountId),
0,
"balance 0 must be stored and returned as 0"
);
});
it("different accountIds do not interfere with each other", () => {
const idA = `test-a-${Date.now()}`;
const idB = `test-b-${Date.now()}`;
updateAntigravityRemainingCredits(idA, 200);
updateAntigravityRemainingCredits(idB, 999);
assert.equal(getAntigravityRemainingCredits(idA), 200);
assert.equal(getAntigravityRemainingCredits(idB), 999);
});
});
// ── accountId key consistency ─────────────────────────────────────────────────
describe("accountId key consistency: executor vs fetcher derivation", () => {
it("both executor and fetcher use email → sub → 'unknown' order", () => {
// Enforces the contract between executor (write) and fetcher (read) for creditBalanceCache.
const credentials = { email: "user@example.com", sub: "abc123" };
const providerSpecificData = { email: "user@example.com", sub: "abc123" };
// executor derivation
const executorAccountId = credentials.email || credentials.sub || "unknown";
// fetcher derivation
const fetcherAccountId = providerSpecificData.email || providerSpecificData.sub || "unknown";
assert.equal(
executorAccountId,
fetcherAccountId,
"accountId must match between executor and fetcher"
);
});
it("falls back to sub when email is absent — both paths agree", () => {
const credentials = { sub: "sub-only-123" };
const providerSpecificData = { sub: "sub-only-123" };
const executorAccountId = credentials.email || credentials.sub || "unknown";
const fetcherAccountId = providerSpecificData.email || providerSpecificData.sub || "unknown";
assert.equal(executorAccountId, "sub-only-123");
assert.equal(fetcherAccountId, "sub-only-123");
assert.equal(executorAccountId, fetcherAccountId);
});
it("both paths return 'unknown' when email and sub are absent", () => {
const credentials = {};
const providerSpecificData = {};
const executorAccountId = credentials.email || credentials.sub || "unknown";
const fetcherAccountId = providerSpecificData.email || providerSpecificData.sub || "unknown";
assert.equal(executorAccountId, "unknown");
assert.equal(fetcherAccountId, "unknown");
});
});
// ── SSE remainingCredits extraction logic ─────────────────────────────────────
describe("SSE remainingCredits extraction logic", () => {
it("parses GOOGLE_ONE_AI credit amount from remainingCredits array", () => {
const remainingCredits = [
{ creditType: "GOOGLE_ONE_AI", creditAmount: "123" },
{ creditType: "SOME_OTHER", creditAmount: "999" },
];
const googleCredit = remainingCredits.find((c) => c.creditType === "GOOGLE_ONE_AI");
assert.ok(googleCredit, "GOOGLE_ONE_AI entry must be found");
const balance = parseInt(googleCredit.creditAmount, 10);
assert.equal(balance, 123, "credit balance must be parsed correctly");
});
it("handles missing GOOGLE_ONE_AI gracefully — no crash", () => {
const remainingCredits = [{ creditType: "SOME_OTHER", creditAmount: "999" }];
const googleCredit = remainingCredits.find((c) => c.creditType === "GOOGLE_ONE_AI");
assert.equal(googleCredit, undefined, "should not find GOOGLE_ONE_AI if not present");
});
it("handles malformed creditAmount gracefully — NaN is not stored", () => {
const remainingCredits = [{ creditType: "GOOGLE_ONE_AI", creditAmount: "not-a-number" }];
const googleCredit = remainingCredits.find((c) => c.creditType === "GOOGLE_ONE_AI");
const balance = parseInt(googleCredit.creditAmount, 10);
assert.ok(isNaN(balance), "NaN guard prevents invalid balance storage");
});
it("balance update is correctly reflected in the cache after a successful parse", () => {
const accountId = `test-sse-${Date.now()}`;
const remainingCredits = [{ creditType: "GOOGLE_ONE_AI", creditAmount: "77" }];
const googleCredit = remainingCredits.find((c) => c.creditType === "GOOGLE_ONE_AI");
if (googleCredit) {
const balance = parseInt(googleCredit.creditAmount, 10);
if (!isNaN(balance)) {
updateAntigravityRemainingCredits(accountId, balance);
}
}
assert.equal(getAntigravityRemainingCredits(accountId), 77);
});
it("skips cache update when creditAmount is NaN — balance remains null", () => {
const accountId = `test-nan-guard-${Date.now()}`;
const remainingCredits = [{ creditType: "GOOGLE_ONE_AI", creditAmount: "bad" }];
const googleCredit = remainingCredits.find((c) => c.creditType === "GOOGLE_ONE_AI");
if (googleCredit) {
const balance = parseInt(googleCredit.creditAmount, 10);
if (!isNaN(balance)) {
updateAntigravityRemainingCredits(accountId, balance);
}
// NaN — no update should happen
}
assert.equal(
getAntigravityRemainingCredits(accountId),
null,
"balance should remain null when parsing yields NaN"
);
});
});