diff --git a/open-sse/config/imageRegistry.ts b/open-sse/config/imageRegistry.ts index 9769431f17..85ffcd57d9 100644 --- a/open-sse/config/imageRegistry.ts +++ b/open-sse/config/imageRegistry.ts @@ -63,10 +63,7 @@ export const IMAGE_PROVIDERS = { authType: "oauth", authHeader: "bearer", format: "gemini-image", // Special format: uses Gemini generateContent API - models: [ - { id: "gemini-2.5-flash-preview-image-generation", name: "Gemini 2.5 Flash Image" }, - { id: "gemini-3.1-flash-image-preview", name: "Gemini 3.1 Flash Image Preview" }, - ], + models: [], supportedSizes: ["1024x1024"], }, diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 9afef5ca2a..c63d9a0cd9 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -46,7 +46,7 @@ export interface RegistryOAuth { export interface RegistryEntry { id: string; - alias: string; + alias?: string; format: string; executor: string; baseUrl?: string; @@ -359,7 +359,7 @@ export const REGISTRY: Record = { antigravity: { id: "antigravity", - alias: "ag", + alias: undefined, format: "antigravity", executor: "antigravity", baseUrls: [ @@ -387,16 +387,11 @@ export const REGISTRY: Record = { models: [ { id: "claude-opus-4-6-thinking", name: "Claude Opus 4.6 Thinking" }, { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, - { id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" }, - { id: "claude-sonnet-4", name: "Claude Sonnet 4" }, - { id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" }, - { id: "gemini-3.1-flash-lite-preview", name: "Gemini 3.1 Flash Lite Preview" }, - { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" }, - { id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" }, - { id: "gemini-2.0-flash", name: "Gemini 2.0 Flash" }, + { id: "gemini-3-flash", name: "Gemini 3 Flash" }, + { id: "gemini-3.1-flash-image", name: "Gemini 3.1 Flash Image" }, + { id: "gemini-3.1-pro-high", name: "Gemini 3.1 Pro (High)" }, + { id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro (Low)" }, { id: "gpt-oss-120b-medium", name: "GPT OSS 120B Medium" }, - { id: "gpt-5", name: "GPT 5" }, - { id: "gpt-5-mini", name: "GPT 5 Mini" }, ], passthroughModels: true, }, @@ -1550,6 +1545,21 @@ export function isLocalProvider(baseUrl?: string | null): boolean { } } +/** Set of provider IDs with passthroughModels enabled — 404s are model-specific, not account-level. */ +const _passthroughProviderIds: Set | null = (() => { + try { + const ids = new Set(); + for (const entry of Object.values(REGISTRY)) { + if (entry.passthroughModels) ids.add(entry.id); + } + return ids; + } catch { return null; } +})(); + +export function getPassthroughProviders(): Set { + return _passthroughProviderIds ?? new Set(); +} + // ── Registry Lookup Helpers ─────────────────────────────────────────────── const _byAlias = new Map(); diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts index f831da14fc..ab8208779a 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -5,13 +5,21 @@ import { PROVIDERS, OAUTH_ENDPOINTS, HTTP_STATUS } from "../config/constants.ts" const MAX_RETRY_AFTER_MS = 60_000; const LONG_RETRY_THRESHOLD_MS = 60_000; +const BARE_PRO_IDS = new Set(["gemini-3.1-pro"]); + /** * Strip provider prefixes (e.g. "antigravity/model" → "model"). * Ensures the model name sent to the upstream API never contains a routing prefix. */ function cleanModelName(model: string): string { if (!model) return model; - return model.includes("/") ? model.split("/").pop()! : model; + let clean = model.includes("/") ? model.split("/").pop()! : model; + // Normalize bare Pro IDs to the Low tier (matching OpenClaw convention). + // The upstream API requires an explicit tier suffix; bare IDs cause errors. + if (BARE_PRO_IDS.has(clean)) { + clean = `${clean}-low`; + } + return clean; } export class AntigravityExecutor extends BaseExecutor { @@ -22,8 +30,12 @@ export class AntigravityExecutor extends BaseExecutor { buildUrl(model, stream, urlIndex = 0) { const baseUrls = this.getBaseUrls(); const baseUrl = baseUrls[urlIndex] || baseUrls[0]; - const action = stream ? "streamGenerateContent?alt=sse" : "generateContent"; - return `${baseUrl}/v1internal:${action}`; + // Always use streaming endpoint — the non-streaming `generateContent` causes + // upstream 400 errors for some models (e.g. gpt-oss-120b-medium) because the + // Cloud Code API internally converts to OpenAI format and injects + // stream_options without setting stream=true. chatCore already handles + // SSE→JSON conversion for non-streaming client requests. + return `${baseUrl}/v1internal:streamGenerateContent?alt=sse`; } buildHeaders(credentials, stream = true) { @@ -32,7 +44,7 @@ export class AntigravityExecutor extends BaseExecutor { Authorization: `Bearer ${credentials.accessToken}`, "User-Agent": this.config.headers?.["User-Agent"] || "antigravity/1.104.0 darwin/arm64", "X-OmniRoute-Source": "omniroute", - ...(stream && { Accept: "text/event-stream" }), + Accept: "text/event-stream", }; } @@ -202,6 +214,106 @@ export class AntigravityExecutor extends BaseExecutor { return totalMs > 0 ? totalMs : null; } + /** + * Collect an SSE streaming response into a single non-streaming JSON response. + * Parses Gemini-format SSE chunks and assembles text content + usage into one + * OpenAI-format chat.completion payload. + */ + collectStreamToResponse(response, model, url, headers, transformedBody, log?, signal?) { + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + + const SSE_COLLECT_TIMEOUT_MS = 120_000; + + const collect = async () => { + const chunks: string[] = []; + let timedOut = false; + const timeout = AbortSignal.timeout(SSE_COLLECT_TIMEOUT_MS); + try { + // eslint-disable-next-line no-constant-condition + while (true) { + if (signal?.aborted) throw new Error("Request aborted during SSE collection"); + const { done, value } = await Promise.race([ + reader.read(), + new Promise((_, reject) => + timeout.addEventListener("abort", () => reject(new Error("SSE collection timed out")), { once: true }) + ), + ]); + if (done) break; + chunks.push(decoder.decode(value, { stream: true })); + } + } catch (err) { + const msg = err?.message || String(err); + timedOut = msg.includes("timed out"); + log?.warn?.("SSE_COLLECT", `Error collecting SSE stream: ${msg}`); + // Fall through — return whatever was collected so far + } + const rawSSE = chunks.join(""); + + // Parse Gemini SSE: each line is "data: {json}" + let textContent = ""; + let finishReason = "stop"; + let usage: Record | null = null; + const lines = rawSSE.split("\n"); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed.startsWith("data:")) continue; + const payload = trimmed.slice(5).trim(); + if (!payload || payload === "[DONE]") continue; + try { + const parsed = JSON.parse(payload); + const candidate = parsed?.response?.candidates?.[0]; + if (candidate?.content?.parts) { + for (const part of candidate.content.parts) { + if (typeof part.text === "string" && !part.thought && !part.thoughtSignature) { + textContent += part.text; + } + } + } + if (candidate?.finishReason) { + finishReason = candidate.finishReason.toLowerCase() === "stop" ? "stop" : candidate.finishReason.toLowerCase(); + } + if (parsed?.response?.usageMetadata) { + const um = parsed.response.usageMetadata; + usage = { + prompt_tokens: um.promptTokenCount || 0, + completion_tokens: um.candidatesTokenCount || 0, + total_tokens: um.totalTokenCount || 0, + }; + } + } catch (e) { + log?.debug?.("SSE_PARSE", `Skipping malformed SSE line: ${payload.slice(0, 80)}`); + } + } + + const result = { + id: `chatcmpl-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`, + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model, + choices: [ + { + index: 0, + message: { role: "assistant", content: textContent }, + finish_reason: timedOut ? "length" : finishReason, + }, + ], + ...(usage && { usage }), + }; + + const syntheticStatus = timedOut ? 504 : response.status; + const syntheticResponse = new Response(JSON.stringify(result), { + status: syntheticStatus, + statusText: timedOut ? "Gateway Timeout" : response.statusText, + headers: [["Content-Type", "application/json"]], + }); + + return { response: syntheticResponse, url, headers, transformedBody }; + }; + + return collect(); + } + async execute({ model, body, stream, credentials, signal, log, upstreamExtraHeaders }) { const fallbackCount = this.getFallbackCount(); let lastError = null; @@ -209,11 +321,21 @@ export class AntigravityExecutor extends BaseExecutor { const MAX_AUTO_RETRIES = 3; const retryAttemptsByUrl = {}; // Track retry attempts per URL + // Always stream upstream — buildUrl always returns the streaming endpoint. + // For non-streaming clients, we collect the SSE below and return a synthetic + // non-streaming Response so chatCore's non-streaming path stays unchanged. + const upstreamStream = true; + for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) { - const url = this.buildUrl(model, stream, urlIndex); - const headers = this.buildHeaders(credentials, stream); + const url = this.buildUrl(model, upstreamStream, urlIndex); + const headers = this.buildHeaders(credentials, upstreamStream); mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders); - const transformedBody = await this.transformRequest(model, body, stream, credentials); + const transformedBody = await this.transformRequest( + model, + body, + upstreamStream, + credentials + ); // Initialize retry counter for this URL if (!retryAttemptsByUrl[urlIndex]) { @@ -349,6 +471,12 @@ export class AntigravityExecutor extends BaseExecutor { } } + // For non-streaming clients, collect the SSE stream and return a synthetic + // non-streaming Response so chatCore doesn't need to handle SSE conversion. + if (!stream) { + return this.collectStreamToResponse(response, model, url, headers, transformedBody, log, signal); + } + return { response, url, headers, transformedBody }; } catch (error) { lastError = error; diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 033338ad63..dd989fe90d 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -13,7 +13,7 @@ import { refreshWithRetry } from "../services/tokenRefresh.ts"; import { createRequestLogger } from "../utils/requestLogger.ts"; import { getModelTargetFormat, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.ts"; import { resolveModelAlias } from "../services/modelDeprecation.ts"; -import { getUnsupportedParams } from "../config/providerRegistry.ts"; +import { getUnsupportedParams, getPassthroughProviders } from "../config/providerRegistry.ts"; import { buildErrorBody, createErrorResult, @@ -1208,19 +1208,32 @@ export async function handleChatCore({ `[provider] Node ${connectionId} account deactivated (${statusCode}) — disabling permanently` ); } else if (errorType === PROVIDER_ERROR_TYPES.RATE_LIMITED) { - const rateLimitedUntil = new Date(Date.now() + retryAfterMs).toISOString(); - await updateProviderConnection(connectionId, { - rateLimitedUntil: rateLimitedUntil, - testStatus: "credits_exhausted", - lastErrorType: errorType, - lastError: message, - errorCode: statusCode, - healthCheckInterval: null, - lastHealthCheckAt: null, - }); - console.warn( - `[provider] Node ${connectionId} rate limited (${statusCode}) - Next available at ${rateLimitedUntil}` - ); + // For passthrough providers (e.g. Antigravity), each model has independent + // quota. A 429 on one model must NOT lock out the entire connection — other + // models may still have quota available. Use lockModel() instead. + const isPassthrough = provider && getPassthroughProviders().has(provider); + if (isPassthrough) { + const { lockModel } = await import("../services/accountFallback.ts"); + const cooldown = retryAfterMs || 120_000; // 2 min default, same as COOLDOWN_MS.rateLimit + lockModel(provider, connectionId, model, "rate_limited", cooldown); + console.warn( + `[provider] Node ${connectionId} model-only rate limited (${statusCode}) for ${model} - ${Math.ceil(cooldown / 1000)}s (connection stays active)` + ); + } else { + const rateLimitedUntil = new Date(Date.now() + retryAfterMs).toISOString(); + await updateProviderConnection(connectionId, { + rateLimitedUntil: rateLimitedUntil, + testStatus: "credits_exhausted", + lastErrorType: errorType, + lastError: message, + errorCode: statusCode, + healthCheckInterval: null, + lastHealthCheckAt: null, + }); + console.warn( + `[provider] Node ${connectionId} rate limited (${statusCode}) - Next available at ${rateLimitedUntil}` + ); + } } else if (errorType === PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED) { await updateProviderConnection(connectionId, { testStatus: "credits_exhausted", diff --git a/open-sse/services/modelCapabilities.ts b/open-sse/services/modelCapabilities.ts index 7cd47e232f..5f72678d38 100644 --- a/open-sse/services/modelCapabilities.ts +++ b/open-sse/services/modelCapabilities.ts @@ -51,9 +51,6 @@ const REASONING_UNSUPPORTED_PATTERNS = [ "antigravity/claude-sonnet-4-6", "antigravity/claude-sonnet-4-5", "antigravity/claude-sonnet-4", - "ag/claude-sonnet-4-6", - "ag/claude-sonnet-4-5", - "ag/claude-sonnet-4", ]; function getRegistryReasoningFlag(providerIdOrAlias: string, modelId: string): boolean | null { diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index b4fe38f854..b3b189f9b4 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -657,34 +657,26 @@ function getAntigravityPlanLabel(subscriptionInfo) { /** * Antigravity Usage - Fetch quota from Google Cloud Code API - * Now calls loadCodeAssist ONCE (cached) and reuses for projectId + plan. - * Uses retrieveUserQuota API (same as Gemini CLI) for accurate quota data across all tiers. + * Uses fetchAvailableModels API which returns ALL models (including Claude) + * with per-model quotaInfo (remainingFraction, resetTime). + * retrieveUserQuota only returns Gemini models — not suitable for Antigravity. */ async function getAntigravityUsage(accessToken, providerSpecificData) { try { const subscriptionInfo = await getAntigravitySubscriptionInfoCached(accessToken); const projectId = subscriptionInfo?.cloudaicompanionProject || null; - if (!projectId) { - return { - plan: getAntigravityPlanLabel(subscriptionInfo), - message: "Antigravity project ID not available.", - }; - } - - // Use retrieveUserQuota API (same as Gemini CLI) - works correctly for both Free and Pro tiers - const response = await fetch( - "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota", - { - method: "POST", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ project: projectId }), - signal: AbortSignal.timeout(10000), - } - ); + // Fetch model list with quota info from fetchAvailableModels + const response = await fetch(ANTIGRAVITY_CONFIG.quotaApiUrl, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "User-Agent": ANTIGRAVITY_CONFIG.userAgent, + "Content-Type": "application/json", + }, + body: JSON.stringify(projectId ? { project: projectId } : {}), + signal: AbortSignal.timeout(10000), + }); if (response.status === 403) { return { message: "Antigravity access forbidden. Check subscription." }; @@ -695,28 +687,59 @@ async function getAntigravityUsage(accessToken, providerSpecificData) { } const data = await response.json(); + const dataObj = toRecord(data); + const modelEntries = toRecord(dataObj.models); const quotas: Record = {}; - // Parse buckets from retrieveUserQuota response (same format as Gemini CLI) - if (Array.isArray(data.buckets)) { - for (const bucket of data.buckets) { - if (!bucket.modelId || bucket.remainingFraction == null) continue; + // Models excluded from quota display — internal/special-purpose models that + // the Antigravity API returns quota for but are not user-callable via + // generateContent. Matches CLIProxyAPI's hardcoded exclusion list. + const ANTIGRAVITY_EXCLUDED_MODELS = new Set([ + "chat_20706", + "chat_23310", + "tab_flash_lite_preview", + "tab_jump_flash_lite_preview", + "gemini-2.5-flash-thinking", + "gemini-2.5-pro", // browser subagent model — not user-callable + "gemini-2.5-flash", // internal — quota always exhausted on free tier + "gemini-2.5-flash-lite", // internal — quota always exhausted on free tier + "gemini-2.5-flash-preview-image-generation", // image-gen only, not usable for chat + "gemini-3.1-flash-image-preview", // image-gen preview, not usable for chat + "gemini-3-flash-agent", // internal agent model — not user-callable + "gemini-3.1-flash-lite", // not usable for chat + "gemini-3-pro-low", // not usable for chat + "gemini-3-pro-high", // not usable for chat + ]); - const remainingFraction = toNumber(bucket.remainingFraction, 0); - const remainingPercentage = remainingFraction * 100; - const QUOTA_NORMALIZED_BASE = 1000; - const total = QUOTA_NORMALIZED_BASE; - const remaining = Math.round(total * remainingFraction); - const used = Math.max(0, total - remaining); + // Parse per-model quota info from fetchAvailableModels response. + for (const [modelKey, infoValue] of Object.entries(modelEntries)) { + const info = toRecord(infoValue); + const quotaInfo = toRecord(info.quotaInfo); - quotas[bucket.modelId] = { - used, - total, - resetAt: parseResetTime(bucket.resetTime), - remainingPercentage, - unlimited: false, - }; + // Skip internal, excluded, and models without quota info + if (info.isInternal === true || ANTIGRAVITY_EXCLUDED_MODELS.has(modelKey) || Object.keys(quotaInfo).length === 0) { + continue; } + + const rawFraction = toNumber(quotaInfo.remainingFraction, -1); + const resetAt = parseResetTime(quotaInfo.resetTime); + // Default to 100% when the API doesn't report a fraction + const remainingFraction = rawFraction < 0 ? 1 : rawFraction; + // Models with no resetTime and full remaining are unlimited (e.g. tab-completion models) + const isUnlimited = !resetAt && remainingFraction >= 1; + const remainingPercentage = remainingFraction * 100; + const QUOTA_NORMALIZED_BASE = 1000; + const total = QUOTA_NORMALIZED_BASE; + const remaining = Math.round(total * remainingFraction); + const used = isUnlimited ? 0 : Math.max(0, total - remaining); + + quotas[modelKey] = { + used, + total: isUnlimited ? 0 : total, + resetAt, + remainingPercentage: isUnlimited ? 100 : remainingPercentage, + unlimited: isUnlimited, + }; } return { diff --git a/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx index 181cded1fb..3c3845ba63 100644 --- a/src/app/(dashboard)/dashboard/combos/page.tsx +++ b/src/app/(dashboard)/dashboard/combos/page.tsx @@ -1450,10 +1450,10 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) { const PAID_PREMIUM_PRESET_MODELS = [ { model: "cu/claude-4.6-opus-high", weight: 0 }, - { model: "ag/claude-sonnet-4-6", weight: 0 }, + { model: "antigravity/claude-sonnet-4-6", weight: 0 }, { model: "cu/claude-4.6-sonnet-high", weight: 0 }, - { model: "ag/gpt-5", weight: 0 }, - { model: "ag/gemini-3.1-pro-preview", weight: 0 }, + { model: "antigravity/gemini-3.1-pro-high", weight: 0 }, + { model: "antigravity/gemini-3-pro-high", weight: 0 }, ]; const applyTemplate = (template) => { diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx index ef927c4333..1ef9380b52 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx @@ -619,7 +619,9 @@ export default function ProviderLimits() {
{quota.message}
) : quota?.quotas?.length > 0 ? ( quota.quotas.map((q, i) => { - const remainingPercentage = calculatePercentage(q.used, q.total); + const remainingPercentage = q.unlimited + ? 100 + : (q.remainingPercentage ?? calculatePercentage(q.used, q.total)); const colors = getBarColor(remainingPercentage); const cd = formatCountdown(q.resetAt); const shortName = formatQuotaLabel(q.name); diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx index b01a36514b..8bf11c95d4 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx @@ -204,8 +204,8 @@ export function parseQuotaData(provider, data) { if (data.quotas) { Object.entries(data.quotas).forEach(([modelKey, quota]: [string, any]) => { normalizedQuotas.push( - normalizeQuotaEntry(quota.displayName || modelKey, quota, { - modelKey: modelKey, // Keep modelKey for sorting + normalizeQuotaEntry(modelKey, quota, { + modelKey: modelKey, }) ); }); diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index d7e80d59a1..78d098a567 100644 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -69,11 +69,10 @@ const STATIC_MODEL_PROVIDERS: Record Array<{ id: string; name: str antigravity: () => [ { id: "claude-opus-4-6-thinking", name: "Claude Opus 4.6 Thinking" }, { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, - { id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" }, - { id: "gemini-3.1-flash-lite-preview", name: "Gemini 3.1 Flash Lite Preview" }, - { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" }, - { id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" }, - { id: "gemini-2.0-flash", name: "Gemini 2.0 Flash" }, + { id: "gemini-3-flash", name: "Gemini 3 Flash" }, + { id: "gemini-3.1-flash-image", name: "Gemini 3.1 Flash Image" }, + { id: "gemini-3.1-pro-high", name: "Gemini 3.1 Pro (High)" }, + { id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro (Low)" }, { id: "gpt-oss-120b-medium", name: "GPT OSS 120B Medium" }, ], claude: () => [ diff --git a/src/app/docs/page.tsx b/src/app/docs/page.tsx index 1895f1c35f..77bd26e631 100644 --- a/src/app/docs/page.tsx +++ b/src/app/docs/page.tsx @@ -394,7 +394,7 @@ export default function DocsPage() { {t("clientClaudeBullet1Prefix")}{" "} cc/{" "} {t("clientClaudeBullet1Middle")}{" "} - ag/{" "} + antigravity/{" "} {t("clientClaudeBullet1Suffix")}
  • {t("oauthAutoRefresh")}
  • diff --git a/src/shared/constants/modelSpecs.ts b/src/shared/constants/modelSpecs.ts index 4f5766b0a9..6b094fe545 100644 --- a/src/shared/constants/modelSpecs.ts +++ b/src/shared/constants/modelSpecs.ts @@ -32,7 +32,7 @@ export const MODEL_SPECS: Record = { // ── Gemini 3.1 Pro High ───────────────────────────────────────── "gemini-3.1-pro-high": { - maxOutputTokens: 131072, + maxOutputTokens: 65535, contextWindow: 1048576, defaultThinkingBudget: 24576, thinkingBudgetCap: 32768, @@ -45,7 +45,7 @@ export const MODEL_SPECS: Record = { // ── Gemini 3.1 Pro Low ────────────────────────────────────────── "gemini-3.1-pro-low": { - maxOutputTokens: 131072, + maxOutputTokens: 65535, contextWindow: 1048576, defaultThinkingBudget: 8192, thinkingBudgetCap: 16000, diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 6f81b77246..e448b40bbf 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -22,7 +22,7 @@ export const OAUTH_PROVIDERS = { claude: { id: "claude", alias: "cc", name: "Claude Code", icon: "smart_toy", color: "#D97757" }, antigravity: { id: "antigravity", - alias: "ag", + alias: undefined, name: "Antigravity", icon: "rocket_launch", color: "#F59E0B", @@ -643,13 +643,13 @@ export function getProviderAlias(providerId) { // Alias to ID mapping (for quick lookup) export const ALIAS_TO_ID = Object.values(AI_PROVIDERS).reduce((acc, p) => { - acc[p.alias] = p.id; + if (p.alias) acc[p.alias] = p.id; return acc; }, {}); // ID to Alias mapping export const ID_TO_ALIAS = Object.values(AI_PROVIDERS).reduce((acc, p) => { - acc[p.id] = p.alias; + acc[p.id] = p.alias || p.id; return acc; }, {}); diff --git a/src/shared/validation/providerSchema.ts b/src/shared/validation/providerSchema.ts index 4bc5b1e219..0ca395d312 100644 --- a/src/shared/validation/providerSchema.ts +++ b/src/shared/validation/providerSchema.ts @@ -12,7 +12,7 @@ import { z } from "zod"; export const ProviderSchema = z.object({ id: z.string().min(1), - alias: z.string().min(1), + alias: z.string().min(1).optional(), name: z.string().min(1), icon: z.string().min(1), color: z.string().regex(/^#[0-9A-Fa-f]{6}$/, "Must be a valid hex color (#RRGGBB)"), diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index 29beb5770c..e465a7fd49 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -15,7 +15,7 @@ import { isModelLocked, lockModel, } from "@omniroute/open-sse/services/accountFallback.ts"; -import { isLocalProvider } from "@omniroute/open-sse/config/providerRegistry.ts"; +import { isLocalProvider, getPassthroughProviders } from "@omniroute/open-sse/config/providerRegistry.ts"; import { COOLDOWN_MS } from "@omniroute/open-sse/config/constants.ts"; import { getCodexModelScope } from "@omniroute/open-sse/executors/codex.ts"; import * as log from "../utils/logger"; @@ -784,23 +784,40 @@ export async function markAccountUnavailable( const { shouldFallback, cooldownMs, newBackoffLevel, reason } = result; if (!shouldFallback) return { shouldFallback: false, cooldownMs: 0 }; - // ── Local provider 404: model-only lockout, connection stays active ── - // Detection: URL-based only (apiKey===null heuristic was too broad — could match - // cloud providers with non-standard auth stored in providerSpecificData). + // ── 404 model-only lockout: connection stays active ── + // For local providers (detected by URL) and cloud providers with passthrough models + // (like Antigravity), a 404 means the specific model doesn't exist or isn't available + // for this account — it should NOT lock out the entire connection. const connBaseUrl = (conn?.providerSpecificData as Record)?.baseUrl as | string | undefined; - if (isLocalProvider(connBaseUrl) && status === 404 && provider && model) { + const isPassthroughProvider = provider && getPassthroughProviders().has(provider); + if ((isLocalProvider(connBaseUrl) || isPassthroughProvider) && status === 404 && provider && model) { const localCooldown = COOLDOWN_MS.notFoundLocal; - lockModel(provider, connectionId, model, "local_not_found", localCooldown); + lockModel(provider, connectionId, model, "not_found", localCooldown); log.info( "AUTH", - `Local 404 for ${model} — model-only lockout ${localCooldown / 1000}s (connection stays active)` + `Model-only lockout for ${model} — 404 lockout ${localCooldown / 1000}s (connection stays active)` ); return { shouldFallback: true, cooldownMs: localCooldown }; } + // ── 429 model-only lockout for passthrough providers ── + // For passthrough providers like Antigravity, each model has independent quota. + // A 429 on one model should NOT lock out the entire connection — other models + // may still have quota available. Use lockModel() instead of connection-wide + // rateLimitedUntil, same pattern as the 404 model-only lockout above. + if (isPassthroughProvider && status === 429 && provider && model) { + const modelCooldown = cooldownMs || COOLDOWN_MS.rateLimit; + lockModel(provider, connectionId, model, reason || "rate_limited", modelCooldown); + log.info( + "AUTH", + `Model-only lockout for ${model} — 429 rate limit ${Math.ceil(modelCooldown / 1000)}s (connection stays active)` + ); + return { shouldFallback: true, cooldownMs: modelCooldown }; + } + const rateLimitedUntil = getUnavailableUntil(cooldownMs); const errorMsg = typeof errorText === "string" ? errorText.slice(0, 100) : "Provider error"; diff --git a/tests/unit/t28-model-catalog-updates.test.mjs b/tests/unit/t28-model-catalog-updates.test.mjs index 67750d0a0e..00e6f85088 100644 --- a/tests/unit/t28-model-catalog-updates.test.mjs +++ b/tests/unit/t28-model-catalog-updates.test.mjs @@ -15,11 +15,14 @@ test("T28: gemini catalog includes preview models from 9router", () => { assert.ok(geminiCliIds.includes("gemini-3-flash-preview")); }); -test("T28: antigravity static catalog includes Gemini 3.1 preview fallbacks", () => { +test("T28: antigravity static catalog includes Gemini 3 Pro High/Low tier models", () => { const staticIds = (getStaticModelsForProvider("antigravity") || []).map((m) => m.id); - assert.ok(staticIds.includes("gemini-3.1-pro-preview")); - assert.ok(staticIds.includes("gemini-3.1-flash-lite-preview")); + assert.ok(staticIds.includes("gemini-3.1-pro-high")); + assert.ok(staticIds.includes("gemini-3.1-pro-low")); + assert.ok(staticIds.includes("gemini-3-pro-high")); + assert.ok(staticIds.includes("gemini-3-pro-low")); + assert.ok(staticIds.includes("gemini-3-flash")); }); test("T28: qwen registry uses native chat.qwen.ai base URL", () => {