From ed27ef4cee5be6e60945f5ef25ed7f0d5545674d Mon Sep 17 00:00:00 2001 From: SiFax Date: Mon, 13 Apr 2026 01:48:22 +0100 Subject: [PATCH] =?UTF-8?q?feat(antigravity):=20implement=20AI=20Credits?= =?UTF-8?q?=20overages=20fallback=20and=20dashboa=E2=80=A6=20(#1190)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- open-sse/executors/antigravity.ts | 170 +++++++++++++++-- .../usage/components/ProviderLimits/index.tsx | 94 ++++++---- .../usage/components/ProviderLimits/utils.tsx | 23 +++ src/lib/oauth/constants/oauth.ts | 1 + src/lib/usage/fetcher.ts | 105 ++++++++++- tests/unit/antigravity-credits.test.mjs | 174 ++++++++++++++++++ 6 files changed, 506 insertions(+), 61 deletions(-) create mode 100644 tests/unit/antigravity-credits.test.mjs diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts index e88599a3a6..e6115f0fcb 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -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(); + +/** + * 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(); + +/** 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 | 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) { diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx index d01f829eac..ba2abdd5e2 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx @@ -612,48 +612,70 @@ export default function ProviderLimits() { return (
0 ? "border-l border-border/80 pl-3 ml-1" : "" }`} > - {/* Model label */} - - {shortName} - + {q.isCredits ? ( + /* ── AI Credits counter ── */ + <> + + 🪙 {formatQuotaLabel(q.name)} + + + {q.creditCount ?? q.remaining} + + left + + ) : ( + /* ── Standard quota bar ── */ + <> + {/* Model label */} + + {shortName} + - {/* Countdown */} - {staleAfterReset ? ( - - ⟳ Refreshing... - - ) : cd ? ( - - ⏱ {cd} - - ) : null} + {/* Countdown */} + {staleAfterReset ? ( + + ⟳ Refreshing... + + ) : cd ? ( + + ⏱ {cd} + + ) : null} - {/* Progress bar */} -
-
-
+ {/* Progress bar */} +
+
+
- {/* Percentage */} - - {remainingPercentage}% - + {/* Percentage */} + + {remainingPercentage}% + + + )}
); }) diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx index 995d1e4493..929d690af2 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx @@ -19,6 +19,8 @@ const QUOTA_LABEL_MAP: Record = { code_review: "Code Review", agentic_request: "Agentic", agentic_request_freetrial: "Agentic (Trial)", + credits: "AI Credits", + models: "Models", }; function toRecord(value: unknown): Record { @@ -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; } diff --git a/src/lib/oauth/constants/oauth.ts b/src/lib/oauth/constants/oauth.ts index d6c3c9127c..868a73b869 100644 --- a/src/lib/oauth/constants/oauth.ts +++ b/src/lib/oauth/constants/oauth.ts @@ -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"}`, diff --git a/src/lib/usage/fetcher.ts b/src/lib/usage/fetcher.ts index ecadb17294..b3723cd817 100644 --- a/src/lib/usage/fetcher.ts +++ b/src/lib/usage/fetcher.ts @@ -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 = {}) { 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 = data?.models ?? {}; + + // Walk quota-based models (those with remainingFraction in quotaInfo) + let quotaModelsTotal = 0; + let quotaModelsAvailable = 0; + const modelQuotas: Record = {}; + + for (const [modelId, rawInfo] of Object.entries(models)) { + const info = rawInfo as Record; + if (info.isInternal) continue; + const quotaInfo = (info.quotaInfo as Record) ?? {}; + + 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}` }; } } diff --git a/tests/unit/antigravity-credits.test.mjs b/tests/unit/antigravity-credits.test.mjs new file mode 100644 index 0000000000..135783f789 --- /dev/null +++ b/tests/unit/antigravity-credits.test.mjs @@ -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" + ); + }); +});