From fbd30dc4ee6de37c4f9d90abd0ac7b0896765254 Mon Sep 17 00:00:00 2001 From: Chris Staley Date: Tue, 31 Mar 2026 14:12:51 -0600 Subject: [PATCH 1/9] fix: update Antigravity model list and replace ag/ prefix with antigravity/ - Replace stale model IDs (gemini-3.1-pro-preview, gemini-3.1-flash-lite-preview) with correct High/Low tier variants from fetchAvailableModels API (gemini-3-pro-high, gemini-3-pro-low, gemini-3.1-pro-high, gemini-3.1-pro-low, etc.) - Remove ag/ alias prefix in favor of antigravity/ across registry, providers, model capabilities, combos, docs, and static model providers - Make provider alias optional in Zod schema and guard ALIAS_TO_ID/ID_TO_ALIAS maps - Show raw model IDs in quota display instead of unmapped display names - Update T28 model catalog test to assert new High/Low tier models --- open-sse/config/providerRegistry.ts | 15 +++++++++------ open-sse/services/modelCapabilities.ts | 3 --- src/app/(dashboard)/dashboard/combos/page.tsx | 6 +++--- .../usage/components/ProviderLimits/utils.tsx | 4 ++-- src/app/api/providers/[id]/models/route.ts | 11 +++++++++-- src/app/docs/page.tsx | 2 +- src/shared/constants/providers.ts | 6 +++--- src/shared/validation/providerSchema.ts | 2 +- tests/unit/t28-model-catalog-updates.test.mjs | 9 ++++++--- 9 files changed, 34 insertions(+), 24 deletions(-) diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 9afef5ca..7fc272ba 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: [ @@ -389,14 +389,17 @@ export const REGISTRY: Record = { { 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-3.1-pro-high", name: "Gemini 3.1 Pro (High)" }, + { id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro (Low)" }, + { id: "gemini-3.1-flash-image", name: "Gemini 3.1 Flash Image" }, + { id: "gemini-3-pro-high", name: "Gemini 3 Pro (High)" }, + { id: "gemini-3-pro-low", name: "Gemini 3 Pro (Low)" }, + { id: "gemini-3-flash", name: "Gemini 3 Flash" }, { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" }, { id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" }, + { id: "gemini-2.5-flash-lite", name: "Gemini 2.5 Flash Lite" }, { id: "gemini-2.0-flash", name: "Gemini 2.0 Flash" }, { 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, }, diff --git a/open-sse/services/modelCapabilities.ts b/open-sse/services/modelCapabilities.ts index 7cd47e23..5f72678d 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/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx index 181cded1..3c3845ba 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/utils.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx index b01a3651..8bf11c95 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 d7e80d59..e16be03d 100644 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -69,10 +69,17 @@ 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: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" }, + { id: "claude-sonnet-4", name: "Claude Sonnet 4" }, + { 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: "gemini-3.1-flash-image", name: "Gemini 3.1 Flash Image" }, + { id: "gemini-3-pro-high", name: "Gemini 3 Pro (High)" }, + { id: "gemini-3-pro-low", name: "Gemini 3 Pro (Low)" }, + { id: "gemini-3-flash", name: "Gemini 3 Flash" }, { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" }, { id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" }, + { id: "gemini-2.5-flash-lite", name: "Gemini 2.5 Flash Lite" }, { id: "gemini-2.0-flash", name: "Gemini 2.0 Flash" }, { id: "gpt-oss-120b-medium", name: "GPT OSS 120B Medium" }, ], diff --git a/src/app/docs/page.tsx b/src/app/docs/page.tsx index 1895f1c3..77bd26e6 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/providers.ts b/src/shared/constants/providers.ts index 6f81b772..e448b40b 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 4bc5b1e2..0ca395d3 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/tests/unit/t28-model-catalog-updates.test.mjs b/tests/unit/t28-model-catalog-updates.test.mjs index 67750d0a..00e6f850 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", () => { From 89eb5b7eb955bb9f7f179a6bbdce330c9f9ddaba Mon Sep 17 00:00:00 2001 From: Chris Staley Date: Tue, 31 Mar 2026 16:50:33 -0600 Subject: [PATCH 2/9] fix: use fetchAvailableModels for Antigravity quota instead of retrieveUserQuota retrieveUserQuota only returns Gemini model quotas. fetchAvailableModels returns all models (including Claude) with per-model quotaInfo. --- open-sse/services/usage.ts | 83 ++++++++++++++++++++------------------ 1 file changed, 43 insertions(+), 40 deletions(-) diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index b4fe38f8..698e65fd 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,39 @@ 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; + // Parse per-model quota info from fetchAvailableModels response. + // Show all models that have quota data, excluding only internal models + // (tab-completion, chat placeholders, etc.). + for (const [modelKey, infoValue] of Object.entries(modelEntries)) { + const info = toRecord(infoValue); + const quotaInfo = toRecord(info.quotaInfo); - 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); - - quotas[bucket.modelId] = { - used, - total, - resetAt: parseResetTime(bucket.resetTime), - remainingPercentage, - unlimited: false, - }; + // Skip internal models and models without quota info + if (info.isInternal === true || Object.keys(quotaInfo).length === 0) { + continue; } + + const remainingFraction = toNumber(quotaInfo.remainingFraction, 0); + const resetAt = parseResetTime(quotaInfo.resetTime); + // 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 { From 50831287749240434a4bb00a3bd7a26aff5492fe Mon Sep 17 00:00:00 2001 From: Chris Staley Date: Tue, 31 Mar 2026 17:23:27 -0600 Subject: [PATCH 3/9] fix: default missing remainingFraction to 1 instead of 0 Models without quota data (e.g. tab-completion models) were showing 0% because remainingFraction defaulted to 0 when absent. Now defaults to 1 so they show 100% remaining instead. --- open-sse/services/usage.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index 698e65fd..0e2c5fc9 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -703,8 +703,10 @@ async function getAntigravityUsage(accessToken, providerSpecificData) { continue; } - const remainingFraction = toNumber(quotaInfo.remainingFraction, 0); + 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; From adb8127a30e76d5c7fe36029f66995ff62645a55 Mon Sep 17 00:00:00 2001 From: Chris Staley Date: Tue, 31 Mar 2026 20:00:19 -0600 Subject: [PATCH 4/9] =?UTF-8?q?fix:=20Antigravity=20model=20access=20?= =?UTF-8?q?=E2=80=94=20registry,=20404=20lockout,=20and=20non-streaming=20?= =?UTF-8?q?requests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update model list to match CLIProxyAPI filtered models (remove stale gemini-2.5-pro, claude-sonnet-4-5, claude-sonnet-4, gemini-2.0-flash; sort alphabetically) - Extend 404 model-only lockout to passthrough providers so one missing model doesn't lock out the entire Antigravity connection - Always use streaming upstream endpoint (generateContent causes upstream 400 for some models that internally convert to OpenAI format with stream_options); collect SSE into JSON for non-streaming clients - Fix unlimited quota models showing 0% by checking q.unlimited before recalculating percentage --- open-sse/config/providerRegistry.ts | 31 +++-- open-sse/executors/antigravity.ts | 123 +++++++++++++++++- .../usage/components/ProviderLimits/index.tsx | 4 +- src/app/api/providers/[id]/models/route.ts | 16 +-- src/sse/services/auth.ts | 16 ++- 5 files changed, 156 insertions(+), 34 deletions(-) diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 7fc272ba..504c765c 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -387,18 +387,14 @@ 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-high", name: "Gemini 3.1 Pro (High)" }, - { id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro (Low)" }, - { id: "gemini-3.1-flash-image", name: "Gemini 3.1 Flash Image" }, - { id: "gemini-3-pro-high", name: "Gemini 3 Pro (High)" }, - { id: "gemini-3-pro-low", name: "Gemini 3 Pro (Low)" }, - { id: "gemini-3-flash", name: "Gemini 3 Flash" }, - { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" }, { id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" }, { id: "gemini-2.5-flash-lite", name: "Gemini 2.5 Flash Lite" }, - { id: "gemini-2.0-flash", name: "Gemini 2.0 Flash" }, + { id: "gemini-3-flash", name: "Gemini 3 Flash" }, + { id: "gemini-3-pro-high", name: "Gemini 3 Pro (High)" }, + { id: "gemini-3-pro-low", name: "Gemini 3 Pro (Low)" }, + { 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" }, ], passthroughModels: true, @@ -1553,6 +1549,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 0f9fac89..5519b9ce 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -22,8 +22,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 +36,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", }; } @@ -199,6 +203,102 @@ 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[] = []; + 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) { + log?.warn?.("SSE_COLLECT", `Error collecting SSE stream: ${err?.message || err}`); + // 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: finishReason, + }, + ], + ...(usage && { usage }), + }; + + const syntheticResponse = new Response(JSON.stringify(result), { + status: response.status, + statusText: 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; @@ -206,11 +306,16 @@ 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 = this.transformRequest(model, body, stream, credentials); + const transformedBody = this.transformRequest(model, body, upstreamStream, credentials); // Initialize retry counter for this URL if (!retryAttemptsByUrl[urlIndex]) { @@ -346,6 +451,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/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx index ef927c43..1ef9380b 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/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index e16be03d..d1d51716 100644 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -69,18 +69,14 @@ 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: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" }, - { id: "claude-sonnet-4", name: "Claude Sonnet 4" }, - { 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: "gemini-3.1-flash-image", name: "Gemini 3.1 Flash Image" }, - { id: "gemini-3-pro-high", name: "Gemini 3 Pro (High)" }, - { id: "gemini-3-pro-low", name: "Gemini 3 Pro (Low)" }, - { id: "gemini-3-flash", name: "Gemini 3 Flash" }, - { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" }, { id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" }, { id: "gemini-2.5-flash-lite", name: "Gemini 2.5 Flash Lite" }, - { id: "gemini-2.0-flash", name: "Gemini 2.0 Flash" }, + { id: "gemini-3-flash", name: "Gemini 3 Flash" }, + { id: "gemini-3-pro-high", name: "Gemini 3 Pro (High)" }, + { id: "gemini-3-pro-low", name: "Gemini 3 Pro (Low)" }, + { 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/sse/services/auth.ts b/src/sse/services/auth.ts index 29beb577..c07d886d 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,19 +784,21 @@ 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 }; } From c4e2627b432f754e7485578a29dcf01648a86b44 Mon Sep 17 00:00:00 2001 From: Chris Staley Date: Tue, 31 Mar 2026 21:51:02 -0600 Subject: [PATCH 5/9] fix: prevent Antigravity 429 cascade from locking out entire connection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 429 from one Antigravity model was marking the entire provider connection as rate-limited, blocking ALL other models on the same account. This happened in two places: chatCore's error classification (primary) and markAccountUnavailable (secondary). Both now use model-only lockModel() for passthrough providers instead of connection-wide rateLimitedUntil. Also adds: - Bare Pro model ID normalization (gemini-3-pro → gemini-3-pro-low) matching OpenClaw convention - Internal model exclusion list for quota display, matching CLIProxyAPI --- open-sse/executors/antigravity.ts | 10 +++++++- open-sse/handlers/chatCore.ts | 41 ++++++++++++++++++++----------- open-sse/services/usage.ts | 18 +++++++++++--- src/sse/services/auth.ts | 15 +++++++++++ 4 files changed, 65 insertions(+), 19 deletions(-) diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts index 5519b9ce..1623b598 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-pro", "gemini-3.1-pro", "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 { diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index d8a46133..c9612edd 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, @@ -1211,19 +1211,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 || 60_000; + 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/usage.ts b/open-sse/services/usage.ts index 0e2c5fc9..e18cf47f 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -691,15 +691,25 @@ async function getAntigravityUsage(accessToken, providerSpecificData) { const modelEntries = toRecord(dataObj.models); const quotas: Record = {}; + // 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 + ]); + // Parse per-model quota info from fetchAvailableModels response. - // Show all models that have quota data, excluding only internal models - // (tab-completion, chat placeholders, etc.). for (const [modelKey, infoValue] of Object.entries(modelEntries)) { const info = toRecord(infoValue); const quotaInfo = toRecord(info.quotaInfo); - // Skip internal models and models without quota info - if (info.isInternal === true || Object.keys(quotaInfo).length === 0) { + // Skip internal, excluded, and models without quota info + if (info.isInternal === true || ANTIGRAVITY_EXCLUDED_MODELS.has(modelKey) || Object.keys(quotaInfo).length === 0) { continue; } diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index c07d886d..7b24eaf1 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -803,6 +803,21 @@ export async function markAccountUnavailable( 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.rateLimited; + 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"; From c7da9223836097603ed250be4b34d0119ac2091d Mon Sep 17 00:00:00 2001 From: Chris Staley Date: Tue, 31 Mar 2026 21:58:21 -0600 Subject: [PATCH 6/9] fix: address PR review findings for Antigravity 429 cascade fix - Standardize cooldown fallback to 2 min (COOLDOWN_MS.rateLimit) in both chatCore and auth.ts instead of inconsistent 60s/undefined - Return 504 Gateway Timeout instead of 200 OK when SSE collection times out, with finish_reason "length" to signal incomplete response --- open-sse/executors/antigravity.ts | 12 ++++++++---- open-sse/handlers/chatCore.ts | 2 +- src/sse/services/auth.ts | 2 +- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts index 1623b598..545a73ae 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -224,6 +224,7 @@ export class AntigravityExecutor extends BaseExecutor { 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 @@ -239,7 +240,9 @@ export class AntigravityExecutor extends BaseExecutor { chunks.push(decoder.decode(value, { stream: true })); } } catch (err) { - log?.warn?.("SSE_COLLECT", `Error collecting SSE stream: ${err?.message || 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(""); @@ -289,15 +292,16 @@ export class AntigravityExecutor extends BaseExecutor { { index: 0, message: { role: "assistant", content: textContent }, - finish_reason: finishReason, + finish_reason: timedOut ? "length" : finishReason, }, ], ...(usage && { usage }), }; + const syntheticStatus = timedOut ? 504 : response.status; const syntheticResponse = new Response(JSON.stringify(result), { - status: response.status, - statusText: response.statusText, + status: syntheticStatus, + statusText: timedOut ? "Gateway Timeout" : response.statusText, headers: [["Content-Type", "application/json"]], }); diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index c9612edd..8b7176a9 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -1217,7 +1217,7 @@ export async function handleChatCore({ const isPassthrough = provider && getPassthroughProviders().has(provider); if (isPassthrough) { const { lockModel } = await import("../services/accountFallback.ts"); - const cooldown = retryAfterMs || 60_000; + 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)` diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index 7b24eaf1..e465a7fd 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -809,7 +809,7 @@ export async function markAccountUnavailable( // 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.rateLimited; + const modelCooldown = cooldownMs || COOLDOWN_MS.rateLimit; lockModel(provider, connectionId, model, reason || "rate_limited", modelCooldown); log.info( "AUTH", From 3fad8479caffb0d90267411a90be08d60bba7f1a Mon Sep 17 00:00:00 2001 From: Chris Staley Date: Tue, 31 Mar 2026 22:47:40 -0600 Subject: [PATCH 7/9] fix: remove non-viable Antigravity models from registry and quota display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Models removed from available list (not usable via chat completions): - gemini-3-pro-high/low — returns empty responses, quota unusable - gemini-2.5-flash/flash-lite — quota always exhausted on free tier - gemini-3.1-flash-image-preview — preview variant, not functional Models hidden from quota UI (in addition to above): - gemini-3-flash-agent — internal agent model - gemini-3.1-flash-lite — not usable for chat Kept gemini-3.1-flash-image in available models (confirmed working). Removed dead gemini-3-pro from bare Pro ID normalization. --- open-sse/config/providerRegistry.ts | 4 ---- open-sse/executors/antigravity.ts | 2 +- open-sse/services/usage.ts | 10 +++++++++- src/app/api/providers/[id]/models/route.ts | 4 ---- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 504c765c..c63d9a0c 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -387,11 +387,7 @@ 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: "gemini-2.5-flash", name: "Gemini 2.5 Flash" }, - { id: "gemini-2.5-flash-lite", name: "Gemini 2.5 Flash Lite" }, { id: "gemini-3-flash", name: "Gemini 3 Flash" }, - { id: "gemini-3-pro-high", name: "Gemini 3 Pro (High)" }, - { id: "gemini-3-pro-low", name: "Gemini 3 Pro (Low)" }, { 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)" }, diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts index 545a73ae..beb7439e 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -5,7 +5,7 @@ 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-pro", "gemini-3.1-pro", "gemini-3-1-pro"]); +const BARE_PRO_IDS = new Set(["gemini-3.1-pro"]); /** * Strip provider prefixes (e.g. "antigravity/model" → "model"). diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index e18cf47f..b3b189f9 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -700,7 +700,15 @@ async function getAntigravityUsage(accessToken, providerSpecificData) { "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-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 ]); // Parse per-model quota info from fetchAvailableModels response. diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index d1d51716..78d098a5 100644 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -69,11 +69,7 @@ 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-2.5-flash", name: "Gemini 2.5 Flash" }, - { id: "gemini-2.5-flash-lite", name: "Gemini 2.5 Flash Lite" }, { id: "gemini-3-flash", name: "Gemini 3 Flash" }, - { id: "gemini-3-pro-high", name: "Gemini 3 Pro (High)" }, - { id: "gemini-3-pro-low", name: "Gemini 3 Pro (Low)" }, { 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)" }, From 5df8abcddf641fa82a373868f08280d40ea36d49 Mon Sep 17 00:00:00 2001 From: Chris Staley Date: Tue, 31 Mar 2026 23:10:57 -0600 Subject: [PATCH 8/9] fix: cap gemini-3.1-pro maxOutputTokens and filter live models - Reduce maxOutputTokens from 131072 to 65535 for gemini-3.1-pro-high and gemini-3.1-pro-low, fixing 400 "invalid argument" errors from Open WebUI when no max_tokens is specified (upstream limit is 65535) - Filter non-viable models (gemini-3.1-flash-image-preview, gemini-2.5-flash-preview-image-generation, gemini-3-pro-high/low) from the live upstream API response in /api/providers/[id]/models --- src/app/api/providers/[id]/models/route.ts | 10 +++++++++- src/shared/constants/modelSpecs.ts | 4 ++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index 78d098a5..3c7b034f 100644 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -154,7 +154,15 @@ const PROVIDER_MODELS_CONFIG: Record = { authHeader: "Authorization", authPrefix: "Bearer ", body: {}, - parseResponse: (data) => data.models || [], + parseResponse: (data) => { + const excluded = new Set([ + "gemini-2.5-flash-preview-image-generation", + "gemini-3.1-flash-image-preview", + "gemini-3-pro-low", + "gemini-3-pro-high", + ]); + return (data.models || []).filter((m: any) => !excluded.has(m.model || m.id)); + }, }, openai: { url: "https://api.openai.com/v1/models", diff --git a/src/shared/constants/modelSpecs.ts b/src/shared/constants/modelSpecs.ts index 4f5766b0..6b094fe5 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, From ff158282e7a14b8e5154a6b8588bd8aecc7d7d0a Mon Sep 17 00:00:00 2001 From: Chris Staley Date: Tue, 31 Mar 2026 23:13:56 -0600 Subject: [PATCH 9/9] fix: remove non-functional Antigravity image models from imageRegistry The models gemini-2.5-flash-preview-image-generation and gemini-3.1-flash-image-preview were surfacing in the model catalog via getAllImageModels() from imageRegistry.ts, not from the live upstream API. Removed them from the image provider registry. --- open-sse/config/imageRegistry.ts | 5 +---- src/app/api/providers/[id]/models/route.ts | 10 +--------- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/open-sse/config/imageRegistry.ts b/open-sse/config/imageRegistry.ts index 9769431f..85ffcd57 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/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index 3c7b034f..78d098a5 100644 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -154,15 +154,7 @@ const PROVIDER_MODELS_CONFIG: Record = { authHeader: "Authorization", authPrefix: "Bearer ", body: {}, - parseResponse: (data) => { - const excluded = new Set([ - "gemini-2.5-flash-preview-image-generation", - "gemini-3.1-flash-image-preview", - "gemini-3-pro-low", - "gemini-3-pro-high", - ]); - return (data.models || []).filter((m: any) => !excluded.has(m.model || m.id)); - }, + parseResponse: (data) => data.models || [], }, openai: { url: "https://api.openai.com/v1/models",