diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index e10d1f6551f5..d6baf30a5d48 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -44,11 +44,6 @@ "count": 1 } }, - "open-sse/executors/antigravity.ts": { - "@typescript-eslint/no-unused-vars": { - "count": 2 - } - }, "open-sse/executors/awsPollyTts.ts": { "@typescript-eslint/no-unused-vars": { "count": 1 diff --git a/open-sse/config/agyModels.ts b/open-sse/config/agyModels.ts index d43fcc25314e..0aa8dba652e2 100644 --- a/open-sse/config/agyModels.ts +++ b/open-sse/config/agyModels.ts @@ -12,6 +12,36 @@ // they are not chat-callable. export const AGY_PUBLIC_MODELS = Object.freeze([ + // Gemini 3.8 Flash tiers. Per-effort upstream ids (`--effort low|medium|high` on the + // CLI); they are dispatched verbatim, unlike the 3.7 tiers below which share one + // `-tiered` upstream id. + { + id: "gemini-3.8-flash-high", + name: "Gemini 3.8 Flash (High)", + contextLength: 1048576, + maxOutputTokens: 65536, + supportsReasoning: true, + supportsVision: true, + toolCalling: true, + }, + { + id: "gemini-3.8-flash-medium", + name: "Gemini 3.8 Flash (Medium)", + contextLength: 1048576, + maxOutputTokens: 65536, + supportsReasoning: true, + supportsVision: true, + toolCalling: true, + }, + { + id: "gemini-3.8-flash-low", + name: "Gemini 3.8 Flash (Low)", + contextLength: 1048576, + maxOutputTokens: 65536, + supportsReasoning: true, + supportsVision: true, + toolCalling: true, + }, // Gemini 3.7 Flash tiers. The live endpoint selects High by default and advertises // all three ids to both the IDE 2.5.5 and CLI 1.1.x clients. { diff --git a/open-sse/config/antigravityModelAliases.ts b/open-sse/config/antigravityModelAliases.ts index 3cf9094cbb39..7313e57517cf 100644 --- a/open-sse/config/antigravityModelAliases.ts +++ b/open-sse/config/antigravityModelAliases.ts @@ -1,4 +1,34 @@ export const ANTIGRAVITY_PUBLIC_MODELS = Object.freeze([ + // Gemini 3.8 Flash tiers. Unlike 3.7 these are per-effort upstream ids: each tier is a + // real runtime model, so they are NOT aliased onto a shared `-tiered` id and the effort + // is not steered through generationConfig.thinkingConfig.thinkingBudget. + { + id: "gemini-3.8-flash-high", + name: "Gemini 3.8 Flash (High)", + contextLength: 1048576, + maxOutputTokens: 65536, + supportsReasoning: true, + supportsVision: true, + toolCalling: true, + }, + { + id: "gemini-3.8-flash-medium", + name: "Gemini 3.8 Flash (Medium)", + contextLength: 1048576, + maxOutputTokens: 65536, + supportsReasoning: true, + supportsVision: true, + toolCalling: true, + }, + { + id: "gemini-3.8-flash-low", + name: "Gemini 3.8 Flash (Low)", + contextLength: 1048576, + maxOutputTokens: 65536, + supportsReasoning: true, + supportsVision: true, + toolCalling: true, + }, // Gemini 3.7 Flash tiers listed by the current official Antigravity model catalog. // Keep the upstream model ids unchanged so discovery and execution address the same // models selected by the native client. @@ -100,6 +130,11 @@ export const ANTIGRAVITY_PUBLIC_MODELS = Object.freeze([ ]); export const ANTIGRAVITY_MODEL_ALIASES = Object.freeze({ + // Gemini 3.8 Flash: only the bare id is aliased, onto the upstream default effort + // (medium). The three tier ids are real upstream models and must reach the backend + // verbatim — mapping them onto a shared `-tiered` id (the 3.7 mechanism below) would + // send an id the 3.8 backend does not serve. + "gemini-3.8-flash": "gemini-3.8-flash-medium", // Gemini 3.7 Flash tiers map to the upstream tiered endpoint model; the thinking // budget is steered via generationConfig.thinkingConfig.thinkingBudget. "gemini-3.7-flash": "gemini-3.7-flash-tiered", diff --git a/open-sse/config/freeModelCatalog.data.ts b/open-sse/config/freeModelCatalog.data.ts index a8ba20ae9c55..4031e7912a58 100644 --- a/open-sse/config/freeModelCatalog.data.ts +++ b/open-sse/config/freeModelCatalog.data.ts @@ -22,6 +22,9 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [ { provider: "agentrouter", modelId: "claude-opus-4-8", displayName: "Claude Opus 4.8", monthlyTokens: 0, creditTokens: 200000000, freeType: "one-time-initial", poolKey: "agentrouter", tos: "caution" }, { provider: "agentrouter", modelId: "claude-opus-5", displayName: "Claude Opus 5", monthlyTokens: 0, creditTokens: 200000000, freeType: "one-time-initial", poolKey: "agentrouter", tos: "caution" }, { provider: "agentrouter", modelId: "gpt-5.6-sol", displayName: "GPT-5.6 Sol", monthlyTokens: 0, creditTokens: 200000000, freeType: "one-time-initial", poolKey: "agentrouter", tos: "caution" }, + { provider: "agy", modelId: "gemini-3.8-flash-high", displayName: "Gemini 3.8 Flash (High)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "agy", tos: "avoid" }, + { provider: "agy", modelId: "gemini-3.8-flash-medium", displayName: "Gemini 3.8 Flash (Medium)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "agy", tos: "avoid" }, + { provider: "agy", modelId: "gemini-3.8-flash-low", displayName: "Gemini 3.8 Flash (Low)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "agy", tos: "avoid" }, { provider: "agy", modelId: "gemini-3.7-flash-high", displayName: "Gemini 3.7 Flash (High)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "agy", tos: "avoid" }, { provider: "agy", modelId: "gemini-3.7-flash-medium", displayName: "Gemini 3.7 Flash (Medium)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "agy", tos: "avoid" }, { provider: "agy", modelId: "gemini-3.7-flash-low", displayName: "Gemini 3.7 Flash (Low)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "agy", tos: "avoid" }, diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts index 9582af9fe65f..7387ddaf0468 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -13,10 +13,7 @@ import { getAntigravityOAuthUserAgent, } from "../services/antigravityHeaders.ts"; import { classify429, decide429, type Decision } from "../services/antigravity429Engine.ts"; -import { - parseRetryFromErrorText, - type RetryHintProvenance, -} from "../services/accountFallback.ts"; +import { parseRetryFromErrorText, type RetryHintProvenance } from "../services/accountFallback.ts"; import { parseDetailedRetryHintFromJsonBody } from "../services/retryAfterJson.ts"; import { shouldRetryWithCredits, @@ -28,10 +25,7 @@ import { persistCreditBalance, getAllPersistedCreditBalances } from "@/lib/db/cr import { setConnectionRateLimitUntil } from "@/lib/db/providers"; import { markAntigravityModelQuotaExhausted } from "../services/antigravityFamilyCooldown.ts"; import { getMitmAlias } from "@/lib/db/models"; -import { - MAX_ANTIGRAVITY_OUTPUT_TOKENS, - resolveAntigravityOutputCap, -} from "./antigravityOutputCap.ts"; +import { resolveAntigravityOutputCap } from "./antigravityOutputCap.ts"; export { MAX_ANTIGRAVITY_OUTPUT_TOKENS } from "./antigravityOutputCap.ts"; import { ensureAntigravityProjectAssigned, @@ -270,7 +264,11 @@ export function markConnectionQuotaExhausted( * specific upstream id, pass it here. It is an ALREADY-RESOLVED upstream id, so it bypasses * the MITM/static alias resolution and is used verbatim (after prefix stripping). */ -async function cleanModelName(model: string, modelIdOverride?: string): Promise { +export async function cleanModelName( + model: string, + modelIdOverride?: string, + provider = "antigravity" +): Promise { if (modelIdOverride) { return modelIdOverride.includes("/") ? modelIdOverride.split("/").pop()! : modelIdOverride; } @@ -282,16 +280,16 @@ async function cleanModelName(model: string, modelIdOverride?: string): Promise< // Built during model sync — contains ONLY currently-available models. // Obsolete/removed models are automatically excluded. try { - const mitmAliases = await getMitmAlias("antigravity"); + const mitmAliases = await getMitmAlias(provider); if (mitmAliases && typeof mitmAliases === "object") { const aliases = mitmAliases as Record; const raw = aliases[stripped]; // Only honor string aliases; corrupted/non-string DB values fall through // to the static alias resolution below (never return undefined here). if (typeof raw === "string" && raw) { - // Strip the "antigravity/" prefix if present; use the raw model ID otherwise. - const PREFIX = "antigravity/"; - clean = raw.startsWith(PREFIX) ? raw.slice(PREFIX.length) : raw; + // Strip the provider prefix if present; use the raw model ID otherwise. + const prefix = `${provider}/`; + clean = raw.startsWith(prefix) ? raw.slice(prefix.length) : raw; } } } catch { @@ -315,11 +313,20 @@ function applyAntigravityGenerationDefaults( ? (request.generationConfig as Record) : {}; - if (generationConfig.topK === undefined) { - generationConfig.topK = 40; - } - if (generationConfig.topP === undefined) { - generationConfig.topP = 1.0; + const isGemini38 = + typeof modelId === "string" && modelId.toLowerCase().includes("gemini-3.8-flash"); + + if (isGemini38) { + delete generationConfig.topK; + delete generationConfig.topP; + delete generationConfig.temperature; + } else { + if (generationConfig.topK === undefined) { + generationConfig.topK = 40; + } + if (generationConfig.topP === undefined) { + generationConfig.topP = 1.0; + } } const thinkingConfig = @@ -329,6 +336,7 @@ function applyAntigravityGenerationDefaults( const thinkingBudget = Number(thinkingConfig?.thinkingBudget); const maxOutputTokens = Number(generationConfig.maxOutputTokens); if ( + !isGemini38 && Number.isFinite(thinkingBudget) && thinkingBudget > 0 && (!Number.isFinite(maxOutputTokens) || maxOutputTokens <= thinkingBudget) @@ -379,7 +387,9 @@ const COMPETITIVE_AGENT_PROMPT_PATTERNS: RegExp[] = [ */ export function stripCompetitiveAgentPrompts(systemInstruction: unknown): unknown { const record = asRecord(systemInstruction); - const parts = Array.isArray(record?.parts) ? (record.parts as Array>) : []; + const parts = Array.isArray(record?.parts) + ? (record.parts as Array>) + : []; if (parts.length === 0) return systemInstruction; let changed = false; @@ -387,7 +397,10 @@ export function stripCompetitiveAgentPrompts(systemInstruction: unknown): unknow if (typeof part.text !== "string" || part.text.length === 0) return part; let text = part.text; for (const pattern of COMPETITIVE_AGENT_PROMPT_PATTERNS) { - const stripped = text.replace(pattern, "").replace(/\n{3,}/g, "\n\n").trimStart(); + const stripped = text + .replace(pattern, "") + .replace(/\n{3,}/g, "\n\n") + .trimStart(); if (stripped !== text) { changed = true; text = stripped; @@ -1352,7 +1365,7 @@ export class AntigravityExecutor extends BaseExecutor { accountId, urlIndex, retryAttemptsByUrl, - fallbackCount, + fallbackCount: _fallbackCount, } = ctx; const { response, finalHeaders } = await sendAntigravityRequest( diff --git a/open-sse/executors/antigravity/executeAttempt.ts b/open-sse/executors/antigravity/executeAttempt.ts index b8b9447c19ef..4a6ac44c10df 100644 --- a/open-sse/executors/antigravity/executeAttempt.ts +++ b/open-sse/executors/antigravity/executeAttempt.ts @@ -8,7 +8,7 @@ import { mergeAbortSignals, type ExecutorLog } from "../base.ts"; import { applyFingerprint, isCliCompatEnabled } from "../../config/cliFingerprints.ts"; import { buildAntigravityUpstreamError } from "../antigravityUpstreamError.ts"; -import { maybeTriggerReactiveModelSync } from "@/lib/providerModels/reactiveModelSync.ts"; +import { awaitReactiveModelSync } from "@/lib/providerModels/reactiveModelSync.ts"; import { HTTP_STATUS, STREAM_READINESS_TIMEOUT_MS, @@ -21,12 +21,13 @@ import { removeHeaderCaseInsensitive, } from "../../services/antigravityClientProfile.ts"; import * as prl from "../../utils/providerRequestLogging.ts"; +import { generateAntigravityRequestId } from "../../services/antigravityIdentity.ts"; import { createCreditsExtractionTransform as createCreditsExtractionTransformImpl, buildSsePassthroughResult, type SsePassthroughResult, } from "./streamingPassthrough.ts"; -import type { AntigravityCredentials } from "../antigravity.ts"; +import { cleanModelName, type AntigravityCredentials } from "../antigravity.ts"; const LONG_RETRY_THRESHOLD_MS = 60_000; const CREDITS_EXHAUSTED_TTL_MS = 5 * 60 * 60 * 1000; // 5 hours @@ -387,12 +388,37 @@ export async function sendAntigravityRequest( if (response.status === HTTP_STATUS.NOT_FOUND) { // The backend may have shipped/renamed models the synced catalog does not // know yet (pinned-catalog staleness). Kick a discovery sync for this - // connection so the fresh list lands in the synced catalog and the next - // request can resolve. Cooldown + in-flight dedup live in the trigger. - // credentials.connectionId is optional (base.ts): no connection row means - // there is no synced catalog to refresh, so skip rather than pass undefined. + // connection so the fresh list lands in the synced catalog. If sync succeeds, + // re-resolve the model with the updated catalog, re-serialize the request envelope, + // and transparently retry once so the first request for a freshly shipped model + // or alias succeeds on the first try. if (credentials.connectionId) { - maybeTriggerReactiveModelSync(provider, credentials.connectionId); + const synced = await awaitReactiveModelSync(provider, credentials.connectionId); + if (synced) { + const reResolvedModel = await cleanModelName(model, undefined, provider); + const retryBody: Record = { + ...transformedBody, + model: reResolvedModel, + requestId: generateAntigravityRequestId(), + }; + const reSerialized = serializeAntigravityRequest(provider, headers, retryBody); + const retryHeaders = reSerialized.headers; + applyAntigravityClientProfileHeaders(retryHeaders, credentials, retryBody); + + log.info( + "RETRY", + `[Antigravity] Discovery sync succeeded after 404 for ${model} (re-resolved to ${reResolvedModel}), retrying request with rebuilt envelope` + ); + await prl.captureCurrentProviderBody(url, retryHeaders, reSerialized.bodyString, log); + response = await fetchAntigravityWithReadinessTimeout(url, { + method: "POST", + headers: retryHeaders, + body: getChunkedOrFixedBody(reSerialized.bodyString, stream), + ...(stream ? { duplex: "half" } : {}), + signal, + }); + finalHeaders = retryHeaders; + } } } } diff --git a/open-sse/services/antigravityQuotaFamily.ts b/open-sse/services/antigravityQuotaFamily.ts index 94016c1109bb..cf27e49eeda4 100644 --- a/open-sse/services/antigravityQuotaFamily.ts +++ b/open-sse/services/antigravityQuotaFamily.ts @@ -113,9 +113,9 @@ export function selectAntigravityQuotaWindowNames( const familyAggregates = requestedFamily === "gemini" - ? ["gemini_weekly"] + ? ["gemini_session", "gemini_weekly"] : requestedFamily === "claude" - ? ["claude_gpt_weekly"] + ? ["claude_gpt_session", "claude_gpt_weekly"] : []; const exactWindows = quotaNames.filter((windowName) => { diff --git a/open-sse/services/usage/antigravity.ts b/open-sse/services/usage/antigravity.ts index 66ceab931173..c15d4cdd06d7 100644 --- a/open-sse/services/usage/antigravity.ts +++ b/open-sse/services/usage/antigravity.ts @@ -20,7 +20,7 @@ import { isDiscoverableAntigravityModelId, toClientAntigravityQuotaModelId, } from "../../config/antigravityModelAliases.ts"; -import { isUserCallableAgyModelId } from "../../config/agyModels.ts"; +import { isDiscoverableAgyModelId } from "../../config/agyModels.ts"; import { getDbInstance } from "@/lib/db/core"; import { applyAntigravityClientProfileHeaders, @@ -43,7 +43,11 @@ import { } from "../codeAssistSubscription.ts"; import { toRecord, toNumber, getFieldValue } from "./scalars.ts"; import { type UsageQuota, parseResetTime } from "./quota.ts"; -import { fetchAndParseAntigravityWeeklyQuotas } from "./antigravityWeeklyQuota.ts"; +import { + fetchAndParseAntigravityQuotaSummary, + type AntigravityQuotaGroup, +} from "./antigravityWeeklyQuota.ts"; +import { getAntigravityQuotaFamily } from "../antigravityQuotaFamily.ts"; type JsonRecord = Record; type SubscriptionCacheEntry = { @@ -612,10 +616,10 @@ export async function getAntigravityUsage( ); } - const [data, userQuotaData, weeklyQuotas] = await Promise.all([ + const [data, userQuotaData, quotaSummary] = await Promise.all([ fetchAntigravityAvailableModelsCached(accessToken, projectId, clientProfile, options), fetchAntigravityUserQuotaCached(accessToken, projectId, clientProfile, options), - fetchAndParseAntigravityWeeklyQuotas(accessToken, projectId, clientProfile, options), // #4017 + fetchAndParseAntigravityQuotaSummary(accessToken, projectId, clientProfile, options), ]); const dataObj = toRecord(data); if (dataObj.__antigravityForbidden === true) { @@ -633,6 +637,13 @@ export async function getAntigravityUsage( } } const quotas: Record = {}; + const liveModelIds: string[] = []; + const seenLiveModelIds = new Set(); + const addLiveModelId = (modelId: string): void => { + if (seenLiveModelIds.has(modelId)) return; + seenLiveModelIds.add(modelId); + liveModelIds.push(modelId); + }; // Parse per-model quota info from fetchAvailableModels response. for (const [rawModelKey, infoValue] of Object.entries(modelEntries)) { @@ -645,13 +656,14 @@ export async function getAntigravityUsage( !modelKey || info.isInternal === true || !(provider === "agy" - ? isUserCallableAgyModelId(modelKey) + ? isDiscoverableAgyModelId(modelKey) : isDiscoverableAntigravityModelId(modelKey)) || Object.keys(quotaInfo).length === 0 ) { continue; } + addLiveModelId(modelKey); const liveQuota = userQuotaEntries.get(modelKey); const quotaSource = liveQuota || quotaInfo; const rawFraction = toNumber(quotaSource.remainingFraction, -1); @@ -698,11 +710,12 @@ export async function getAntigravityUsage( if ( quotas[modelKey] || !(provider === "agy" - ? isUserCallableAgyModelId(modelKey) + ? isDiscoverableAgyModelId(modelKey) : isDiscoverableAntigravityModelId(modelKey)) ) { continue; } + addLiveModelId(modelKey); const rawFraction = toNumber(bucket.remainingFraction, -1); if (rawFraction < 0) continue; const remainingFraction = Math.max(0, Math.min(1, rawFraction)); @@ -722,11 +735,23 @@ export async function getAntigravityUsage( }; } + const quotaGroups: AntigravityQuotaGroup[] = quotaSummary.groups.map((group) => ({ + ...group, + models: liveModelIds.filter((modelId) => { + const family = getAntigravityQuotaFamily(modelId); + return ( + (group.id === "gemini" && family === "gemini") || + (group.id === "claude_gpt" && family === "claude") + ); + }), + })); + return { plan: getAntigravityPlanLabel(subscriptionInfo, providerSpecificData), + quotaGroups, quotas: { ...quotas, - ...weeklyQuotas, + ...quotaSummary.quotas, ...(creditBalance !== null && { credits: { used: 0, diff --git a/open-sse/services/usage/antigravityWeeklyQuota.ts b/open-sse/services/usage/antigravityWeeklyQuota.ts index a806eb464528..5c398d9168e0 100644 --- a/open-sse/services/usage/antigravityWeeklyQuota.ts +++ b/open-sse/services/usage/antigravityWeeklyQuota.ts @@ -1,22 +1,11 @@ /** - * usage/antigravityWeeklyQuota.ts — Antigravity weekly-quota fetcher + parser (#4017). + * Antigravity quota-summary fetcher and parser. * - * Antigravity enforces both a 5-hour window (already surfaced per-model by - * `getAntigravityUsage()` via `retrieveUserQuota`) and a separate weekly window. - * The weekly window is NOT part of the per-model `retrieveUserQuota` response — - * it lives in a distinct upstream RPC, `v1internal:retrieveUserQuotaSummary`, - * which groups models into families ("Gemini Models", "Claude and GPT models") - * and reports one bucket per family per window (5h + weekly), keyed by a - * `bucketId`/`displayName` pair rather than by individual modelId. There is no - * dedicated window-type field on the bucket — the window is inferred from the - * bucketId/displayName text (matches the reverse-engineered shape documented by - * third-party Antigravity clients, since Google does not publish this API). - * - * This module is a small, self-contained leaf so `usage/antigravity.ts` stays a - * thin caller: fetch (cached, best-effort) + pure parse, mirroring the existing - * `fetchAntigravityUserQuotaCached` pattern. + * `retrieveUserQuotaSummary` reports quota per model family rather than per model. + * Google currently returns Gemini and Claude/GPT families, with independent 5-hour and + * weekly windows. Per-model `retrieveUserQuota` remains the fallback when this optional, + * undocumented RPC is unavailable. */ - import { ANTIGRAVITY_RUNTIME_BASE_URLS } from "../../config/antigravityUpstream.ts"; import { toRecord, toNumber } from "./scalars.ts"; import { type UsageQuota, parseResetTime } from "./quota.ts"; @@ -25,26 +14,43 @@ import type { AntigravityClientProfile } from "../antigravityClientProfile.ts"; type JsonRecord = Record; -interface AntigravityWeeklyQuotaOptions { +interface AntigravityQuotaSummaryOptions { forceRefresh?: boolean; } -const WEEKLY_QUOTA_CACHE_TTL_MS = 60 * 1000; -const _weeklyQuotaCache = new Map(); -const _weeklyQuotaInflight = new Map>(); - -// Self-contained purge timer — this leaf owns its own cache, so it owns the cleanup too -// (same pattern as usage/antigravity.ts's module-level caches). -const _weeklyQuotaCacheCleanupTimer = setInterval( - () => { - const now = Date.now(); - for (const [key, entry] of _weeklyQuotaCache) { - if (now - entry.fetchedAt > WEEKLY_QUOTA_CACHE_TTL_MS) _weeklyQuotaCache.delete(key); - } - }, - 5 * 60 * 1000 -); -_weeklyQuotaCacheCleanupTimer.unref?.(); +export type AntigravityQuotaWindowName = "session" | "weekly"; +export type AntigravityQuotaGroupId = "gemini" | "claude_gpt" | string; + +export type AntigravityQuotaWindow = UsageQuota & { + window: AntigravityQuotaWindowName; + quotaGroupId: AntigravityQuotaGroupId; + quotaAggregate: true; + quotaSource: "retrieveUserQuotaSummary"; +}; + +export type AntigravityQuotaGroup = { + id: AntigravityQuotaGroupId; + displayName: string; + windows: Partial>; + models: string[]; +}; + +export type AntigravityQuotaSummary = { + groups: AntigravityQuotaGroup[]; + quotas: Record; +}; + +const QUOTA_CACHE_TTL_MS = 60 * 1000; +const quotaCache = new Map(); +const quotaInflight = new Map>(); + +const cleanupTimer = setInterval(() => { + const cutoff = Date.now() - QUOTA_CACHE_TTL_MS * 2; + for (const [key, value] of quotaCache) { + if (value.fetchedAt < cutoff) quotaCache.delete(key); + } +}, QUOTA_CACHE_TTL_MS); +cleanupTimer.unref?.(); function buildCacheKey( accessToken: string, @@ -54,156 +60,192 @@ function buildCacheKey( return `${accessToken.substring(0, 16)}:${projectId || "default"}:${clientProfile}`; } -/** - * Fetch the weekly-quota-bearing `retrieveUserQuotaSummary` response (cached, best-effort). - * Returns `null` on any failure — callers must treat this as optional data, never a hard - * dependency, since the RPC is undocumented and may not be available for every account/tier. - */ export async function fetchAntigravityUserQuotaSummaryCached( accessToken: string, projectId?: string | null, clientProfile: AntigravityClientProfile = "ide", - options: AntigravityWeeklyQuotaOptions = {} -): Promise { - if (!accessToken || !projectId) return null; - + options: AntigravityQuotaSummaryOptions = {} +): Promise { const cacheKey = buildCacheKey(accessToken, projectId, clientProfile); - const cached = _weeklyQuotaCache.get(cacheKey); - if ( - !options.forceRefresh && - cached && - Date.now() - cached.fetchedAt < WEEKLY_QUOTA_CACHE_TTL_MS - ) { - return cached.data; + if (!options.forceRefresh) { + const cached = quotaCache.get(cacheKey); + if (cached && Date.now() - cached.fetchedAt < QUOTA_CACHE_TTL_MS) return cached.data; + const pending = quotaInflight.get(cacheKey); + if (pending) return pending; + } else { + quotaCache.delete(cacheKey); } - const inflight = _weeklyQuotaInflight.get(cacheKey); - if (inflight !== undefined) return inflight; - const promise = (async () => { - try { - for (const baseUrl of ANTIGRAVITY_RUNTIME_BASE_URLS) { - const response = await fetch( - `${baseUrl}/v1internal:retrieveUserQuotaSummary`, - { - method: "POST", - headers: getAntigravityContentHeaders(clientProfile, accessToken), - body: JSON.stringify({ project: projectId }), - signal: AbortSignal.timeout(10000), - } - ); - + for (const baseUrl of ANTIGRAVITY_RUNTIME_BASE_URLS) { + try { + const response = await fetch(`${baseUrl}/v1internal:retrieveUserQuotaSummary`, { + method: "POST", + headers: getAntigravityContentHeaders(clientProfile, accessToken), + body: JSON.stringify(projectId ? { project: projectId } : {}), + }); if (!response.ok) continue; - const data = await response.json(); - _weeklyQuotaCache.set(cacheKey, { data, fetchedAt: Date.now() }); + quotaCache.set(cacheKey, { data, fetchedAt: Date.now() }); return data; + } catch { + // Try the next Antigravity runtime endpoint. } - return null; - } catch { - return null; } - })().finally(() => { - _weeklyQuotaInflight.delete(cacheKey); - }); + return null; + })().finally(() => quotaInflight.delete(cacheKey)); - _weeklyQuotaInflight.set(cacheKey, promise); + quotaInflight.set(cacheKey, promise); return promise; } -/** Matches a bucket's combined bucketId+displayName text against a window keyword. */ -function bucketMatchesWindow(bucket: JsonRecord, keyword: RegExp): boolean { - const text = `${String(bucket.bucketId || "")} ${String(bucket.displayName || "")}`.toLowerCase(); - return keyword.test(text); +function extractSummaryGroups(summaryData: unknown): unknown[] { + const root = toRecord(summaryData); + if (Array.isArray(root.groups)) return root.groups; + const nested = toRecord(root.quotaSummary); + return Array.isArray(nested.groups) ? nested.groups : []; } -const WEEKLY_KEYWORD = /\bweekly\b/; +function normalizeGroup( + displayName: unknown, + id: unknown +): { + id: AntigravityQuotaGroupId; + displayName: string; +} | null { + const text = `${String(id || "")} ${String(displayName || "")}`.toLowerCase(); + if (text.includes("gemini")) return { id: "gemini", displayName: "Gemini Models" }; + if (text.includes("claude") || text.includes("gpt")) { + return { id: "claude_gpt", displayName: "Claude & GPT Models" }; + } -/** Turns a group displayName (e.g. "Gemini Models", "Claude and GPT models") into a quota key. */ -function slugifyGroupWeeklyKey(displayName: string): string | null { - const cleaned = String(displayName || "") + const fallback = String(displayName || id || "") .toLowerCase() - .replace(/\bmodels?\b/g, "") - .replace(/\band\b/g, " ") .replace(/[^a-z0-9]+/g, "_") .replace(/^_+|_+$/g, ""); - return cleaned ? `${cleaned}_weekly` : null; + return fallback ? { id: fallback, displayName: String(displayName || id).trim() } : null; } -/** - * Parse the raw `retrieveUserQuotaSummary` response into weekly `UsageQuota` entries, - * one per model family group. Tolerant of the two response envelopes third-party - * Antigravity clients have observed (`groups[]` at the top level, or nested under - * `quotaSummary.groups[]`) since the RPC is undocumented and unversioned by Google. - */ -export function parseAntigravityWeeklyQuotas(summaryData: unknown): Record { - const quotas: Record = {}; - for (const groupValue of extractSummaryGroups(summaryData)) { - const entry = parseGroupWeeklyQuota(toRecord(groupValue)); - if (entry) quotas[entry.key] = entry.quota; - } - return quotas; -} +/** Map a summary bucket into a supported quota window. */ +export function normalizeAntigravityQuotaWindow( + bucket: JsonRecord +): AntigravityQuotaWindowName | null { + const explicit = String(bucket.window || "") + .trim() + .toLowerCase(); + if (explicit === "5h" || explicit === "session") return "session"; + if (explicit === "weekly" || explicit === "7d") return "weekly"; -/** Extracts `groups[]` from either observed response envelope (top-level or nested). */ -function extractSummaryGroups(summaryData: unknown): unknown[] { - const root = toRecord(summaryData); - if (Array.isArray(root.groups)) return root.groups; - const nested = toRecord(root.quotaSummary).groups; - return Array.isArray(nested) ? nested : []; + const text = `${String(bucket.bucketId || "")} ${String(bucket.displayName || "")}`.toLowerCase(); + if (/\b5h\b|five[-\s]?hour|\bsession\b/.test(text)) return "session"; + if (/\bweekly\b|\b7d\b|seven[-\s]?day/.test(text)) return "weekly"; + return null; } -/** Parses one model-family group into its weekly quota entry, or null when absent/invalid. */ -function parseGroupWeeklyQuota(group: JsonRecord): { key: string; quota: UsageQuota } | null { - const buckets = Array.isArray(group.buckets) ? group.buckets : []; - const weeklyBucketValue = buckets.find( - (b) => b && typeof b === "object" && bucketMatchesWindow(toRecord(b), WEEKLY_KEYWORD) +function toQuotaWindow( + groupId: AntigravityQuotaGroupId, + window: AntigravityQuotaWindowName, + bucket: JsonRecord +): AntigravityQuotaWindow | null { + if (bucket.disabled === true) return null; + + // retrieveUserQuotaSummary currently reports consumption under `remaining`, unlike + // retrieveUserQuota's flat bucket shape. Accept both so an upstream envelope change + // cannot silently erase every family window from Provider Quota. + const remainingData = toRecord(bucket.remaining); + const rawFraction = toNumber( + remainingData.remainingFraction ?? bucket.remainingFraction, + -1 ); - if (!weeklyBucketValue) return null; - - const weeklyBucket = toRecord(weeklyBucketValue); - if (weeklyBucket.disabled === true) return null; - - const key = slugifyGroupWeeklyKey(String(group.displayName || "")); - if (!key) return null; - - const rawFraction = toNumber(weeklyBucket.remainingFraction, -1); if (rawFraction < 0) return null; const remainingFraction = Math.max(0, Math.min(1, rawFraction)); - const resetAt = parseResetTime(weeklyBucket.resetTime); - const isUnlimited = !resetAt && remainingFraction >= 1; - const QUOTA_NORMALIZED_BASE = 1000; - const total = QUOTA_NORMALIZED_BASE; + const resetAt = parseResetTime(remainingData.resetTime ?? bucket.resetTime); + const unlimited = !resetAt && remainingFraction >= 1; + const total = 1000; const remaining = Math.round(total * remainingFraction); return { - key, - quota: { - used: isUnlimited ? 0 : Math.max(0, total - remaining), - total: isUnlimited ? 0 : total, - resetAt, - remainingPercentage: isUnlimited ? 100 : remainingFraction * 100, - unlimited: isUnlimited, - fractionReported: true, - quotaSource: "retrieveUserQuota", - displayName: String(group.displayName || "").trim() || undefined, - }, + window, + quotaGroupId: groupId, + quotaAggregate: true, + used: unlimited ? 0 : Math.max(0, total - remaining), + total: unlimited ? 0 : total, + resetAt, + remainingPercentage: unlimited ? 100 : remainingFraction * 100, + unlimited, + fractionReported: true, + quotaSource: "retrieveUserQuotaSummary", }; } -/** Fetch + parse in one call — the only entry point `usage/antigravity.ts` needs. */ +/** Parse all supported family/window buckets, preserving the group structure and flat projection. */ +export function parseAntigravityQuotaSummary(summaryData: unknown): AntigravityQuotaSummary { + const groups: AntigravityQuotaGroup[] = []; + const quotas: Record = {}; + + for (const rawGroup of extractSummaryGroups(summaryData)) { + const group = toRecord(rawGroup); + const normalized = normalizeGroup(group.displayName, group.groupId ?? group.id); + if (!normalized) continue; + + const windows: AntigravityQuotaGroup["windows"] = {}; + const buckets = Array.isArray(group.buckets) ? group.buckets : []; + for (const rawBucket of buckets) { + const bucket = toRecord(rawBucket); + const window = normalizeAntigravityQuotaWindow(bucket); + if (!window || windows[window]) continue; + const parsed = toQuotaWindow(normalized.id, window, bucket); + if (!parsed) continue; + windows[window] = parsed; + quotas[`${normalized.id}_${window}`] = parsed; + } + + if (Object.keys(windows).length > 0) { + groups.push({ ...normalized, windows, models: [] }); + } + } + + return { groups, quotas }; +} + +/** Compatibility wrapper for existing callers that only need weekly aggregate entries. */ +export function parseAntigravityWeeklyQuotas(summaryData: unknown): Record { + const summary = parseAntigravityQuotaSummary(summaryData); + return Object.fromEntries( + Object.entries(summary.quotas).filter(([, quota]) => quota.window === "weekly") + ); +} + +export async function fetchAndParseAntigravityQuotaSummary( + accessToken: string, + projectId: string | undefined | null, + clientProfile: AntigravityClientProfile = "ide", + options: AntigravityQuotaSummaryOptions = {} +): Promise { + const data = await fetchAntigravityUserQuotaSummaryCached( + accessToken, + projectId, + clientProfile, + options + ); + return parseAntigravityQuotaSummary(data); +} + +/** Compatibility wrapper retained for leaf-module consumers. */ export async function fetchAndParseAntigravityWeeklyQuotas( accessToken: string, projectId: string | undefined | null, clientProfile: AntigravityClientProfile = "ide", - options: AntigravityWeeklyQuotaOptions = {} + options: AntigravityQuotaSummaryOptions = {} ): Promise> { - const data = await fetchAntigravityUserQuotaSummaryCached( + const summary = await fetchAndParseAntigravityQuotaSummary( accessToken, projectId, clientProfile, options ); - return parseAntigravityWeeklyQuotas(data); + return Object.fromEntries( + Object.entries(summary.quotas).filter(([, quota]) => quota.window === "weekly") + ); } diff --git a/open-sse/services/usage/quota.ts b/open-sse/services/usage/quota.ts index ce9642c902e3..5237be161c47 100644 --- a/open-sse/services/usage/quota.ts +++ b/open-sse/services/usage/quota.ts @@ -23,7 +23,8 @@ export type UsageQuota = { * NOT a confirmed-exhausted state. Antigravity-specific. */ fractionReported?: boolean; - quotaSource?: "retrieveUserQuota" | "fetchAvailableModels" | "localUsageHistory"; + quotaSource?: + "retrieveUserQuota" | "retrieveUserQuotaSummary" | "fetchAvailableModels" | "localUsageHistory"; displayName?: string; details?: Array<{ name: string; diff --git a/open-sse/translator/request/claude-to-gemini.ts b/open-sse/translator/request/claude-to-gemini.ts index 9137aec0f09b..b0b01e0c8a14 100644 --- a/open-sse/translator/request/claude-to-gemini.ts +++ b/open-sse/translator/request/claude-to-gemini.ts @@ -1,9 +1,6 @@ import { register } from "../registry.ts"; import { FORMATS } from "../formats.ts"; -import { - DEFAULT_SAFETY_SETTINGS, - cleanJSONSchemaForAntigravity, -} from "../helpers/geminiHelper.ts"; +import { DEFAULT_SAFETY_SETTINGS, cleanJSONSchemaForAntigravity } from "../helpers/geminiHelper.ts"; import { buildGeminiTools, sanitizeGeminiToolName } from "../helpers/geminiToolsSanitizer.ts"; import { buildGeminiThoughtSignatureKey, @@ -14,6 +11,7 @@ import { getModelSpec } from "../../../src/shared/constants/modelSpecs.ts"; import { buildChangedToolNameMap, buildHistoricalToolResultContext, + getGeminiThinkingLevel, mergeConsecutiveSameRoleContents, type GeminiContent, } from "./openai-to-gemini/helpers.ts"; @@ -241,9 +239,15 @@ export function claudeToGeminiRequest(model, body, stream, credentials = null) { } // ── Thinking config ──────────────────────────────────────────── - // Priority: thinking.budget_tokens (Claude native) > output_config.effort (Claude Code). + // Priority: thinkingLevel (Gemini 3.8) > thinking.budget_tokens (Claude native) > output_config.effort (Claude Code). + const thinkingLevel = getGeminiThinkingLevel(model, body.output_config?.effort); if (model.startsWith("gemma-4")) { // gemma-4 models returns - 400: Thinking budget is not supported for this model + } else if (thinkingLevel) { + result.generationConfig.thinkingConfig = { + thinkingLevel, + includeThoughts: body.output_config?.effort !== "none", + }; } else if (body.thinking?.type === "enabled" && typeof body.thinking.budget_tokens === "number") { // typeof check ensures only numeric budget_tokens triggers the thinking path; // non-numeric values (e.g. string "auto") fall through to the effort-based path. diff --git a/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index d68946369b5e..e98451706834 100644 --- a/open-sse/translator/request/openai-to-gemini.ts +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -32,6 +32,7 @@ import { buildChangedToolNameMap, extractClientThoughtSignature, deepCleanUndefined, + getGeminiThinkingLevel, applyAntigravityGenerationDefaults, stringifyHistoricalToolArguments, buildInertHistoricalToolCallText, @@ -214,14 +215,20 @@ function openaiToGeminiBase( if (model.startsWith("gemma-4")) { // gemma-4 models returns - 400: Thinking budget is not supported for this model } else { - // 1. OpenAI format: reasoning_effort (none/low/medium/high/auto/max/xhigh) - // "auto", "max", and "xhigh" are clamped to the high-tier budget because Gemini - // does not accept these strings directly. "auto" signals "use max reasonable effort" - // which maps to high. "max"/"xhigh" exceed Gemini's accepted range and are clamped. - // "none" maps to budget 0 — an explicit, documented off-switch (#6813 defect 2), - // distinct from the no-knob-at-all default-injection case below (#4170). - // Port of decolua/9router#2043 by @nguyenxvotanminh3. - if (body.reasoning_effort) { + const thinkingLevel = getGeminiThinkingLevel(model, body.reasoning_effort); + if (thinkingLevel) { + result.generationConfig.thinkingConfig = { + thinkingLevel, + includeThoughts: body.reasoning_effort !== "none", + }; + } else if (body.reasoning_effort) { + // 1. OpenAI format: reasoning_effort (none/low/medium/high/auto/max/xhigh) + // "auto", "max", and "xhigh" are clamped to the high-tier budget because Gemini + // does not accept these strings directly. "auto" signals "use max reasonable effort" + // which maps to high. "max"/"xhigh" exceed Gemini's accepted range and are clamped. + // "none" maps to budget 0 — an explicit, documented off-switch (#6813 defect 2), + // distinct from the no-knob-at-all default-injection case below (#4170). + // Port of decolua/9router#2043 by @nguyenxvotanminh3. const highBudget = capThinkingBudget(model, 32768); const budgetMap: Record = { none: 0, @@ -245,29 +252,30 @@ function openaiToGeminiBase( thinkingBudget: budget, includeThoughts: budget !== 0, }; - } - // 2. Claude format: thinking (type: enabled, budget_tokens) - // Use an explicit numeric check (not truthy) so an explicit `budget_tokens: 0` — the - // natural way to disable thinking — is honored as thinkingBudget 0 instead of being - // dropped and falling through to the default injection below (#6813). A zero budget - // yields no thoughts, so includeThoughts is only set for a non-zero budget. - const thinking = body.thinking as { type?: string; budget_tokens?: number } | undefined; - if (thinking?.type === "enabled" && typeof thinking.budget_tokens === "number") { - // typeof check ensures only numeric budget_tokens triggers thinking path; - // non-numeric values (e.g. string "auto") fall through to the effort-based path. - const cappedBudget = capThinkingBudget(model, thinking.budget_tokens); - // Only send thinkingConfig if the model supports thinking via budget. - // Models with thinkingBudgetCap:0 (e.g. gemini-3-flash) reject - // thinkingConfig even when capped to 0. The supportsThinking flag - // tracks thinkingLevel support, not thinkingBudget; use thinkingBudgetCap - // as the reliable indicator (gemini-2.5-flash has supportsThinking:false - // but thinkingBudgetCap:24576, meaning it supports thinking via budget). - // Models not in MODEL_SPECS (thinkingBudgetCap=undefined) default to allowed. - if (cappedBudget > 0 || getModelSpec(model)?.thinkingBudgetCap !== 0) { - result.generationConfig.thinkingConfig = { - thinkingBudget: cappedBudget, - includeThoughts: cappedBudget !== 0, - }; + } else { + // 2. Claude format: thinking (type: enabled, budget_tokens) + // Use an explicit numeric check (not truthy) so an explicit `budget_tokens: 0` — the + // natural way to disable thinking — is honored as thinkingBudget 0 instead of being + // dropped and falling through to the default injection below (#6813). A zero budget + // yields no thoughts, so includeThoughts is only set for a non-zero budget. + const thinking = body.thinking as { type?: string; budget_tokens?: number } | undefined; + if (thinking?.type === "enabled" && typeof thinking.budget_tokens === "number") { + // typeof check ensures only numeric budget_tokens triggers thinking path; + // non-numeric values (e.g. string "auto") fall through to the effort-based path. + const cappedBudget = capThinkingBudget(model, thinking.budget_tokens); + // Only send thinkingConfig if the model supports thinking via budget. + // Models with thinkingBudgetCap:0 (e.g. gemini-3-flash) reject + // thinkingConfig even when capped to 0. The supportsThinking flag + // tracks thinkingLevel support, not thinkingBudget; use thinkingBudgetCap + // as the reliable indicator (gemini-2.5-flash has supportsThinking:false + // but thinkingBudgetCap:24576, meaning it supports thinking via budget). + // Models not in MODEL_SPECS (thinkingBudgetCap=undefined) default to allowed. + if (cappedBudget > 0 || getModelSpec(model)?.thinkingBudgetCap !== 0) { + result.generationConfig.thinkingConfig = { + thinkingBudget: cappedBudget, + includeThoughts: cappedBudget !== 0, + }; + } } } } @@ -281,23 +289,32 @@ function openaiToGeminiBase( // unconditional (no-knob-at-all still gets includeThoughts:true); the explicit // "reasoning_effort: none" off-switch above (#6813) is the supported opt-out. if (!result.generationConfig.thinkingConfig) { - const modelLower = model.toLowerCase(); - if ( - modelLower.includes("gemini") && - !modelLower.includes("gemini-1") && - (!modelLower.includes("gemini-2.0") || modelLower.includes("thinking")) && - // Skip thinkingConfig for models that don't support thinking via budget. - // Models with thinkingBudgetCap:0 (e.g. gemini-3-flash) reject - // thinkingConfig. Use thinkingBudgetCap (not supportsThinking) as the - // reliable indicator; gemini-2.5-flash has supportsThinking:false but - // thinkingBudgetCap:24576, meaning it supports thinking via budget. - // Models not in MODEL_SPECS (thinkingBudgetCap=undefined) default to allowed. - getModelSpec(model)?.thinkingBudgetCap !== 0 - ) { + const thinkingLevel = getGeminiThinkingLevel(model); + if (thinkingLevel) { result.generationConfig.thinkingConfig = { - thinkingBudget: getDefaultThinkingBudget(model) || capThinkingBudget(model, 24576), + thinkingLevel, includeThoughts: true, }; + } else { + const modelLower = model.toLowerCase(); + if ( + modelLower.includes("gemini") && + !modelLower.includes("gemini-1") && + (!modelLower.includes("gemini-2.0") || modelLower.includes("thinking")) && + // Skip thinkingConfig for models that don't support thinking via budget. + // Models with thinkingBudgetCap:0 (e.g. gemini-3-flash) reject + // thinkingConfig. Use thinkingBudgetCap (not supportsThinking) as the + // reliable indicator; gemini-2.5-flash has supportsThinking:false but + // thinkingBudgetCap:24576, meaning it supports thinking via budget. + // Models with thinkingLevel (like 3.8) are handled above and omit thinkingBudget. + getModelSpec(model)?.thinkingBudgetCap !== 0 && + getModelSpec(model)?.thinkingBudgetCap !== undefined + ) { + result.generationConfig.thinkingConfig = { + thinkingBudget: getDefaultThinkingBudget(model) || capThinkingBudget(model, 24576), + includeThoughts: true, + }; + } } } @@ -698,7 +715,10 @@ function wrapInCloudCodeEnvelope(model, cloudCodeRequest, credentials = null) { sessionId: getAntigravitySessionId(credentials), contents: cloudCodeRequest.contents, systemInstruction: cloudCodeRequest.systemInstruction, - generationConfig: applyAntigravityGenerationDefaults(cloudCodeRequest.generationConfig), + generationConfig: applyAntigravityGenerationDefaults( + cloudCodeRequest.generationConfig, + cleanModel + ), tools: cloudCodeRequest.tools, safetySettings: cloudCodeRequest.safetySettings, }, diff --git a/open-sse/translator/request/openai-to-gemini/helpers.ts b/open-sse/translator/request/openai-to-gemini/helpers.ts index 092a857a2cba..045296dd040a 100644 --- a/open-sse/translator/request/openai-to-gemini/helpers.ts +++ b/open-sse/translator/request/openai-to-gemini/helpers.ts @@ -11,8 +11,9 @@ export type GeminiGenerationConfig = { topK?: unknown; maxOutputTokens?: unknown; thinkingConfig?: { - thinkingBudget: number; - includeThoughts: boolean; + thinkingBudget?: number; + thinkingLevel?: "LOW" | "MEDIUM" | "HIGH" | string; + includeThoughts?: boolean; }; responseMimeType?: string; responseSchema?: unknown; @@ -89,8 +90,47 @@ export function deepCleanUndefined(value: unknown, depth = 0): void { } } -export function applyAntigravityGenerationDefaults(generationConfig: GeminiGenerationConfig) { +export function getGeminiThinkingLevel( + model: string, + reasoningEffort?: unknown +): "LOW" | "MEDIUM" | "HIGH" | null { + const lower = model.toLowerCase(); + + // Gemini 3.8 models use thinkingLevel ("LOW" | "MEDIUM" | "HIGH"), not numeric thinkingBudget + if (!lower.startsWith("gemini-3.8-flash") && !lower.includes("gemini-3.8-flash")) { + return null; + } + + if (typeof reasoningEffort === "string") { + const effort = reasoningEffort.toLowerCase(); + if (effort === "low") return "LOW"; + if (effort === "medium") return "MEDIUM"; + if (effort === "high" || effort === "auto" || effort === "max" || effort === "xhigh") { + return "HIGH"; + } + } + + if (lower.endsWith("-low")) return "LOW"; + if (lower.endsWith("-medium")) return "MEDIUM"; + if (lower.endsWith("-high")) return "HIGH"; + + return "MEDIUM"; +} + +export function applyAntigravityGenerationDefaults( + generationConfig: GeminiGenerationConfig, + model?: string +) { const config = { ...generationConfig }; + const isGemini38 = typeof model === "string" && model.toLowerCase().includes("gemini-3.8-flash"); + + if (isGemini38) { + delete config.topK; + delete config.topP; + delete config.temperature; + return config; + } + if (config.topK === undefined) { config.topK = 40; } diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/AntigravityQuotaGroups.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/AntigravityQuotaGroups.tsx new file mode 100644 index 000000000000..a3a58c361b48 --- /dev/null +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/AntigravityQuotaGroups.tsx @@ -0,0 +1,208 @@ +"use client"; + +import { useState } from "react"; +import { formatCountdown, getBarColor } from "./utils"; + +export type QuotaWindow = { + remainingPercentage?: number; + resetAt?: string | null; +}; + +export type QuotaGroup = { + id: string; + displayName: string; + windows?: { session?: QuotaWindow; weekly?: QuotaWindow }; + models?: string[]; +}; + +export function resolveAntigravityQuotaGroups( + rawGroups?: Array>, + quotas?: any[] +): QuotaGroup[] { + if (Array.isArray(rawGroups) && rawGroups.length > 0) { + return rawGroups as QuotaGroup[]; + } + + if (!Array.isArray(quotas) || quotas.length === 0) { + return []; + } + + const geminiSession = quotas.find( + (q) => q.name === "gemini_session" || q.modelKey === "gemini_session" + ); + const geminiWeekly = quotas.find( + (q) => q.name === "gemini_weekly" || q.modelKey === "gemini_weekly" + ); + const claudeSession = quotas.find( + (q) => q.name === "claude_gpt_session" || q.modelKey === "claude_gpt_session" + ); + const claudeWeekly = quotas.find( + (q) => q.name === "claude_gpt_weekly" || q.modelKey === "claude_gpt_weekly" + ); + + const isGeminiModel = (name: string) => + name.includes("gemini") && name !== "gemini_session" && name !== "gemini_weekly"; + const isClaudeModel = (name: string) => + (name.includes("claude") || name.includes("gpt")) && + name !== "claude_gpt_session" && + name !== "claude_gpt_weekly"; + + const geminiModels = quotas + .map((q) => q.modelKey || q.name || "") + .filter(isGeminiModel); + const claudeModels = quotas + .map((q) => q.modelKey || q.name || "") + .filter(isClaudeModel); + + const fallbackGeminiSession = !geminiSession + ? quotas.find((q) => isGeminiModel(q.modelKey || q.name || "")) + : undefined; + const fallbackClaudeSession = !claudeSession + ? quotas.find((q) => isClaudeModel(q.modelKey || q.name || "")) + : undefined; + + const effectiveGeminiSession = geminiSession || fallbackGeminiSession; + const effectiveClaudeSession = claudeSession || fallbackClaudeSession; + + const groups: QuotaGroup[] = []; + + if (effectiveGeminiSession || geminiWeekly || geminiModels.length > 0) { + groups.push({ + id: "gemini", + displayName: "Gemini Models", + windows: { + ...(effectiveGeminiSession + ? { + session: { + remainingPercentage: effectiveGeminiSession.remainingPercentage, + resetAt: effectiveGeminiSession.resetAt, + }, + } + : {}), + ...(geminiWeekly + ? { + weekly: { + remainingPercentage: geminiWeekly.remainingPercentage, + resetAt: geminiWeekly.resetAt, + }, + } + : {}), + }, + models: geminiModels, + }); + } + + if (effectiveClaudeSession || claudeWeekly || claudeModels.length > 0) { + groups.push({ + id: "claude_gpt", + displayName: "Claude & GPT Models", + windows: { + ...(effectiveClaudeSession + ? { + session: { + remainingPercentage: effectiveClaudeSession.remainingPercentage, + resetAt: effectiveClaudeSession.resetAt, + }, + } + : {}), + ...(claudeWeekly + ? { + weekly: { + remainingPercentage: claudeWeekly.remainingPercentage, + resetAt: claudeWeekly.resetAt, + }, + } + : {}), + }, + models: claudeModels, + }); + } + + return groups; +} + +function formatModelName(model: string): string { + return model + .split("-") + .map((part) => (part === "gpt" ? "GPT" : part === "gemini" ? "Gemini" : part)) + .join(" "); +} + +function QuotaWindowRow({ label, window }: { label: string; window?: QuotaWindow }) { + if (!window) { + return

{label}: unavailable from upstream

; + } + + const remaining = Math.max(0, Math.min(100, Number(window.remainingPercentage) || 0)); + const countdown = formatCountdown(window.resetAt || null); + return ( +
+
+ {label} + {Math.round(remaining)}% left +
+
+
+
+ {countdown ? ( + Resets in {countdown} + ) : null} +
+ ); +} + +export default function AntigravityQuotaGroups({ groups }: { groups: QuotaGroup[] }) { + const [expandedGroups, setExpandedGroups] = useState>({}); + + return ( +
+ {groups.map((group) => { + const models = group.models || []; + const expanded = expandedGroups[group.id] === true; + const visibleModels = expanded ? models : models.slice(0, 4); + return ( +
+

{group.displayName}

+
+ + +
+
+

Models available: {models.length}

+ {visibleModels.map((model) => ( +

+ {formatModelName(model)} +

+ ))} + {models.length > visibleModels.length ? ( + + ) : expanded && models.length > 4 ? ( + + ) : null} +
+
+ ); + })} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCard.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCard.tsx index af76b84a773c..1ae1e7524fe7 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCard.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCard.tsx @@ -39,7 +39,10 @@ interface QuotaCardProps { plan?: string | null; message?: string | null; billing?: ProviderBillingStatus | null; - raw?: { billing?: ProviderBillingStatus | null }; + raw?: { + billing?: ProviderBillingStatus | null; + quotaGroups?: Array>; + }; stale?: { since?: string; reason?: string } | null; } | undefined; @@ -149,6 +152,7 @@ export default function QuotaCard({ /> = { USD: "$", @@ -136,6 +139,7 @@ export function shouldShowLoadingPlaceholder( interface Props { quotas: any[]; + quotaGroups?: Array>; providerId?: string; loading: boolean; error: string | null; @@ -321,6 +325,7 @@ function QuotaDetailRow({ export default function QuotaCardExpanded({ quotas, + quotaGroups = [], providerId, loading, error, @@ -346,6 +351,13 @@ export default function QuotaCardExpanded({ translateUsageOrFallback(t, key, fallback, values); const [expanded, setExpanded] = useState(false); + const resolvedAntigravityGroups = useMemo( + () => + providerId === "antigravity" || providerId === "agy" + ? resolveAntigravityQuotaGroups(quotaGroups, quotas) + : [], + [providerId, quotaGroups, quotas] + ); const sortedQuotas = useMemo( () => resolveQuotaDisplayOrder(providerId, quotas), [quotas, providerId] @@ -383,6 +395,9 @@ export default function QuotaCardExpanded({ error {error}
+ ) : (providerId === "antigravity" || providerId === "agy") && + resolvedAntigravityGroups.length > 0 ? ( + ) : quotas.length === 0 && message ? (
{message} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts index 7ce822a5d607..6820d085a0cf 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts @@ -448,6 +448,9 @@ function parseProviderQuotas(providerId: string, data: any) { } function sortProviderModelOrder(provider: string, quotas: any[]) { + // Antigravity/AGY model IDs are discovered live. Static ordering would rank a model + // Google ships tomorrow at 999 and bury it below the collapsed three-row cutoff. + if (provider === "antigravity" || provider === "agy") return; const modelOrder = getModelsByProviderId(provider); if (modelOrder.length === 0) return; const orderMap = new Map(modelOrder.map((m, i) => [m.id, i])); diff --git a/src/domain/quotaCache.ts b/src/domain/quotaCache.ts index 0065eb61ea44..e1fcdc5d923b 100644 --- a/src/domain/quotaCache.ts +++ b/src/domain/quotaCache.ts @@ -285,13 +285,14 @@ function isAntigravityQuotaExhausted( const quotaNames = Object.keys(entry.quotas || {}); if (quotaNames.length === 0) return entry.exhausted; const matchingWindows = resolveAntigravityQuotaWindowsForModel(quotaNames, requestedModel); - return ( - matchingWindows.length > 0 && - matchingWindows.every( - (windowName) => - getQuotaWindowStatus(connectionId, windowName, DEFAULT_QUOTA_THRESHOLD_PERCENT) - ?.reachedThreshold - ) + // Antigravity enforces both 5h and weekly windows for a family. A remaining + // 5h bucket cannot make an account usable when weekly is exhausted (or vice versa). + // Missing/unreported windows are absent from `matchingWindows` and deliberately fall + // back to the legacy per-model information rather than being treated as full quota. + return matchingWindows.some( + (windowName) => + getQuotaWindowStatus(connectionId, windowName, DEFAULT_QUOTA_THRESHOLD_PERCENT) + ?.reachedThreshold ); } diff --git a/src/lib/db/providerLimits.ts b/src/lib/db/providerLimits.ts index 677c067a6f72..44d4977ca181 100644 --- a/src/lib/db/providerLimits.ts +++ b/src/lib/db/providerLimits.ts @@ -30,6 +30,7 @@ export interface ProviderLimitsCacheEntry { source?: string | null; bankedResetCredits?: number; billing?: ProviderBillingStatus; + quotaGroups?: Array>; } const PROVIDER_LIMITS_CACHE_NAMESPACE = "providerLimitsCache"; @@ -71,6 +72,9 @@ function normalizeCacheEntry(value: unknown): ProviderLimitsCacheEntry | null { source: typeof record.source === "string" ? record.source : null, ...(Number.isFinite(bankedResetCredits) ? { bankedResetCredits } : {}), ...(billing ? { billing } : {}), + ...(Array.isArray(record.quotaGroups) + ? { quotaGroups: record.quotaGroups as Array> } + : {}), }; } diff --git a/src/lib/providerModels/reactiveModelSync.ts b/src/lib/providerModels/reactiveModelSync.ts index 479991ec3caa..5daf8c214ea5 100644 --- a/src/lib/providerModels/reactiveModelSync.ts +++ b/src/lib/providerModels/reactiveModelSync.ts @@ -5,14 +5,12 @@ * ships (or renames) a model, requests for it 404 upstream until somebody * re-freezes the catalog by hand (Gemini 3.7 Flash on Antigravity, zai-web * #7678, Claude Opus 4.8 #2979). The Antigravity executor calls - * maybeTriggerReactiveModelSync() on a model-not-found 404: this runs the - * existing discovery sync for that connection (loopback sync-models), so the - * fresh model list lands in the synced catalog and the next request resolves. + * maybeTriggerReactiveModelSync() or awaitReactiveModelSync() on a model-not-found 404: + * this runs the existing discovery sync for that connection (loopback sync-models), so the + * fresh model list lands in the synced catalog and requests can resolve. * * Guardrails: provider allow-list, per-connection cooldown, in-flight dedup — - * a burst of 404s triggers at most one sync per connection per window. The - * sync is fire-and-forget; failures only log (the scheduled auto-sync cycle - * stays the backstop). + * a burst of 404s triggers at most one sync per connection per window. */ import { @@ -29,7 +27,7 @@ const REACTIVE_SYNC_COOLDOWN_MS = 10 * 60 * 1000; let cooldownMs = REACTIVE_SYNC_COOLDOWN_MS; const lastTriggerAt = new Map(); -const inFlight = new Set(); +const inFlightPromises = new Map>(); type SyncFn = (connectionId: string, provider: string, baseUrl: string) => Promise; const defaultSyncFn: SyncFn = (connectionId, provider, baseUrl) => @@ -37,51 +35,100 @@ const defaultSyncFn: SyncFn = (connectionId, provider, baseUrl) => let syncFn: SyncFn = defaultSyncFn; /** - * Kick a discovery sync for the connection if the provider supports discovery - * and the connection is not cooling down / already syncing. Returns true when - * a sync was scheduled (fire-and-forget), false when the call was a no-op. + * Await a discovery sync for the connection (bounded by `timeoutMs`), returning + * true when the sync actually succeeded in time. + * + * Used by the Antigravity executor on a 404: when an upstream model was added + * between OmniRoute releases, awaiting the sync lets the current request retry + * once immediately instead of failing the first caller. */ -export function maybeTriggerReactiveModelSync(provider: string, connectionId: string): boolean { +export async function awaitReactiveModelSync( + provider: string, + connectionId: string, + timeoutMs = 5000 +): Promise { const providerId = provider.trim().toLowerCase(); const connection = connectionId.trim(); if (!REACTIVE_SYNC_PROVIDERS.has(providerId) || !connection) return false; const key = `${providerId}:${connection}`; const now = Date.now(); - const last = lastTriggerAt.get(key); - if (last !== undefined && now - last < cooldownMs) return false; - if (inFlight.has(key)) return false; + const last = lastTriggerAt.get(key) ?? 0; + const existing = inFlightPromises.get(key); + + if (existing) { + try { + return await Promise.race([ + existing, + new Promise((resolve) => setTimeout(() => resolve(false), timeoutMs)), + ]); + } catch { + return false; + } + } + + if (now - last < cooldownMs) { + return false; + } lastTriggerAt.set(key, now); - inFlight.add(key); - void (async () => { + const baseUrl = getModelSyncInternalBaseUrl(); + const promise = (async () => { try { - const ok = await syncFn(connection, providerId, getModelSyncInternalBaseUrl()); - console.log( - `[ReactiveModelSync] ${providerId} (${connection.slice(0, 8)}): discovery sync ${ - ok ? "succeeded" : "failed" - } after upstream model-not-found` - ); + return await syncFn(connection, providerId, baseUrl); } catch (err) { - console.warn( - `[ReactiveModelSync] ${providerId} (${connection.slice(0, 8)}): sync error —`, - (err as Error).message - ); + console.warn(`[ReactiveModelSync] Sync threw for ${key}:`, err); + return false; } finally { - inFlight.delete(key); + inFlightPromises.delete(key); } })(); + + inFlightPromises.set(key, promise); + + try { + return await Promise.race([ + promise, + new Promise((resolve) => setTimeout(() => resolve(false), timeoutMs)), + ]); + } catch { + return false; + } +} + +/** + * Kick a discovery sync for the connection if the provider supports discovery + * and the connection is not cooling down / already syncing. Returns true when + * a sync was scheduled (fire-and-forget), false when the call was a no-op. + */ +export function maybeTriggerReactiveModelSync(provider: string, connectionId: string): boolean { + const providerId = provider.trim().toLowerCase(); + const connection = connectionId.trim(); + if (!REACTIVE_SYNC_PROVIDERS.has(providerId) || !connection) return false; + + const key = `${providerId}:${connection}`; + const now = Date.now(); + const last = lastTriggerAt.get(key) ?? 0; + if (inFlightPromises.has(key) || now - last < cooldownMs) return false; + + void awaitReactiveModelSync(provider, connectionId); return true; } -/** Test helper: swap the sync implementation (null restores the loopback sync). */ +/** Test helper: override the cooldown window. */ +export function __setReactiveSyncCooldownForTests(ms: number): void { + cooldownMs = ms; +} + +/** Test helper: intercept the underlying sync call. */ export function __setReactiveSyncFnForTests(fn: SyncFn | null): void { syncFn = fn ?? defaultSyncFn; } -/** Test helper: clear cooldown/in-flight state and optionally shrink the cooldown. */ -export function __resetReactiveModelSyncForTests(testCooldownMs?: number): void { +/** Test helper: reset triggers and in-flight tracking. */ +export function __resetReactiveModelSyncForTests(ms?: number): void { + cooldownMs = typeof ms === "number" ? ms : REACTIVE_SYNC_COOLDOWN_MS; lastTriggerAt.clear(); - inFlight.clear(); - cooldownMs = typeof testCooldownMs === "number" ? testCooldownMs : REACTIVE_SYNC_COOLDOWN_MS; + inFlightPromises.clear(); + syncFn = defaultSyncFn; } diff --git a/src/lib/usage/providerLimits.ts b/src/lib/usage/providerLimits.ts index 2e0b8895dacd..70efa5ac81f6 100644 --- a/src/lib/usage/providerLimits.ts +++ b/src/lib/usage/providerLimits.ts @@ -38,6 +38,7 @@ import { normalizeUsageQuotasForProvider, sanitizeUsageQuotasForProvider, } from "./providerLimits/quotaNormalize"; +import { getCachedLiveModelIds, refreshLiveModelIds } from "./providerLimits/liveModelIds"; import { syncInChunksWithSpacing } from "./providerLimits/chunkedSpacingSync"; import { refreshAndUpdateCredentialsWithResolver, @@ -154,7 +155,11 @@ function sanitizeProviderLimitsCacheForConnection( if (!connection || !entry || !entry.quotas) return entry; if (connection.provider !== "antigravity" && connection.provider !== "agy") return entry; - const sanitizedQuotas = normalizeUsageQuotasForProvider(connection.provider, entry.quotas); + const sanitizedQuotas = normalizeUsageQuotasForProvider( + connection.provider, + entry.quotas, + getCachedLiveModelIds(connection.provider) + ); return sanitizedQuotas === entry.quotas ? entry : { ...entry, quotas: sanitizedQuotas }; } @@ -168,7 +173,12 @@ function shouldRefreshProviderLimitsCache( return ( !hasRetrieveUserQuotaSource(connection.provider, cache) || Object.keys(cache.quotas).some( - (quotaKey) => !isUsageQuotaKeyAllowed(connection.provider, quotaKey) + (quotaKey) => + !isUsageQuotaKeyAllowed( + connection.provider, + quotaKey, + getCachedLiveModelIds(connection.provider) + ) ) ); } @@ -704,6 +714,10 @@ export async function getSanitizedCachedProviderLimitsMap(): Promise< return { ...caches }; } + // Refresh the live-discovery snapshot the sync sanitizer reads, so a model the + // connection reports but the static catalog predates keeps its quota bucket. + await Promise.all([refreshLiveModelIds("antigravity"), refreshLiveModelIds("agy")]); + const byId = new Map(sanitizableConnections.map((conn) => [conn.id, conn])); const sanitized: Record = {}; for (const [connectionId, entry] of Object.entries(caches)) { @@ -747,7 +761,8 @@ async function fetchLiveProviderLimitsWithOptions( connection.provider, (await runWithProxyContext(apiKeyProxy?.proxy ?? null, () => getUsageForProvider(connection as unknown as JsonRecord, options) - )) as JsonRecord + )) as JsonRecord, + await refreshLiveModelIds(connection.provider) ); if (isRecord(usage.quotas)) { setQuotaCache(connectionId, connection.provider, usage.quotas); @@ -779,7 +794,8 @@ async function fetchLiveProviderLimitsWithOptions( let usageData = sanitizeUsageQuotasForProvider( conn.provider, - (await getUsageForProvider(conn as unknown as JsonRecord, options)) as JsonRecord + (await getUsageForProvider(conn as unknown as JsonRecord, options)) as JsonRecord, + await refreshLiveModelIds(conn.provider) ); // Reactive 401 recovery (on-demand/force path only): an unauthorized usage @@ -797,7 +813,8 @@ async function fetchLiveProviderLimitsWithOptions( await syncToCloudIfEnabled(); usageData = sanitizeUsageQuotasForProvider( conn.provider, - (await getUsageForProvider(conn as unknown as JsonRecord, options)) as JsonRecord + (await getUsageForProvider(conn as unknown as JsonRecord, options)) as JsonRecord, + await refreshLiveModelIds(conn.provider) ); } } @@ -963,6 +980,7 @@ export async function syncAllProviderLimits( const fetchOne = async (connection: ProviderConnectionLike) => { const existingCache = getProviderLimitsCache(connection.id); + await refreshLiveModelIds(connection.provider); const forceRefresh = source === "manual" || shouldRefreshProviderLimitsCache(connection, existingCache || undefined); diff --git a/src/lib/usage/providerLimits/liveModelIds.ts b/src/lib/usage/providerLimits/liveModelIds.ts new file mode 100644 index 000000000000..81fa1765d5a2 --- /dev/null +++ b/src/lib/usage/providerLimits/liveModelIds.ts @@ -0,0 +1,58 @@ +/** + * Per-provider snapshot of the model ids reported by live discovery. + * + * The static catalogs in `open-sse/config` (ANTIGRAVITY_PUBLIC_MODELS / AGY_PUBLIC_MODELS) + * are a cold-start fallback, not an allowlist: a tier Google ships between two OmniRoute + * releases is legitimate the moment a connection's discovery sync reports it. Quota + * normalization has sync entry points, so async callers refresh the snapshot first and the + * sync ones read it afterwards. An empty snapshot degrades to the static lists — never to + * "everything allowed". + */ +import { getActiveSyncedCatalog } from "@/lib/db/models/activeSyncedCatalog"; + +const SNAPSHOT_TTL_MS = 60_000; +const EMPTY_IDS: ReadonlySet = new Set(); +// Only these providers filter quota buckets against a model catalog; every other provider +// short-circuits so the sync paths never pay a discovery lookup they cannot use. +const CATALOG_FILTERED_PROVIDERS = new Set(["antigravity", "agy"]); + +type Snapshot = { ids: ReadonlySet; at: number }; + +const snapshots = new Map(); + +/** Last known live model ids for `provider` (empty when nothing was ever synced). */ +export function getCachedLiveModelIds(provider: string): ReadonlySet { + return snapshots.get(provider)?.ids ?? EMPTY_IDS; +} + +/** Refresh the snapshot from the synced catalog, at most once per TTL window. */ +export async function refreshLiveModelIds(provider: string): Promise> { + if (!CATALOG_FILTERED_PROVIDERS.has(provider)) return EMPTY_IDS; + + const cached = snapshots.get(provider); + if (cached && Date.now() - cached.at < SNAPSHOT_TTL_MS) return cached.ids; + + try { + const catalog = await getActiveSyncedCatalog(provider); + const ids = new Set( + catalog.models + .map((model) => (typeof model?.id === "string" ? model.id : "")) + .filter((id): id is string => id.length > 0) + ); + snapshots.set(provider, { ids, at: Date.now() }); + return ids; + } catch { + // Discovery state unavailable: keep the previous snapshot rather than widening or + // narrowing the quota filter on a transient DB error. + return cached?.ids ?? EMPTY_IDS; + } +} + +/** Test helper: drop every cached snapshot, or seed one for a provider. */ +export function __setLiveModelIdsForTests(provider?: string, ids?: Iterable): void { + if (!provider) { + snapshots.clear(); + return; + } + snapshots.set(provider, { ids: new Set(ids ?? []), at: Date.now() }); +} diff --git a/src/lib/usage/providerLimits/quotaNormalize.ts b/src/lib/usage/providerLimits/quotaNormalize.ts index de1dffefa8aa..79d16b6ea611 100644 --- a/src/lib/usage/providerLimits/quotaNormalize.ts +++ b/src/lib/usage/providerLimits/quotaNormalize.ts @@ -10,25 +10,43 @@ export function isRecord(value: unknown): value is JsonRecord { return value !== null && typeof value === "object" && !Array.isArray(value); } -export function isUsageQuotaKeyAllowed(provider: string, quotaKey: string): boolean { +/** + * Whether a quota bucket may be shown for `provider`. + * + * `liveModelIds` is the provider's live discovery catalog when the caller has it: a model + * the connection actually reports is always allowed, so a tier Google ships between two + * OmniRoute releases keeps its quota instead of being dropped. The static catalogs stay as + * the fallback for cold start / logged-out / discovery-failure. + */ +export function isUsageQuotaKeyAllowed( + provider: string, + quotaKey: string, + liveModelIds?: ReadonlySet +): boolean { if (quotaKey === "credits" || quotaKey === "models") return true; + if (provider !== "antigravity" && provider !== "agy") return true; + if (liveModelIds?.has(quotaKey)) return true; if (provider === "antigravity") return isUserCallableAntigravityModelId(quotaKey); - if (provider === "agy") return isUserCallableAgyModelId(quotaKey); - return true; + return isUserCallableAgyModelId(quotaKey); } -export function normalizeUsageQuotaKey(provider: string, quotaKey: string): string | null { +export function normalizeUsageQuotaKey( + provider: string, + quotaKey: string, + liveModelIds?: ReadonlySet +): string | null { if (quotaKey === "credits" || quotaKey === "models") return quotaKey; if (provider === "antigravity" || provider === "agy") { const clientKey = toClientAntigravityModelId(quotaKey); - return isUsageQuotaKeyAllowed(provider, clientKey) ? clientKey : null; + return isUsageQuotaKeyAllowed(provider, clientKey, liveModelIds) ? clientKey : null; } - return isUsageQuotaKeyAllowed(provider, quotaKey) ? quotaKey : null; + return isUsageQuotaKeyAllowed(provider, quotaKey, liveModelIds) ? quotaKey : null; } export function normalizeUsageQuotasForProvider( provider: string, - quotas: JsonRecord | null | undefined + quotas: JsonRecord | null | undefined, + liveModelIds?: ReadonlySet ): JsonRecord | null { if (!isRecord(quotas)) return quotas ?? null; @@ -36,7 +54,7 @@ export function normalizeUsageQuotasForProvider( let changed = false; for (const [quotaKey, quota] of Object.entries(quotas)) { - const normalizedKey = normalizeUsageQuotaKey(provider, quotaKey); + const normalizedKey = normalizeUsageQuotaKey(provider, quotaKey, liveModelIds); if (!normalizedKey) { changed = true; continue; @@ -63,10 +81,14 @@ export function normalizeUsageQuotasForProvider( return changed ? normalized : quotas; } -export function sanitizeUsageQuotasForProvider(provider: string, usage: JsonRecord): JsonRecord { +export function sanitizeUsageQuotasForProvider( + provider: string, + usage: JsonRecord, + liveModelIds?: ReadonlySet +): JsonRecord { if (provider !== "antigravity" && provider !== "agy") return usage; if (!isRecord(usage.quotas)) return usage; - const sanitizedQuotas = normalizeUsageQuotasForProvider(provider, usage.quotas); + const sanitizedQuotas = normalizeUsageQuotasForProvider(provider, usage.quotas, liveModelIds); return sanitizedQuotas === usage.quotas ? usage : { ...usage, quotas: sanitizedQuotas }; } diff --git a/src/lib/usage/providerLimitsCache.ts b/src/lib/usage/providerLimitsCache.ts index 6548bef54bd8..9d388c696e6b 100644 --- a/src/lib/usage/providerLimitsCache.ts +++ b/src/lib/usage/providerLimitsCache.ts @@ -28,6 +28,9 @@ export function toProviderLimitsCacheEntry( source, bankedResetCredits: Number.isFinite(bankedResetCredits) ? bankedResetCredits : undefined, billing: sanitizeProviderBillingStatus(usage.billing), + quotaGroups: Array.isArray(usage.quotaGroups) + ? (usage.quotaGroups as Array>) + : undefined, }; } diff --git a/src/shared/constants/cliTools.ts b/src/shared/constants/cliTools.ts index 01401ea986f9..87ea13ce2c0b 100644 --- a/src/shared/constants/cliTools.ts +++ b/src/shared/constants/cliTools.ts @@ -242,6 +242,9 @@ export const CLI_TOOLS: Record = { acpSpawnable: false, baseUrlSupport: "none", modelAliases: [ + "gemini-3.8-flash-high", + "gemini-3.8-flash-medium", + "gemini-3.8-flash-low", "gemini-3.7-flash-high", "gemini-3.7-flash-medium", "gemini-3.7-flash-low", @@ -252,6 +255,9 @@ export const CLI_TOOLS: Record = { "gpt-oss-120b-medium", ], defaultModels: [ + createCliModel("gemini-3.8-flash-high", "Gemini 3.8 Flash High"), + createCliModel("gemini-3.8-flash-medium", "Gemini 3.8 Flash Medium"), + createCliModel("gemini-3.8-flash-low", "Gemini 3.8 Flash Low"), createCliModel("gemini-3.7-flash-high", "Gemini 3.7 Flash High"), createCliModel("gemini-3.7-flash-medium", "Gemini 3.7 Flash Medium"), createCliModel("gemini-3.7-flash-low", "Gemini 3.7 Flash Low"), diff --git a/src/shared/constants/modelSpecs.ts b/src/shared/constants/modelSpecs.ts index c8b6faaf4b95..a75e3b579986 100644 --- a/src/shared/constants/modelSpecs.ts +++ b/src/shared/constants/modelSpecs.ts @@ -181,6 +181,41 @@ export const MODEL_SPECS: Record = { supportsTools: true, supportsVision: true, }, + // ── Gemini 3.8 Flash (Antigravity/AGY per-effort tiers) ─────────── + // No defaultThinkingBudget/thinkingBudgetCap on purpose: 3.8 steers reasoning through + // thinkingConfig.thinkingLevel (low/medium/high), not through a token budget like the + // 3.7 tiered backend. Declaring a budget here would make capThinkingBudget() inject a + // 3.7-shaped thinkingBudget the 3.8 backend does not use. + "gemini-3.8-flash-high": { + maxOutputTokens: 65536, + contextWindow: 1048576, + supportsThinking: true, + supportsTools: true, + supportsVision: true, + }, + "gemini-3.8-flash-medium": { + maxOutputTokens: 65536, + contextWindow: 1048576, + supportsThinking: true, + supportsTools: true, + supportsVision: true, + }, + "gemini-3.8-flash-low": { + maxOutputTokens: 65536, + contextWindow: 1048576, + supportsThinking: true, + supportsTools: true, + supportsVision: true, + }, + "gemini-3.8-flash": { + maxOutputTokens: 65536, + contextWindow: 1048576, + supportsThinking: true, + supportsTools: true, + supportsVision: true, + aliases: ["gemini-3.8-flash-medium"], + }, + // ── Gemini 3.7 Flash (current Antigravity/AGY live tiers) ───────── // The tier suffix configures the thinking budget passed to the upstream // gemini-3.7-flash-tiered backend (high: 24.5k, medium: 8k, low: 1k). diff --git a/src/shared/constants/pricing/oauth-subscriptions.ts b/src/shared/constants/pricing/oauth-subscriptions.ts index 9d255e0e240e..3505e8d1e185 100644 --- a/src/shared/constants/pricing/oauth-subscriptions.ts +++ b/src/shared/constants/pricing/oauth-subscriptions.ts @@ -14,6 +14,10 @@ import { } from "./shared-tiers"; const ANTIGRAVITY_GEMINI_3_7_PRICING = { + "gemini-3.8-flash": GEMINI_3_7_FLASH_PROMO_PRICING, + "gemini-3.8-flash-low": GEMINI_3_7_FLASH_PROMO_PRICING, + "gemini-3.8-flash-medium": GEMINI_3_7_FLASH_PROMO_PRICING, + "gemini-3.8-flash-high": GEMINI_3_7_FLASH_PROMO_PRICING, "gemini-3.7-flash-low": GEMINI_3_7_FLASH_PROMO_PRICING, "gemini-3.7-flash-medium": GEMINI_3_7_FLASH_PROMO_PRICING, "gemini-3.7-flash-high": GEMINI_3_7_FLASH_PROMO_PRICING, diff --git a/src/shared/services/modelSyncScheduler.ts b/src/shared/services/modelSyncScheduler.ts index c63a5e50ac11..358d4e8b10e6 100644 --- a/src/shared/services/modelSyncScheduler.ts +++ b/src/shared/services/modelSyncScheduler.ts @@ -144,6 +144,23 @@ export function isModelSyncInternalRequest(request: { headers: Headers }): boole return Boolean(headerToken && internalAuthToken && headerToken === internalAuthToken); } +/** Providers whose connections are created with `autoSync: true` (#488). */ +const AUTO_SYNC_DEFAULT_PROVIDERS = new Set(["antigravity", "agy"]); + +/** + * Whether a connection takes part in the sync cycle. + * + * `autoSync` unset means the connection predates the family default, not that the operator + * opted out — those connections never refreshed their catalog again, so every model Google + * shipped after the connection was created stayed invisible. An explicit `false` is still + * honored. + */ +export function isAutoSyncEnabled(provider: unknown, psd: Record): boolean { + if (psd.autoSync === true) return true; + if (psd.autoSync !== undefined) return false; + return typeof provider === "string" && AUTO_SYNC_DEFAULT_PROVIDERS.has(provider); +} + /** * Fetch all provider connections that have autoSync enabled. */ @@ -165,7 +182,7 @@ async function getAutoSyncConnections(): Promise< conn.providerSpecificData && typeof conn.providerSpecificData === "object" ? (conn.providerSpecificData as Record) : {}; - if (psd.autoSync !== true) continue; + if (!isAutoSyncEnabled(conn.provider, psd)) continue; if (typeof conn.id !== "string" || typeof conn.provider !== "string") continue; autoSyncConnections.push({ id: conn.id, diff --git a/tests/unit/antigravity-gemini-38-catalog.test.ts b/tests/unit/antigravity-gemini-38-catalog.test.ts new file mode 100644 index 000000000000..aed3ea124ffe --- /dev/null +++ b/tests/unit/antigravity-gemini-38-catalog.test.ts @@ -0,0 +1,72 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + ANTIGRAVITY_PUBLIC_MODELS, + ANTIGRAVITY_MODEL_ALIASES, + resolveAntigravityModelId, + isDiscoverableAntigravityModelId, + isUserCallableAntigravityModelId, +} from "../../open-sse/config/antigravityModelAliases.ts"; + +import { + AGY_PUBLIC_MODELS, + isDiscoverableAgyModelId, + isUserCallableAgyModelId, +} from "../../open-sse/config/agyModels.ts"; + +import { MODEL_SPECS } from "../../src/shared/constants/modelSpecs.ts"; + +test("Gemini 3.8 Flash tiers exist in ANTIGRAVITY_PUBLIC_MODELS", () => { + const ids = ANTIGRAVITY_PUBLIC_MODELS.map((m) => m.id); + assert.ok(ids.includes("gemini-3.8-flash-high"), "must include gemini-3.8-flash-high"); + assert.ok(ids.includes("gemini-3.8-flash-medium"), "must include gemini-3.8-flash-medium"); + assert.ok(ids.includes("gemini-3.8-flash-low"), "must include gemini-3.8-flash-low"); +}); + +test("Gemini 3.8 Flash tiers exist in AGY_PUBLIC_MODELS", () => { + const ids = AGY_PUBLIC_MODELS.map((m) => m.id); + assert.ok(ids.includes("gemini-3.8-flash-high"), "must include gemini-3.8-flash-high"); + assert.ok(ids.includes("gemini-3.8-flash-medium"), "must include gemini-3.8-flash-medium"); + assert.ok(ids.includes("gemini-3.8-flash-low"), "must include gemini-3.8-flash-low"); +}); + +test("Gemini 3.8 Flash tier resolution keeps per-effort upstream ids verbatim (no -tiered mapping)", () => { + // 3.8 tiers MUST NOT be mapped to gemini-3.8-flash-tiered or gemini-3.7-flash-tiered + assert.equal(resolveAntigravityModelId("gemini-3.8-flash-high"), "gemini-3.8-flash-high"); + assert.equal(resolveAntigravityModelId("gemini-3.8-flash-medium"), "gemini-3.8-flash-medium"); + assert.equal(resolveAntigravityModelId("gemini-3.8-flash-low"), "gemini-3.8-flash-low"); + + // Bare gemini-3.8-flash defaults to medium + assert.equal(resolveAntigravityModelId("gemini-3.8-flash"), "gemini-3.8-flash-medium"); + assert.equal(ANTIGRAVITY_MODEL_ALIASES["gemini-3.8-flash"], "gemini-3.8-flash-medium"); +}); + +test("Gemini 3.8 Flash discoverability and static callability", () => { + for (const tier of ["gemini-3.8-flash-high", "gemini-3.8-flash-medium", "gemini-3.8-flash-low"]) { + assert.equal(isDiscoverableAntigravityModelId(tier), true, `antigravity discoverable: ${tier}`); + assert.equal(isUserCallableAntigravityModelId(tier), true, `antigravity callable: ${tier}`); + assert.equal(isDiscoverableAgyModelId(tier), true, `agy discoverable: ${tier}`); + assert.equal(isUserCallableAgyModelId(tier), true, `agy callable: ${tier}`); + } +}); + +test("MODEL_SPECS declares Gemini 3.8 Flash with full context and without thinking budget", () => { + for (const tier of [ + "gemini-3.8-flash-high", + "gemini-3.8-flash-medium", + "gemini-3.8-flash-low", + "gemini-3.8-flash", + ]) { + const spec = MODEL_SPECS[tier]; + assert.ok(spec, `spec for ${tier} must exist`); + assert.equal(spec.maxOutputTokens, 65536); + assert.equal(spec.contextWindow, 1048576); + assert.equal(spec.supportsThinking, true); + assert.equal(spec.supportsTools, true); + assert.equal(spec.supportsVision, true); + // Crucial: 3.8 uses thinkingLevel, NOT token thinkingBudget like 3.7 + assert.equal(spec.defaultThinkingBudget, undefined, "must not declare defaultThinkingBudget"); + assert.equal(spec.thinkingBudgetCap, undefined, "must not declare thinkingBudgetCap"); + } +}); diff --git a/tests/unit/antigravity-gemini-38-request-shape.test.ts b/tests/unit/antigravity-gemini-38-request-shape.test.ts new file mode 100644 index 000000000000..1ca3cf2a4528 --- /dev/null +++ b/tests/unit/antigravity-gemini-38-request-shape.test.ts @@ -0,0 +1,110 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { openaiToGeminiRequest } from "../../open-sse/translator/request/openai-to-gemini.ts"; +import { claudeToGeminiRequest } from "../../open-sse/translator/request/claude-to-gemini.ts"; +import { + getGeminiThinkingLevel, + applyAntigravityGenerationDefaults, +} from "../../open-sse/translator/request/openai-to-gemini/helpers.ts"; + +type TranslatedRequest = { + generationConfig: { + thinkingConfig?: { + thinkingLevel?: string; + thinkingBudget?: number; + includeThoughts?: boolean; + }; + }; +}; + +test("getGeminiThinkingLevel maps 3.8 tiers and reasoning efforts correctly", () => { + assert.equal(getGeminiThinkingLevel("gemini-3.8-flash-high"), "HIGH"); + assert.equal(getGeminiThinkingLevel("gemini-3.8-flash-medium"), "MEDIUM"); + assert.equal(getGeminiThinkingLevel("gemini-3.8-flash-low"), "LOW"); + assert.equal(getGeminiThinkingLevel("gemini-3.8-flash"), "MEDIUM"); + assert.equal(getGeminiThinkingLevel("gemini-3.8-flash", "high"), "HIGH"); + assert.equal(getGeminiThinkingLevel("gemini-3.8-flash", "low"), "LOW"); + assert.equal(getGeminiThinkingLevel("gemini-3.7-flash-high"), null); +}); + +test("openaiToGemini translates Gemini 3.8 to thinkingLevel and strips sampling defaults", () => { + const tiers = [ + { model: "gemini-3.8-flash-high", expectedLevel: "HIGH" }, + { model: "gemini-3.8-flash-medium", expectedLevel: "MEDIUM" }, + { model: "gemini-3.8-flash-low", expectedLevel: "LOW" }, + { model: "gemini-3.8-flash", expectedLevel: "MEDIUM" }, + ]; + + for (const { model, expectedLevel } of tiers) { + const body = { + model, + messages: [{ role: "user", content: "Hello" }], + }; + + const result = openaiToGeminiRequest(model, body, false) as TranslatedRequest; + const config = result.generationConfig; + + assert.ok(config.thinkingConfig, `thinkingConfig must be defined for ${model}`); + assert.equal(config.thinkingConfig.thinkingLevel, expectedLevel); + assert.equal(config.thinkingConfig.includeThoughts, true); + assert.equal(config.thinkingConfig.thinkingBudget, undefined, "must NOT inject thinkingBudget"); + } +}); + +test("openaiToGemini respects explicit reasoning_effort for Gemini 3.8", () => { + const body = { + model: "gemini-3.8-flash", + messages: [{ role: "user", content: "Hello" }], + reasoning_effort: "low", + }; + + const result = openaiToGeminiRequest("gemini-3.8-flash", body, false) as TranslatedRequest; + const config = result.generationConfig; + assert.equal(config.thinkingConfig.thinkingLevel, "LOW"); + assert.equal(config.thinkingConfig.includeThoughts, true); + assert.equal(config.thinkingConfig.thinkingBudget, undefined); +}); + +test("claudeToGeminiRequest translates Gemini 3.8 with thinkingLevel", () => { + const body = { + model: "gemini-3.8-flash-high", + max_tokens: 1024, + messages: [{ role: "user", content: "Hello" }], + output_config: { effort: "high" }, + }; + + const result = claudeToGeminiRequest("gemini-3.8-flash-high", body, false) as TranslatedRequest; + const config = result.generationConfig; + assert.ok(config.thinkingConfig); + assert.equal(config.thinkingConfig.thinkingLevel, "HIGH"); + assert.equal(config.thinkingConfig.includeThoughts, true); + assert.equal(config.thinkingConfig.thinkingBudget, undefined); +}); + +test("applyAntigravityGenerationDefaults strips topK, topP, temperature for Gemini 3.8", () => { + const inputConfig = { + temperature: 0.7, + topK: 40, + topP: 0.9, + maxOutputTokens: 2048, + thinkingConfig: { + thinkingLevel: "HIGH" as const, + includeThoughts: true, + }, + }; + + const outputConfig = applyAntigravityGenerationDefaults(inputConfig, "gemini-3.8-flash-high"); + assert.equal(outputConfig.topK, undefined, "topK must be removed for 3.8"); + assert.equal(outputConfig.topP, undefined, "topP must be removed for 3.8"); + assert.equal(outputConfig.temperature, undefined, "temperature must be removed for 3.8"); + assert.equal(outputConfig.maxOutputTokens, 2048); + + // 3.7 should retain defaults + const output37 = applyAntigravityGenerationDefaults( + { maxOutputTokens: 2048 }, + "gemini-3.7-flash-high" + ); + assert.equal(output37.topK, 40); + assert.equal(output37.topP, 1); +}); diff --git a/tests/unit/antigravity-live-catalog-quota-allowlist.test.ts b/tests/unit/antigravity-live-catalog-quota-allowlist.test.ts new file mode 100644 index 000000000000..713863923b6f --- /dev/null +++ b/tests/unit/antigravity-live-catalog-quota-allowlist.test.ts @@ -0,0 +1,65 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + isUsageQuotaKeyAllowed, + normalizeUsageQuotasForProvider, + sanitizeUsageQuotasForProvider, +} from "../../src/lib/usage/providerLimits/quotaNormalize.ts"; + +test("quota key allowed: static model IDs are always allowed", () => { + assert.equal(isUsageQuotaKeyAllowed("antigravity", "gemini-3.8-flash-high"), true); + assert.equal(isUsageQuotaKeyAllowed("antigravity", "gemini-3.7-flash-high"), true); + assert.equal(isUsageQuotaKeyAllowed("agy", "gemini-3.8-flash-medium"), true); + assert.equal(isUsageQuotaKeyAllowed("agy", "gemini-3.7-flash-medium"), true); +}); + +test("quota key allowed: future dynamic live catalog model IDs are allowed when in liveModelIds set", () => { + const liveSet = new Set(["gemini-3.9-flash-high", "gemini-4.0-pro"]); + + // Without live set, unknown model is rejected + assert.equal(isUsageQuotaKeyAllowed("antigravity", "gemini-3.9-flash-high"), false); + assert.equal(isUsageQuotaKeyAllowed("agy", "gemini-3.9-flash-high"), false); + + // With live set, unknown model is allowed + assert.equal(isUsageQuotaKeyAllowed("antigravity", "gemini-3.9-flash-high", liveSet), true); + assert.equal(isUsageQuotaKeyAllowed("agy", "gemini-3.9-flash-high", liveSet), true); + assert.equal(isUsageQuotaKeyAllowed("antigravity", "gemini-4.0-pro", liveSet), true); + + // Model not in static and not in live set is still rejected + assert.equal(isUsageQuotaKeyAllowed("antigravity", "unrecognized-future-model", liveSet), false); +}); + +test("normalizeUsageQuotasForProvider preserves future models from live discovery", () => { + const liveSet = new Set(["gemini-3.9-flash-high"]); + const rawQuotas = { + "gemini-3.7-flash-high": { remainingFraction: 0.8 }, + "gemini-3.9-flash-high": { remainingFraction: 1.0 }, + "non-existent-retired-model": { remainingFraction: 0.0 }, + }; + + const normalized = normalizeUsageQuotasForProvider("antigravity", rawQuotas, liveSet); + assert.ok(normalized); + assert.ok(normalized["gemini-3.7-flash-high"], "static model preserved"); + assert.ok(normalized["gemini-3.9-flash-high"], "future live model preserved"); + assert.equal(normalized["non-existent-retired-model"], undefined, "unsupported model stripped"); +}); + +test("sanitizeUsageQuotasForProvider handles full usage object with live model ids", () => { + const liveSet = new Set(["gemini-3.9-flash-medium"]); + const usage = { + provider: "agy", + quotas: { + "gemini-3.8-flash-medium": { remainingFraction: 0.9 }, + "gemini-3.9-flash-medium": { remainingFraction: 1.0 }, + "totally-bogus-model": { remainingFraction: 0.0 }, + }, + }; + + const sanitized = sanitizeUsageQuotasForProvider("agy", usage, liveSet); + assert.ok(sanitized.quotas); + const quotas = sanitized.quotas as Record; + assert.ok(quotas["gemini-3.8-flash-medium"]); + assert.ok(quotas["gemini-3.9-flash-medium"]); + assert.equal(quotas["totally-bogus-model"], undefined); +}); diff --git a/tests/unit/antigravity-quota-groups-resolve.test.ts b/tests/unit/antigravity-quota-groups-resolve.test.ts new file mode 100644 index 000000000000..8053b4f46556 --- /dev/null +++ b/tests/unit/antigravity-quota-groups-resolve.test.ts @@ -0,0 +1,60 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { resolveAntigravityQuotaGroups } from "../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/AntigravityQuotaGroups.tsx"; + +test("resolveAntigravityQuotaGroups returns rawGroups when already provided", () => { + const rawGroups = [ + { + id: "gemini", + displayName: "Gemini Models", + windows: { session: { remainingPercentage: 80 } }, + models: ["gemini-3.8-flash-high"], + }, + ]; + + const result = resolveAntigravityQuotaGroups(rawGroups, []); + assert.equal(result.length, 1); + assert.equal(result[0].id, "gemini"); + assert.equal(result[0].windows?.session?.remainingPercentage, 80); +}); + +test("resolveAntigravityQuotaGroups synthesizes groups from cached flat quotas", () => { + const quotas = [ + { name: "gemini_session", remainingPercentage: 74, resetAt: "2026-09-03T12:00:00Z" }, + { name: "gemini_weekly", remainingPercentage: 36, resetAt: "2026-09-07T12:00:00Z" }, + { modelKey: "gemini-3.8-flash-high", remainingPercentage: 74 }, + { modelKey: "gemini-3.8-flash-medium", remainingPercentage: 74 }, + { name: "claude_gpt_session", remainingPercentage: 92, resetAt: "2026-09-03T12:00:00Z" }, + { name: "claude_gpt_weekly", remainingPercentage: 68, resetAt: "2026-09-07T12:00:00Z" }, + { modelKey: "claude-sonnet-4-6", remainingPercentage: 92 }, + ]; + + const result = resolveAntigravityQuotaGroups([], quotas); + assert.equal(result.length, 2); + + const gemini = result.find((g) => g.id === "gemini"); + assert.ok(gemini); + assert.equal(gemini.windows?.session?.remainingPercentage, 74); + assert.equal(gemini.windows?.weekly?.remainingPercentage, 36); + assert.deepEqual(gemini.models, ["gemini-3.8-flash-high", "gemini-3.8-flash-medium"]); + + const claude = result.find((g) => g.id === "claude_gpt"); + assert.ok(claude); + assert.equal(claude.windows?.session?.remainingPercentage, 92); + assert.equal(claude.windows?.weekly?.remainingPercentage, 68); + assert.deepEqual(claude.models, ["claude-sonnet-4-6"]); +}); + +test("resolveAntigravityQuotaGroups falls back to per-model session if summary was unavailable", () => { + const quotas = [ + { name: "gemini_weekly", remainingPercentage: 36, resetAt: "2026-09-07T12:00:00Z" }, + { modelKey: "gemini-3.7-flash-high", remainingPercentage: 80, resetAt: "2026-09-03T10:00:00Z" }, + ]; + + const result = resolveAntigravityQuotaGroups(undefined, quotas); + assert.equal(result.length, 1); + assert.equal(result[0].id, "gemini"); + assert.equal(result[0].windows?.session?.remainingPercentage, 80); + assert.equal(result[0].windows?.weekly?.remainingPercentage, 36); +}); diff --git a/tests/unit/antigravity-quota-skipping.test.ts b/tests/unit/antigravity-quota-skipping.test.ts index 6f585bb3bca8..c253704ab307 100644 --- a/tests/unit/antigravity-quota-skipping.test.ts +++ b/tests/unit/antigravity-quota-skipping.test.ts @@ -114,7 +114,7 @@ test("isQuotaExhaustedForRequest scopes gemini exhaustion to the requested model quotaCache.setQuotaCache(connectionId, "antigravity", { "gemini-3.7-flash-medium": { remainingPercentage: 0, resetAt: null }, "gemini-pro-agent": { remainingPercentage: 100, resetAt: null }, - gemini_weekly: { remainingPercentage: 0, resetAt: null }, + gemini_weekly: { remainingPercentage: 100, resetAt: null }, }); assert.equal( diff --git a/tests/unit/antigravity-quota-summary.test.ts b/tests/unit/antigravity-quota-summary.test.ts new file mode 100644 index 000000000000..a6d20b1baae4 --- /dev/null +++ b/tests/unit/antigravity-quota-summary.test.ts @@ -0,0 +1,108 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + normalizeAntigravityQuotaWindow, + parseAntigravityQuotaSummary, +} from "../../open-sse/services/usage/antigravityWeeklyQuota.ts"; + +test("Antigravity quota summary parses 5h and weekly windows for each family", () => { + const summary = parseAntigravityQuotaSummary({ + quotaSummary: { + groups: [ + { + displayName: "Gemini Models", + buckets: [ + { + bucketId: "gemini-5h", + window: "5h", + remainingFraction: 0.74, + resetTime: "2026-09-03T12:00:00Z", + }, + { + bucketId: "gemini-weekly", + window: "weekly", + remainingFraction: 0.36, + resetTime: "2026-09-07T12:00:00Z", + }, + ], + }, + { + displayName: "Claude and GPT Models", + buckets: [ + { + bucketId: "claude-gpt-session", + remainingFraction: 0.92, + resetTime: "2026-09-03T12:00:00Z", + }, + { + bucketId: "claude-gpt-7d", + remainingFraction: 0.68, + resetTime: "2026-09-07T12:00:00Z", + }, + ], + }, + ], + }, + }); + + assert.equal(summary.groups.length, 2); + assert.equal(summary.quotas.gemini_session.remainingPercentage, 74); + assert.equal(summary.quotas.gemini_weekly.remainingPercentage, 36); + assert.equal(summary.quotas.claude_gpt_session.remainingPercentage, 92); + assert.equal(summary.quotas.claude_gpt_weekly.remainingPercentage, 68); + assert.equal(summary.quotas.gemini_session.quotaSource, "retrieveUserQuotaSummary"); + assert.equal(summary.quotas.gemini_session.quotaAggregate, true); +}); + +test("Antigravity quota summary reads retrieveUserQuotaSummary's nested remaining shape", () => { + const summary = parseAntigravityQuotaSummary({ + groups: [ + { + displayName: "Gemini Models", + buckets: [ + { + bucketId: "gemini-5h", + window: "5h", + remaining: { remainingFraction: 0.74, resetTime: "2026-09-03T12:00:00Z" }, + }, + { + bucketId: "gemini-weekly", + window: "weekly", + remaining: { remainingFraction: 0.36, resetTime: "2026-09-07T12:00:00Z" }, + }, + ], + }, + ], + }); + + assert.equal(summary.groups.length, 1); + assert.equal(summary.quotas.gemini_session.remainingPercentage, 74); + assert.equal(summary.quotas.gemini_weekly.remainingPercentage, 36); + assert.equal(summary.quotas.gemini_weekly.resetAt, "2026-09-07T12:00:00.000Z"); +}); + +test("Antigravity quota summary skips disabled and unreported buckets", () => { + const summary = parseAntigravityQuotaSummary({ + groups: [ + { + displayName: "Gemini Models", + buckets: [ + { bucketId: "gemini-5h", remainingFraction: 0.5, disabled: true }, + { bucketId: "gemini-weekly" }, + ], + }, + ], + }); + + assert.deepEqual(summary.groups, []); + assert.deepEqual(summary.quotas, {}); +}); + +test("normalizeAntigravityQuotaWindow supports explicit and legacy bucket shapes", () => { + assert.equal(normalizeAntigravityQuotaWindow({ window: "5h" }), "session"); + assert.equal(normalizeAntigravityQuotaWindow({ window: "weekly" }), "weekly"); + assert.equal(normalizeAntigravityQuotaWindow({ bucketId: "gemini-five-hour" }), "session"); + assert.equal(normalizeAntigravityQuotaWindow({ displayName: "7d quota" }), "weekly"); + assert.equal(normalizeAntigravityQuotaWindow({ bucketId: "unknown" }), null); +}); diff --git a/tests/unit/antigravity-retired-public-models.test.ts b/tests/unit/antigravity-retired-public-models.test.ts index 55885e3f2e2c..8e0bca700df9 100644 --- a/tests/unit/antigravity-retired-public-models.test.ts +++ b/tests/unit/antigravity-retired-public-models.test.ts @@ -32,6 +32,9 @@ const RETIRED_PUBLIC_MODELS = [ ] as const; const EXPECTED_LEADING_MODEL_ORDER = [ + "gemini-3.8-flash-high", + "gemini-3.8-flash-medium", + "gemini-3.8-flash-low", "gemini-3.7-flash-high", "gemini-3.7-flash-medium", "gemini-3.7-flash-low", @@ -45,6 +48,9 @@ const EXPECTED_LEADING_MODEL_ORDER = [ ] as const; const ACTIVE_FLASH_MODEL_IDS = [ + "gemini-3.8-flash-high", + "gemini-3.8-flash-medium", + "gemini-3.8-flash-low", "gemini-3.7-flash-high", "gemini-3.7-flash-medium", "gemini-3.7-flash-low", diff --git a/tests/unit/model-sync-legacy-autosync-backfill.test.ts b/tests/unit/model-sync-legacy-autosync-backfill.test.ts new file mode 100644 index 000000000000..6b00c8c681f2 --- /dev/null +++ b/tests/unit/model-sync-legacy-autosync-backfill.test.ts @@ -0,0 +1,28 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { isAutoSyncEnabled } from "../../src/shared/services/modelSyncScheduler.ts"; + +test("isAutoSyncEnabled: explicit autoSync true is always enabled", () => { + assert.equal(isAutoSyncEnabled("openai", { autoSync: true }), true); + assert.equal(isAutoSyncEnabled("antigravity", { autoSync: true }), true); + assert.equal(isAutoSyncEnabled("agy", { autoSync: true }), true); +}); + +test("isAutoSyncEnabled: explicit autoSync false is always honored (disabled)", () => { + assert.equal(isAutoSyncEnabled("openai", { autoSync: false }), false); + assert.equal(isAutoSyncEnabled("antigravity", { autoSync: false }), false); + assert.equal(isAutoSyncEnabled("agy", { autoSync: false }), false); +}); + +test("isAutoSyncEnabled: undefined autoSync defaults to true for Antigravity-family connections", () => { + // Legacy antigravity/agy connections that were saved before autoSync default was introduced + assert.equal(isAutoSyncEnabled("antigravity", {}), true); + assert.equal(isAutoSyncEnabled("agy", {}), true); +}); + +test("isAutoSyncEnabled: undefined autoSync defaults to false for standard providers", () => { + assert.equal(isAutoSyncEnabled("openai", {}), false); + assert.equal(isAutoSyncEnabled("anthropic", {}), false); + assert.equal(isAutoSyncEnabled("openrouter", {}), false); +}); diff --git a/tests/unit/provider-limits-ui.test.ts b/tests/unit/provider-limits-ui.test.ts index cb01fa1f9f53..7d4f821ee654 100644 --- a/tests/unit/provider-limits-ui.test.ts +++ b/tests/unit/provider-limits-ui.test.ts @@ -313,6 +313,27 @@ test("OpenRouter credits render as a USD credit count, not a percentage row", () assert.equal(freeDaily.total, 50); }); +test("Antigravity live quota models retain upstream order instead of static catalog rank", () => { + const quotas = providerLimitUtils.parseQuotaData("antigravity", { + quotas: { + "gemini-3.9-flash-high": { used: 2, total: 100, remainingPercentage: 98 }, + "gemini-3.8-flash-high": { used: 2, total: 100, remainingPercentage: 98 }, + gemini_session: { + used: 2, + total: 100, + remainingPercentage: 98, + quotaAggregate: true, + quotaWindow: "session", + }, + }, + }); + + assert.deepEqual( + quotas.map((quota) => quota.modelKey || quota.name), + ["gemini-3.9-flash-high", "gemini-3.8-flash-high", "gemini_session"] + ); +}); + test("hidden provider models are filtered from per-model quota rows", () => { const quotas = providerLimitUtils.parseQuotaData("antigravity", { quotas: { diff --git a/tests/unit/quota-cache-antigravity-multi-window.test.ts b/tests/unit/quota-cache-antigravity-multi-window.test.ts new file mode 100644 index 000000000000..ae78ab44a644 --- /dev/null +++ b/tests/unit/quota-cache-antigravity-multi-window.test.ts @@ -0,0 +1,77 @@ +import { afterEach, test } from "node:test"; +import assert from "node:assert/strict"; + +const quotaCache = await import("../../src/domain/quotaCache.ts"); + +afterEach(() => quotaCache.__clearForTests()); + +const futureReset = new Date(Date.now() + 60 * 60 * 1000).toISOString(); + +function setGeminiWindows(connectionId: string, session: number | null, weekly: number | null) { + const quotas: Record = {}; + if (session !== null) { + quotas.gemini_session = { + used: 100 - session, + total: 100, + remainingPercentage: session, + resetAt: futureReset, + unlimited: false, + fractionReported: true, + }; + } + if (weekly !== null) { + quotas.gemini_weekly = { + used: 100 - weekly, + total: 100, + remainingPercentage: weekly, + resetAt: futureReset, + unlimited: false, + fractionReported: true, + }; + } + quotaCache.setQuotaCache(connectionId, "antigravity", quotas); +} + +test("Antigravity Gemini family blocks when weekly is exhausted despite 5h headroom", () => { + setGeminiWindows("weekly-exhausted", 80, 0); + assert.equal( + quotaCache.isQuotaExhaustedForRequest( + "weekly-exhausted", + "antigravity", + "gemini-3.8-flash-high" + ), + true + ); +}); + +test("Antigravity Gemini family blocks when 5h is exhausted despite weekly headroom", () => { + setGeminiWindows("session-exhausted", 0, 70); + assert.equal( + quotaCache.isQuotaExhaustedForRequest( + "session-exhausted", + "antigravity", + "gemini-3.8-flash-high" + ), + true + ); +}); + +test("Antigravity Gemini family is usable only when all reported windows have headroom", () => { + setGeminiWindows("both-available", 80, 70); + assert.equal( + quotaCache.isQuotaExhaustedForRequest("both-available", "antigravity", "gemini-3.8-flash-high"), + false + ); +}); + +test("missing weekly window falls back to the reported session rather than assuming weekly is full", () => { + setGeminiWindows("summary-unavailable", 80, null); + assert.equal( + quotaCache.isQuotaExhaustedForRequest( + "summary-unavailable", + "antigravity", + "gemini-3.8-flash-high" + ), + false + ); +}); diff --git a/tests/unit/reactive-model-sync-retry.test.ts b/tests/unit/reactive-model-sync-retry.test.ts new file mode 100644 index 000000000000..bb7c3fb42bf6 --- /dev/null +++ b/tests/unit/reactive-model-sync-retry.test.ts @@ -0,0 +1,53 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { awaitReactiveModelSync, __resetReactiveModelSyncForTests, __setReactiveSyncFnForTests } = + await import("../../src/lib/providerModels/reactiveModelSync.ts"); + +test("awaitReactiveModelSync successfully resolves true when sync succeeds", async () => { + __resetReactiveModelSyncForTests(); + let syncCalled = false; + __setReactiveSyncFnForTests(async (connectionId, provider) => { + syncCalled = true; + assert.equal(connectionId, "conn-test-1"); + assert.equal(provider, "antigravity"); + return true; + }); + + const result = await awaitReactiveModelSync("antigravity", "conn-test-1"); + assert.equal(result, true); + assert.equal(syncCalled, true); +}); + +test("awaitReactiveModelSync returns false on unsupported provider", async () => { + __resetReactiveModelSyncForTests(); + let syncCalled = false; + __setReactiveSyncFnForTests(async () => { + syncCalled = true; + return true; + }); + + const result = await awaitReactiveModelSync("openai", "conn-test-1"); + assert.equal(result, false); + assert.equal(syncCalled, false); +}); + +test("awaitReactiveModelSync returns false on cooldown", async () => { + __resetReactiveModelSyncForTests(); + __setReactiveSyncFnForTests(async () => true); + + const first = await awaitReactiveModelSync("antigravity", "conn-test-2"); + assert.equal(first, true); + + // Immediate second call should hit cooldown and return false + const second = await awaitReactiveModelSync("antigravity", "conn-test-2"); + assert.equal(second, false); +}); + +test("awaitReactiveModelSync honors timeout if sync hangs", async () => { + __resetReactiveModelSyncForTests(); + __setReactiveSyncFnForTests(() => new Promise((resolve) => setTimeout(() => resolve(true), 500))); + + const result = await awaitReactiveModelSync("antigravity", "conn-test-3", 50); + assert.equal(result, false); +});