mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 21:32:10 +03:00
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:
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"}`,
|
||||
|
||||
@@ -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}` };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
174
tests/unit/antigravity-credits.test.mjs
Normal file
174
tests/unit/antigravity-credits.test.mjs
Normal 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"
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user