diff --git a/.gitignore b/.gitignore index f32c82f9a5..88bcf2d3f3 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,7 @@ next-env.d.ts # data and logs data/ +.data/ logs/* # analysis directories (generated, not tracked) @@ -153,4 +154,7 @@ vscode-extension/ typescript # Gemini Antigravity agent data -.gemini/ \ No newline at end of file +.gemini/ + +# Superpowers plans/specs (internal tooling, not project code) +docs/superpowers/ \ No newline at end of file diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 1e493b355d..ab69c66df1 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -192,22 +192,9 @@ export const REGISTRY: Record = { clientSecretEnv: "GEMINI_OAUTH_CLIENT_SECRET", clientSecretDefault: "", }, - models: [ - { 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-pro", name: "Gemini 3.1 Pro" }, - { id: "gemini-3-1-pro", name: "Gemini 3.1 Pro (Alt ID)" }, - { 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-flash-preview", name: "Gemini 3 Flash Preview" }, - { 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-2.0-flash-exp", name: "Gemini 2.0 Flash Exp" }, - { id: "gemini-1.5-pro", name: "Gemini 1.5 Pro" }, - { id: "gemini-1.5-flash", name: "Gemini 1.5 Flash" }, - ], + models: [], + // Models are populated from Google's API via sync-models (per API key). + // No hardcoded fallback — show nothing until a key is added. }, "gemini-cli": { diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 024cca1839..c536220dcb 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -13,7 +13,9 @@ 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, getPassthroughProviders } from "../config/providerRegistry.ts"; +import { getUnsupportedParams } from "../config/providerRegistry.ts"; +import { hasPerModelQuota, lockModelIfPerModelQuota } from "../services/accountFallback.ts"; +import { COOLDOWN_MS } from "../config/constants.ts"; import { buildErrorBody, createErrorResult, @@ -1375,16 +1377,12 @@ export async function handleChatCore({ `[provider] Node ${connectionId} account deactivated (${statusCode}) — disabling permanently` ); } else if (errorType === PROVIDER_ERROR_TYPES.RATE_LIMITED) { - // 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); + // For providers with per-model quotas (passthrough providers, Gemini), + // each model has independent quota. A 429 on one model must NOT lock out + // the entire connection — other models may still have quota available. + if (lockModelIfPerModelQuota(provider, connectionId, model, "rate_limited", retryAfterMs || COOLDOWN_MS.rateLimit)) { console.warn( - `[provider] Node ${connectionId} model-only rate limited (${statusCode}) for ${model} - ${Math.ceil(cooldown / 1000)}s (connection stays active)` + `[provider] Node ${connectionId} model-only rate limited (${statusCode}) for ${model} - ${Math.ceil((retryAfterMs || COOLDOWN_MS.rateLimit) / 1000)}s (connection stays active)` ); } else { const rateLimitedUntil = new Date(Date.now() + retryAfterMs).toISOString(); @@ -1402,13 +1400,20 @@ export async function handleChatCore({ ); } } else if (errorType === PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED) { - await updateProviderConnection(connectionId, { - testStatus: "credits_exhausted", - lastErrorType: errorType, - lastError: message, - errorCode: statusCode, - }); - console.warn(`[provider] Node ${connectionId} exhausted quota (${statusCode})`); + // Providers with per-model quotas — lock the model only, not the connection + if (lockModelIfPerModelQuota(provider, connectionId, model, "quota_exhausted", retryAfterMs || COOLDOWN_MS.rateLimit)) { + console.warn( + `[provider] Node ${connectionId} model-only quota exhausted (${statusCode}) for ${model} - ${Math.ceil((retryAfterMs || COOLDOWN_MS.rateLimit) / 1000)}s (connection stays active)` + ); + } else { + await updateProviderConnection(connectionId, { + testStatus: "credits_exhausted", + lastErrorType: errorType, + lastError: message, + errorCode: statusCode, + }); + console.warn(`[provider] Node ${connectionId} exhausted quota (${statusCode})`); + } } else if (errorType === PROVIDER_ERROR_TYPES.ACCOUNT_DEACTIVATED) { await updateProviderConnection(connectionId, { isActive: false, diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index d485184976..d0c09e845e 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -100,13 +100,49 @@ export function lockModel(provider, connectionId, model, reason, cooldownMs) { if (!model) return; // No model → skip model-level locking ensureCleanupTimer(); const key = `${provider}:${connectionId}:${model}`; + const newUntil = Date.now() + cooldownMs; + // Preserve the longer cooldown if an existing lock has more time remaining. + // Safe without a mutex: no await between get/set, so this runs atomically + // within Node.js's single-threaded event loop. + const existing = modelLockouts.get(key); + if (existing && existing.until > newUntil) return; modelLockouts.set(key, { reason, - until: Date.now() + cooldownMs, + until: newUntil, lockedAt: Date.now(), }); } +/** + * Whether a provider should use per-model lockouts instead of connection-wide cooldowns. + * Gemini AI Studio has per-model quotas; passthrough providers have independent model limits. + */ +export function hasPerModelQuota(provider: string): boolean { + if (provider === "gemini") return true; + try { + const { getPassthroughProviders } = require("../config/providerRegistry.ts"); + return getPassthroughProviders().has(provider); + } catch { + return false; + } +} + +/** + * Lock a model (not connection) for a provider with per-model quotas. + * No-ops for providers that don't use per-model lockouts. + */ +export function lockModelIfPerModelQuota( + provider: string, + connectionId: string, + model: string | null, + reason: string, + cooldownMs: number +): boolean { + if (!hasPerModelQuota(provider) || !model) return false; + lockModel(provider, connectionId, model, reason, cooldownMs); + return true; +} + /** * Check if a specific model on a specific account is locked * @returns {boolean} diff --git a/open-sse/services/rateLimitManager.ts b/open-sse/services/rateLimitManager.ts index 2d7ba8549c..2b0dd54aa2 100644 --- a/open-sse/services/rateLimitManager.ts +++ b/open-sse/services/rateLimitManager.ts @@ -200,6 +200,11 @@ function getLimiterKey(provider, connectionId, model = null) { if (provider === "codex" && model) { return `${provider}:${getCodexRateLimitKey(connectionId, model)}`; } + // Gemini AI Studio has per-model quotas — use model-scoped limiter keys + // so a 429 on one model doesn't pause requests for other models. + if (provider === "gemini" && model) { + return `${provider}:${connectionId}:${model}`; + } return `${provider}:${connectionId}`; } @@ -570,7 +575,7 @@ export function updateFromResponseBody(provider, connectionId, responseBody, sta const { retryAfterMs, reason } = parseRetryAfterFromBody(responseBody); if (retryAfterMs && retryAfterMs > 0) { - const limiter = getLimiter(provider, connectionId, null); + const limiter = getLimiter(provider, connectionId, model); console.log( `🚫 [RATE-LIMIT] ${provider}:${connectionId.slice(0, 8)} — body-parsed retry: ${Math.ceil(retryAfterMs / 1000)}s (${reason})` ); diff --git a/open-sse/translator/helpers/geminiHelper.ts b/open-sse/translator/helpers/geminiHelper.ts index 1a9ff11208..dafdfd3a1b 100644 --- a/open-sse/translator/helpers/geminiHelper.ts +++ b/open-sse/translator/helpers/geminiHelper.ts @@ -84,7 +84,7 @@ export function convertOpenAIContentToParts(content) { const mimeType = mimePart.split(";")[0]; parts.push({ - inlineData: { mime_type: mimeType, data: data }, + inlineData: { mimeType, data }, }); } } diff --git a/open-sse/translator/request/claude-to-gemini.ts b/open-sse/translator/request/claude-to-gemini.ts index 76c53084fd..dc4212ec95 100644 --- a/open-sse/translator/request/claude-to-gemini.ts +++ b/open-sse/translator/request/claude-to-gemini.ts @@ -192,7 +192,7 @@ export function claudeToGeminiRequest(model, body, stream) { if (body.thinking?.type === "enabled" && body.thinking.budget_tokens) { result.generationConfig.thinkingConfig = { thinkingBudget: body.thinking.budget_tokens, - include_thoughts: true, + includeThoughts: true, }; } diff --git a/open-sse/translator/request/gemini-to-openai.ts b/open-sse/translator/request/gemini-to-openai.ts index 7208ad66e8..8be1012e74 100644 --- a/open-sse/translator/request/gemini-to-openai.ts +++ b/open-sse/translator/request/gemini-to-openai.ts @@ -92,11 +92,13 @@ function convertGeminiContent(content) { parts.push({ type: "text", text: part.text }); } - if (part.inlineData) { + if (part.inlineData || part.inline_data) { + const data = part.inlineData || part.inline_data; + const mimeType = data.mimeType || data.mime_type || "image/png"; parts.push({ type: "image_url", image_url: { - url: `data:${part.inlineData.mimeType};base64,${part.inlineData.data}`, + url: `data:${mimeType};base64,${data.data}`, }, }); } diff --git a/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index eafdada22b..45c23c6964 100644 --- a/open-sse/translator/request/openai-to-gemini.ts +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -33,7 +33,7 @@ type GeminiGenerationConfig = { maxOutputTokens?: unknown; thinkingConfig?: { thinkingBudget: number; - include_thoughts: boolean; + includeThoughts: boolean; }; responseMimeType?: string; responseSchema?: unknown; @@ -317,7 +317,7 @@ export function openaiToGeminiCLIRequest(model, body, stream) { const budget = budgetMap[body.reasoning_effort] || getDefaultThinkingBudget(model) || 8192; gemini.generationConfig.thinkingConfig = { thinkingBudget: budget, - include_thoughts: true, + includeThoughts: true, }; } @@ -325,7 +325,7 @@ export function openaiToGeminiCLIRequest(model, body, stream) { if (body.thinking?.type === "enabled" && body.thinking.budget_tokens) { gemini.generationConfig.thinkingConfig = { thinkingBudget: body.thinking.budget_tokens, - include_thoughts: true, + includeThoughts: true, }; } @@ -446,7 +446,7 @@ function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = nu } else if (block.type === "image" && block.source) { parts.push({ inlineData: { - mime_type: block.source.media_type, + mimeType: block.source.media_type, data: block.source.data, }, }); diff --git a/open-sse/translator/response/gemini-to-claude.ts b/open-sse/translator/response/gemini-to-claude.ts index 03578ea1b1..a9b438ab05 100644 --- a/open-sse/translator/response/gemini-to-claude.ts +++ b/open-sse/translator/response/gemini-to-claude.ts @@ -171,6 +171,11 @@ export function geminiToClaudeResponse(chunk, state) { stopReason = "tool_use"; } else if (reason === "max_tokens" || reason === "length") { stopReason = "max_tokens"; + } else if (reason === "safety" || reason === "recitation" || reason === "blocklist") { + // Content blocked by Gemini safety. Any text streamed before this finish + // reason has already been emitted to the client — this is unavoidable in + // SSE streaming. Map to end_turn (Claude has no "content blocked" reason). + stopReason = "end_turn"; } else { stopReason = "end_turn"; } diff --git a/open-sse/translator/response/gemini-to-openai.ts b/open-sse/translator/response/gemini-to-openai.ts index 8db3d51cbd..df40b6062a 100644 --- a/open-sse/translator/response/gemini-to-openai.ts +++ b/open-sse/translator/response/gemini-to-openai.ts @@ -225,6 +225,11 @@ export function geminiToOpenAIResponse(chunk, state) { if (finishReason === "stop" && state.toolCalls.size > 0) { finishReason = "tool_calls"; } + // Content blocked by Gemini safety filters — pass through as "content_filter" + // so downstream clients can distinguish from normal completion. + if (finishReason === "safety" || finishReason === "recitation" || finishReason === "blocklist") { + finishReason = "content_filter"; + } const finalChunk: Record = { id: `chatcmpl-${state.messageId}`, diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index 06f81eedfd..d71b1bebb7 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -840,6 +840,7 @@ export default function ProviderDetailPage() { customModels: CompatModelRow[]; modelCompatOverrides: Array; }>({ customModels: [], modelCompatOverrides: [] }); + const [syncedAvailableModels, setSyncedAvailableModels] = useState([]); const [compatSavingModelId, setCompatSavingModelId] = useState(null); const [applyingCodexAuthId, setApplyingCodexAuthId] = useState(null); const [exportingCodexAuthId, setExportingCodexAuthId] = useState(null); @@ -881,7 +882,11 @@ export default function ProviderDetailPage() { !!(FREE_PROVIDERS as any)[providerId] || !!(OAUTH_PROVIDERS as any)[providerId]; const providerSupportsPat = supportsApiKeyOnFreeProvider(providerId); const isOAuth = providerSupportsOAuth && !providerSupportsPat; - const models = getModelsByProviderId(providerId); + const registryModels = getModelsByProviderId(providerId); + // For Gemini: always use synced API models (empty if no keys added yet) + const models = providerId === "gemini" + ? syncedAvailableModels + : registryModels; const providerAlias = getProviderAlias(providerId); const isManagedAvailableModelsProvider = isCompatible || providerId === "openrouter"; const isSearchProvider = providerId.endsWith("-search"); @@ -915,6 +920,20 @@ export default function ProviderDetailPage() { customModels: data.models || [], modelCompatOverrides: data.modelCompatOverrides || [], }); + // Fetch synced available models for Gemini + if (providerId === "gemini") { + try { + const syncRes = await fetch("/api/synced-available-models?provider=gemini", { + cache: "no-store", + }); + if (syncRes.ok) { + const syncData = await syncRes.json(); + setSyncedAvailableModels(syncData.models || []); + } + } catch { + // Non-critical + } + } } catch (e) { console.error("fetchProviderModelMeta", e); } @@ -1060,6 +1079,10 @@ export default function ProviderDetailPage() { const res = await fetch(`/api/providers/${id}`, { method: "DELETE" }); if (res.ok) { setConnections(connections.filter((c) => c.id !== id)); + // Refresh model list after connection deletion (synced models may change) + if (providerId === "gemini") { + await fetchProviderModelMeta(); + } } } catch (error) { console.log("Error deleting connection:", error); @@ -1087,8 +1110,72 @@ export default function ProviderDetailPage() { body: JSON.stringify({ provider: providerId, ...formData }), }); if (res.ok) { + const connectionData = await res.json(); + const newConnection = connectionData?.connection; await fetchConnections(); setShowAddApiKeyModal(false); + + // For Gemini: show progress dialog and sync models from endpoint + if (providerId === "gemini" && newConnection?.id) { + setShowImportModal(true); + setImportProgress({ + current: 0, + total: 0, + phase: "fetching", + status: t("fetchingModels"), + logs: [], + error: "", + importedCount: 0, + }); + + try { + const syncRes = await fetch(`/api/providers/${newConnection.id}/sync-models`, { + method: "POST", + signal: AbortSignal.timeout(30_000), // 30s timeout — model sync shouldn't hang + }); + const syncData = await syncRes.json(); + + if (!syncRes.ok || syncData.error) { + setImportProgress((prev) => ({ + ...prev, + phase: "error", + status: t("failedFetchModels"), + error: syncData.error?.message || syncData.error || t("failedImportModels"), + })); + return null; + } + + const syncedCount = syncData.syncedModels || 0; + const syncedModelList: Array<{ id: string; name?: string }> = syncData.models || []; + const logs: string[] = []; + if (syncedModelList.length > 0) { + logs.push(`✓ ${syncedCount} models available`); + logs.push(""); + for (const m of syncedModelList) { + logs.push(` ${m.name || m.id}`); + } + } + + setImportProgress((prev) => ({ + ...prev, + phase: "done", + status: t("modelsImported", { count: syncedCount }), + total: syncedCount, + current: syncedCount, + importedCount: syncedCount, + logs, + })); + + await fetchProviderModelMeta(); + } catch (syncError) { + setImportProgress((prev) => ({ + ...prev, + phase: "error", + status: t("failedFetchModels"), + error: String(syncError), + })); + } + } return null; } const data = await res.json().catch(() => ({})); @@ -1963,7 +2050,7 @@ export default function ProviderDetailPage() { ); } - const importButton = ( + const importButton = providerId === "gemini" ? null : (
+
)} diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index 40a27d4166..b9fb870b1a 100644 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -130,16 +130,56 @@ const PROVIDER_MODELS_CONFIG: Record = { parseResponse: (data) => data.data || [], }, gemini: { - url: "https://generativelanguage.googleapis.com/v1beta/models", + url: "https://generativelanguage.googleapis.com/v1beta/models?pageSize=1000", method: "GET", headers: { "Content-Type": "application/json" }, authQuery: "key", // Use query param for API key - parseResponse: (data) => - (data.models || []).map((m) => ({ - ...m, - id: (m.name || m.id || "").replace(/^models\//, ""), - name: m.displayName || (m.name || "").replace(/^models\//, ""), - })), + parseResponse: (data) => { + const METHOD_TO_ENDPOINT: Record = { + generateContent: "chat", + embedContent: "embeddings", + predict: "images", + predictLongRunning: "images", + bidiGenerateContent: "audio", + generateAnswer: "chat", + }; + const IGNORED_METHODS = new Set([ + "countTokens", + "countTextTokens", + "createCachedContent", + "batchGenerateContent", + "asyncBatchEmbedContent", + ]); + + return (data.models || []).map((m: Record) => { + const methods: string[] = Array.isArray(m.supportedGenerationMethods) + ? m.supportedGenerationMethods + : []; + const endpoints = [ + ...new Set( + methods + .filter((method) => !IGNORED_METHODS.has(method)) + .map((method) => METHOD_TO_ENDPOINT[method] || "chat") + ), + ]; + if (endpoints.length === 0) endpoints.push("chat"); + + return { + ...m, + id: ((m.name as string) || (m.id as string) || "").replace(/^models\//, ""), + name: (m.displayName as string) || ((m.name as string) || "").replace(/^models\//, ""), + supportedEndpoints: endpoints, + ...(typeof m.inputTokenLimit === "number" + ? { inputTokenLimit: m.inputTokenLimit } + : {}), + ...(typeof m.outputTokenLimit === "number" + ? { outputTokenLimit: m.outputTokenLimit } + : {}), + ...(typeof m.description === "string" ? { description: m.description } : {}), + ...(m.thinking === true ? { supportsThinking: true } : {}), + }; + }); + }, }, // gemini-cli handled via retrieveUserQuota (see GET handler) qwen: { @@ -648,7 +688,7 @@ export async function GET( // Build request URL let url = config.url; if (config.authQuery) { - url += `?${config.authQuery}=${token}`; + url += `${url.includes("?") ? "&" : "?"}${config.authQuery}=${token}`; } // Build headers @@ -657,7 +697,7 @@ export async function GET( headers[config.authHeader] = (config.authPrefix || "") + token; } - // Make request + // Make request (with pagination for providers that use nextPageToken, e.g. Gemini) const fetchOptions: any = { method: config.method, headers, @@ -667,24 +707,53 @@ export async function GET( fetchOptions.body = JSON.stringify(config.body); } - const response = await fetch(url, fetchOptions); + let allModels: any[] = []; + let pageUrl = url; + let pageCount = 0; + const MAX_PAGES = 20; // Safety limit + const seenTokens = new Set(); - if (!response.ok) { - const errorText = await response.text(); - console.log(`Error fetching models from ${provider}:`, errorText); - return NextResponse.json( - { error: `Failed to fetch models: ${response.status}` }, - { status: response.status } - ); + while (pageUrl && pageCount < MAX_PAGES) { + pageCount++; + const response = await fetch(pageUrl, { + ...fetchOptions, + signal: AbortSignal.timeout(15_000), + }); + + if (!response.ok) { + const errorText = await response.text(); + console.log(`Error fetching models from ${provider}:`, errorText); + return NextResponse.json( + { error: `Failed to fetch models: ${response.status}` }, + { status: response.status } + ); + } + + const data = await response.json(); + const pageModels = config.parseResponse(data); + allModels = allModels.concat(pageModels); + + const nextPageToken = data.nextPageToken; + if (!nextPageToken) break; + if (seenTokens.has(nextPageToken)) { + console.warn(`[models] ${provider}: duplicate nextPageToken detected, stopping pagination`); + break; + } + seenTokens.add(nextPageToken); + pageUrl = `${config.url}${config.url.includes("?") ? "&" : "?"}pageToken=${encodeURIComponent(nextPageToken)}`; + if (config.authQuery) { + pageUrl += `&${config.authQuery}=${token}`; + } } - const data = await response.json(); - const models = config.parseResponse(data); + if (pageCount > 1) { + console.log(`[models] ${provider}: fetched ${allModels.length} models across ${pageCount} pages`); + } return buildResponse({ provider, connectionId, - models, + models: allModels, }); } catch (error) { console.log("Error fetching provider models:", error); diff --git a/src/app/api/providers/[id]/route.ts b/src/app/api/providers/[id]/route.ts index 6d91e3f3f1..43c6624736 100644 --- a/src/app/api/providers/[id]/route.ts +++ b/src/app/api/providers/[id]/route.ts @@ -169,11 +169,27 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i try { const { id } = await params; + // Fetch connection before deleting to check provider type + const connection = await getProviderConnectionById(id); + if (!connection) { + return NextResponse.json({ error: "Connection not found" }, { status: 404 }); + } + const deleted = await deleteProviderConnection(id); if (!deleted) { return NextResponse.json({ error: "Connection not found" }, { status: 404 }); } + // Clean up synced available models for this connection + if (connection.provider === "gemini") { + try { + const { deleteSyncedAvailableModelsForConnection } = await import("@/lib/db/models"); + await deleteSyncedAvailableModelsForConnection("gemini", id); + } catch (e) { + console.error("Failed to clean up synced models for deleted gemini connection:", e); + } + } + // Auto sync to Cloud if enabled await syncToCloudIfEnabled(); diff --git a/src/app/api/providers/[id]/sync-models/route.ts b/src/app/api/providers/[id]/sync-models/route.ts index 3792aa204b..d4f9a40e0f 100644 --- a/src/app/api/providers/[id]/sync-models/route.ts +++ b/src/app/api/providers/[id]/sync-models/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { getProviderConnectionById } from "@/models"; -import { getCustomModels, replaceCustomModels } from "@/lib/db/models"; +import { getCustomModels, replaceCustomModels, replaceSyncedAvailableModelsForConnection } from "@/lib/db/models"; import { syncManagedAvailableModelAliases, usesManagedAvailableModels, @@ -188,11 +188,38 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: id: m.id || m.name || m.model, name: m.name || m.displayName || m.id || m.model, source: "auto-sync", + ...(Array.isArray(m.supportedEndpoints) && m.supportedEndpoints.length > 0 + ? { supportedEndpoints: m.supportedEndpoints } + : {}), + ...(typeof m.inputTokenLimit === "number" ? { inputTokenLimit: m.inputTokenLimit } : {}), + ...(typeof m.outputTokenLimit === "number" ? { outputTokenLimit: m.outputTokenLimit } : {}), + ...(typeof m.description === "string" ? { description: m.description } : {}), + ...(m.supportsThinking === true ? { supportsThinking: true } : {}), })) .filter((m: any) => m.id && !registryIds.has(m.id)); const previousModels = await getCustomModels(logProvider); const replaced = await replaceCustomModels(logProvider, models); + + // For Gemini: also write to syncedAvailableModels (unioned across API keys) + if (logProvider === "gemini") { + try { + const syncedModels = models.map((m: any) => ({ + id: m.id, + name: m.name || m.id, + source: "api-sync" as const, + ...(m.supportedEndpoints ? { supportedEndpoints: m.supportedEndpoints } : {}), + ...(typeof m.inputTokenLimit === "number" ? { inputTokenLimit: m.inputTokenLimit } : {}), + ...(typeof m.outputTokenLimit === "number" ? { outputTokenLimit: m.outputTokenLimit } : {}), + ...(typeof m.description === "string" ? { description: m.description } : {}), + ...(m.supportsThinking === true ? { supportsThinking: true } : {}), + })); + await replaceSyncedAvailableModelsForConnection(logProvider, id, syncedModels); + } catch (e) { + console.error("Failed to union synced available models for gemini:", e); + } + } + const modelChanges = summarizeModelChanges(previousModels, replaced); let syncedAliases = 0; diff --git a/src/app/api/providers/route.ts b/src/app/api/providers/route.ts index 06ff7ebb1a..42275e7c8b 100644 --- a/src/app/api/providers/route.ts +++ b/src/app/api/providers/route.ts @@ -145,6 +145,8 @@ export async function POST(request: Request) { testStatus: testStatus || "unknown", }); + // Note: Gemini model sync is now triggered client-side with progress dialog + // Hide sensitive fields const result: Record = { ...newConnection }; delete result.apiKey; diff --git a/src/app/api/synced-available-models/route.ts b/src/app/api/synced-available-models/route.ts new file mode 100644 index 0000000000..1dd9686548 --- /dev/null +++ b/src/app/api/synced-available-models/route.ts @@ -0,0 +1,33 @@ +import { getSyncedAvailableModels, getAllSyncedAvailableModels } from "@/lib/db/models"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; + +/** + * GET /api/synced-available-models?provider= + * List synced available models for a provider (or all providers). + */ +export async function GET(request: Request) { + try { + if (!(await isAuthenticated(request))) { + return Response.json( + { error: { message: "Authentication required", type: "invalid_api_key" } }, + { status: 401 } + ); + } + + const { searchParams } = new URL(request.url); + const provider = searchParams.get("provider"); + + if (provider) { + const models = await getSyncedAvailableModels(provider); + return Response.json({ models }); + } + + const allModels = await getAllSyncedAvailableModels(); + return Response.json(allModels); + } catch { + return Response.json( + { error: { message: "Failed to fetch synced available models", type: "server_error" } }, + { status: 500 } + ); + } +} diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index 73d4998c6f..2cd0e8465b 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -18,6 +18,7 @@ import { getAllModerationModels } from "@omniroute/open-sse/config/moderationReg import { getAllVideoModels } from "@omniroute/open-sse/config/videoRegistry.ts"; import { getAllMusicModels } from "@omniroute/open-sse/config/musicRegistry.ts"; import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts"; +import { getSyncedAvailableModels } from "@/lib/db/models"; const FALLBACK_ALIAS_TO_PROVIDER = { ag: "antigravity", @@ -179,7 +180,7 @@ export async function getUnifiedModelsResponse( connections = connections.filter((c) => c.isActive !== false); } catch (e) { // If database not available, show no provider models (safe default) - console.log("Could not fetch providers, showing only combos/custom models"); + console.log("[catalog] Could not fetch providers:", e); } // Get provider nodes (for compatible providers with custom prefixes) @@ -296,6 +297,63 @@ export async function getUnifiedModelsResponse( } } + // Gemini: synced API models exclusively (outside PROVIDER_MODELS loop since registry is empty) + if (activeAliases.has("gemini") && !blockedProviders.has("gemini")) { + try { + const syncedModels = await getSyncedAvailableModels("gemini"); + for (const sm of syncedModels) { + const aliasId = `gemini/${sm.id}`; + if (getModelIsHidden("gemini", sm.id)) continue; + + // Convert supportedEndpoints to type/subtype for endpoint categorization + const endpoints = Array.isArray(sm.supportedEndpoints) + ? sm.supportedEndpoints + : ["chat"]; + let modelType: string | undefined; + if (endpoints.includes("embeddings")) modelType = "embedding"; + else if (endpoints.includes("images")) modelType = "image"; + else if (endpoints.includes("audio")) modelType = "audio"; + + models.push({ + id: aliasId, + object: "model", + created: timestamp, + owned_by: "gemini", + permission: [], + root: sm.id, + parent: null, + ...(modelType ? { type: modelType } : {}), + ...(modelType === "audio" ? { subtype: "transcription" } : {}), + ...(sm.inputTokenLimit ? { context_length: sm.inputTokenLimit } : {}), + ...(endpoints.length > 1 || !endpoints.includes("chat") + ? { supported_endpoints: endpoints } + : {}), + }); + + // For audio models, also add a speech variant so they appear in both sections + if (modelType === "audio") { + models.push({ + id: aliasId, + object: "model", + created: timestamp, + owned_by: "gemini", + permission: [], + root: sm.id, + parent: null, + type: "audio", + subtype: "speech", + ...(sm.inputTokenLimit ? { context_length: sm.inputTokenLimit } : {}), + ...(endpoints.length > 1 || !endpoints.includes("chat") + ? { supported_endpoints: endpoints } + : {}), + }); + } + } + } catch (err) { + console.error("[catalog] Error fetching synced Gemini models:", err); + } + } + // Helper: check if a provider is active (by provider id or alias) const isProviderActive = (provider: string) => { if (activeAliases.size === 0) return false; // No active connections = show nothing @@ -394,6 +452,8 @@ export async function getUnifiedModelsResponse( try { const customModelsMap = (await getAllCustomModels()) as Record; for (const [providerId, rawProviderCustomModels] of Object.entries(customModelsMap)) { + // Skip Gemini — handled by syncedAvailableModels above + if (providerId === "gemini") continue; const providerCustomModels = Array.isArray(rawProviderCustomModels) ? rawProviderCustomModels.filter( (model): model is Record => @@ -454,6 +514,9 @@ export async function getUnifiedModelsResponse( ...(endpoints.length > 1 || !endpoints.includes("chat") ? { supported_endpoints: endpoints } : {}), + ...(typeof (model as any).inputTokenLimit === "number" + ? { context_length: (model as any).inputTokenLimit } + : {}), ...(visionFields || {}), }); @@ -476,6 +539,9 @@ export async function getUnifiedModelsResponse( parent: aliasId, custom: true, ...(modelType ? { type: modelType } : {}), + ...(typeof (model as any).inputTokenLimit === "number" + ? { context_length: (model as any).inputTokenLimit } + : {}), ...(providerVisionFields || {}), }); } diff --git a/src/app/api/v1beta/models/route.ts b/src/app/api/v1beta/models/route.ts index 1838e63375..87393213f4 100644 --- a/src/app/api/v1beta/models/route.ts +++ b/src/app/api/v1beta/models/route.ts @@ -1,5 +1,6 @@ import { CORS_ORIGIN } from "@/shared/utils/cors"; import { PROVIDER_MODELS } from "@/shared/constants/models"; +import { getAllCustomModels, getSyncedAvailableModels } from "@/lib/db/models"; /** * Handle CORS preflight @@ -16,13 +17,13 @@ export async function OPTIONS() { /** * GET /v1beta/models - Gemini compatible models list - * Returns models in Gemini API format + * Returns models in Gemini API format with real token limits when available. */ export async function GET() { try { - // Collect all models from all providers const models = []; + // Built-in models (hardcoded defaults) for (const [provider, providerModels] of Object.entries(PROVIDER_MODELS)) { for (const model of providerModels) { models.push({ @@ -36,8 +37,60 @@ export async function GET() { } } + // Gemini: always replace hardcoded entries with synced models (no fallback) + // Always remove hardcoded gemini entries — even if sync returns empty + for (let i = models.length - 1; i >= 0; i--) { + if (typeof (models[i] as any).name === "string" && (models[i] as any).name.startsWith("models/gemini/")) { + models.splice(i, 1); + } + } + try { + const syncedGeminiModels = await getSyncedAvailableModels("gemini"); + for (const m of syncedGeminiModels) { + models.push({ + name: `models/gemini/${m.id}`, + displayName: m.name || m.id, + ...(typeof m.description === "string" ? { description: m.description } : {}), + supportedGenerationMethods: ["generateContent"], + inputTokenLimit: typeof m.inputTokenLimit === "number" ? m.inputTokenLimit : 128000, + outputTokenLimit: typeof m.outputTokenLimit === "number" ? m.outputTokenLimit : 8192, + ...(m.supportsThinking === true ? { thinking: true } : {}), + }); + } + } catch (err) { + console.error("[v1beta/models] Error fetching synced Gemini models:", err); + } + + // Custom models (use stored metadata from provider APIs) + try { + const customModelsMap = (await getAllCustomModels()) as Record; + for (const [providerId, rawModels] of Object.entries(customModelsMap)) { + if (!Array.isArray(rawModels)) continue; + // Skip Gemini — handled by syncedAvailableModels above + if (providerId === "gemini") continue; + for (const model of rawModels) { + if (!model || typeof model !== "object" || typeof (model as any).id !== "string") continue; + const m = model as Record; + if (m.isHidden === true) continue; + models.push({ + name: `models/${providerId}/${m.id}`, + displayName: m.name || m.id, + ...(typeof m.description === "string" ? { description: m.description } : {}), + supportedGenerationMethods: ["generateContent"], + inputTokenLimit: + typeof m.inputTokenLimit === "number" ? m.inputTokenLimit : 128000, + outputTokenLimit: + typeof m.outputTokenLimit === "number" ? m.outputTokenLimit : 8192, + ...(m.supportsThinking === true ? { thinking: true } : {}), + }); + } + } + } catch { + // Custom models are optional — skip on error + } + return Response.json({ models }); - } catch (error) { + } catch (error: any) { console.log("Error fetching models:", error); return Response.json({ error: { message: error.message } }, { status: 500 }); } diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 969492f42a..9fd535b5d6 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1595,6 +1595,7 @@ "chatCompletions": "Chat Completions", "importingModels": "Importing...", "importFromModels": "Import from /models", + "modelsImported": "{count} models imported", "allModelsAlreadyImported": "All models already imported", "noNewModelsToImport": "No new models to import — all models are already in the registry or custom models list", "skippingExistingModels": "Skipping {count} existing models", @@ -1616,6 +1617,7 @@ "importFailed": "Import failed", "noNewModelsAdded": "No new models were added.", "adding": "Adding...", + "close": "Close", "importingModelsTitle": "Importing Models", "copyModel": "Copy model", "removeModel": "Remove model", diff --git a/src/lib/db/models.ts b/src/lib/db/models.ts index 74234a7f12..e5545de694 100644 --- a/src/lib/db/models.ts +++ b/src/lib/db/models.ts @@ -383,6 +383,10 @@ export async function replaceCustomModels( source?: string; apiFormat?: string; supportedEndpoints?: string[]; + inputTokenLimit?: number; + outputTokenLimit?: number; + description?: string; + supportsThinking?: boolean; }>, { allowEmpty = false }: { allowEmpty?: boolean } = {} ) { @@ -412,6 +416,27 @@ export async function replaceCustomModels( source: m.source || "auto-sync", apiFormat: m.apiFormat || (prev as any)?.apiFormat || "chat-completions", supportedEndpoints: m.supportedEndpoints || (prev as any)?.supportedEndpoints || ["chat"], + // Preserve metadata from provider API (or previous sync) + ...(m.inputTokenLimit != null + ? { inputTokenLimit: m.inputTokenLimit } + : (prev as any)?.inputTokenLimit != null + ? { inputTokenLimit: (prev as any).inputTokenLimit } + : {}), + ...(m.outputTokenLimit != null + ? { outputTokenLimit: m.outputTokenLimit } + : (prev as any)?.outputTokenLimit != null + ? { outputTokenLimit: (prev as any).outputTokenLimit } + : {}), + ...(m.description != null + ? { description: m.description } + : (prev as any)?.description != null + ? { description: (prev as any).description } + : {}), + ...(m.supportsThinking != null + ? { supportsThinking: m.supportsThinking } + : (prev as any)?.supportsThinking != null + ? { supportsThinking: (prev as any).supportsThinking } + : {}), // Preserve existing compat flags ...(prev && (prev as any).normalizeToolCallId !== undefined ? { normalizeToolCallId: (prev as any).normalizeToolCallId } @@ -481,6 +506,108 @@ export async function removeCustomModel(providerId: string, modelId: string) { return true; } +// ──────────────── Synced Available Models ──────────────── +// Storage: namespace = 'syncedAvailableModels', key = ':' +// Each connection stores its own model list. Reads union across all connections +// for a provider. Deleting a connection removes only its models. + +export interface SyncedAvailableModel { + id: string; + name: string; + source: "api-sync"; + supportedEndpoints?: string[]; + inputTokenLimit?: number; + outputTokenLimit?: number; + description?: string; + supportsThinking?: boolean; +} + +/** + * Get all synced available models for a provider, unioned across all connections. + */ +export async function getSyncedAvailableModels(providerId: string): Promise { + const db = getDbInstance(); + const rows = db + .prepare("SELECT key, value FROM key_value WHERE namespace = 'syncedAvailableModels' AND key LIKE ?") + .all(`${providerId}:%`); + const map = new Map(); + for (const row of rows) { + const { key, value } = getKeyValue(row); + if (!key || value === null) continue; + const models: SyncedAvailableModel[] = JSON.parse(value); + for (const m of models) { + if (m.id) map.set(m.id, m); + } + } + return Array.from(map.values()); +} + +/** + * Get all synced available models across all providers. + */ +export async function getAllSyncedAvailableModels(): Promise> { + const db = getDbInstance(); + const rows = db + .prepare("SELECT key, value FROM key_value WHERE namespace = 'syncedAvailableModels'") + .all(); + // Group by providerId (before the colon) + const byProvider = new Map>(); + for (const row of rows) { + const { key, value } = getKeyValue(row); + if (!key || value === null) continue; + const providerId = key.split(":")[0]; + if (!byProvider.has(providerId)) byProvider.set(providerId, new Map()); + const models: SyncedAvailableModel[] = JSON.parse(value); + const map = byProvider.get(providerId)!; + for (const m of models) { + if (m.id) map.set(m.id, m); + } + } + const result: Record = {}; + for (const [providerId, map] of byProvider) { + result[providerId] = Array.from(map.values()); + } + return result; +} + +/** + * Replace the model list for a specific connection. + * Key format: ':' + */ +export async function replaceSyncedAvailableModelsForConnection( + providerId: string, + connectionId: string, + models: SyncedAvailableModel[] +): Promise { + const db = getDbInstance(); + const key = `${providerId}:${connectionId}`; + if (models.length === 0) { + db.prepare("DELETE FROM key_value WHERE namespace = 'syncedAvailableModels' AND key = ?").run(key); + } else { + db.prepare( + "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('syncedAvailableModels', ?, ?)" + ).run(key, JSON.stringify(models)); + } + backupDbFile("pre-write"); + // Return the full unioned list for the provider + return getSyncedAvailableModels(providerId); +} + +/** + * Delete all synced models for a specific connection. + * Returns the remaining unioned list for the provider. + */ +export async function deleteSyncedAvailableModelsForConnection( + providerId: string, + connectionId: string +): Promise { + const db = getDbInstance(); + const key = `${providerId}:${connectionId}`; + db.prepare("DELETE FROM key_value WHERE namespace = 'syncedAvailableModels' AND key = ?").run(key); + backupDbFile("pre-write"); + return getSyncedAvailableModels(providerId); +} + export async function updateCustomModel( providerId: string, modelId: string, diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index 31301a67e7..b9aa15401c 100644 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -58,9 +58,15 @@ export { getModelPreserveOpenAIDeveloperRole, getModelUpstreamExtraHeaders, getModelIsHidden, + + // Synced Available Models + getSyncedAvailableModels, + getAllSyncedAvailableModels, + replaceSyncedAvailableModelsForConnection, + deleteSyncedAvailableModelsForConnection, } from "./db/models"; -export type { ModelCompatPerProtocol, ModelCompatPatch } from "./db/models"; +export type { ModelCompatPerProtocol, ModelCompatPatch, SyncedAvailableModel } from "./db/models"; export { // Combos diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index ca57661076..efc81736ca 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -567,7 +567,9 @@ async function handleSingleModelChat( } // 6. Mark account as quota-exhausted on 429 response - if (result.status === 429) { + // For per-model quota providers (Gemini), a 429 on one model doesn't mean + // the entire account is exhausted — skip connection-wide exhaustion marking. + if (result.status === 429 && provider !== "gemini") { markAccountExhaustedFrom429(credentials.connectionId, provider); } diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index e465a7fd49..76e5ee1aa9 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -14,6 +14,7 @@ import { checkFallbackError, isModelLocked, lockModel, + hasPerModelQuota, } from "@omniroute/open-sse/services/accountFallback.ts"; import { isLocalProvider, getPassthroughProviders } from "@omniroute/open-sse/config/providerRegistry.ts"; import { COOLDOWN_MS } from "@omniroute/open-sse/config/constants.ts"; @@ -408,6 +409,8 @@ export async function getProviderCredentials( if (isAccountUnavailable(c.rateLimitedUntil)) return false; if (isTerminalConnectionStatus(c)) return false; if (provider === "codex" && isCodexScopeUnavailable(c, requestedModel)) return false; + // Per-model lockout: if this specific model is locked on this connection, skip it + if (requestedModel && isModelLocked(provider, c.id, requestedModel)) return false; } return true; }); @@ -724,6 +727,29 @@ export async function markAccountUnavailable( try { await currentMutex; + // ── Per-model lockout for providers with independent model quotas ── + // Providers like Gemini AI Studio have per-model quotas. A 429/404 on one + // model must NOT lock out other models on the same API key. + if (hasPerModelQuota(provider) && model && (status === 429 || status === 404)) { + const reason = status === 404 ? "not_found" : "rate_limited"; + const cooldown = status === 404 + ? COOLDOWN_MS.notFoundLocal + : COOLDOWN_MS.rateLimit; + lockModel(provider, connectionId, model, reason, cooldown); + // Update last error for observability (without changing terminal status) + updateProviderConnection(connectionId, { + lastErrorType: reason, + lastError: `Model ${model} ${reason}`, + lastErrorAt: new Date().toISOString(), + errorCode: status, + }).catch(() => {}); + log.info( + "AUTH", + `Model-only lockout for ${provider}:${model} — ${status} ${reason} ${Math.ceil(cooldown / 1000)}s (connection stays active)` + ); + return { shouldFallback: true, cooldownMs: cooldown }; + } + // Read current connection to get backoffLevel const connectionsRaw = await getProviderConnections({ provider }); const connections = (Array.isArray(connectionsRaw) ? connectionsRaw : []) @@ -793,7 +819,8 @@ export async function markAccountUnavailable( | undefined; const isPassthroughProvider = provider && getPassthroughProviders().has(provider); - if ((isLocalProvider(connBaseUrl) || isPassthroughProvider) && status === 404 && provider && model) { + const isPerModelQuotaProvider = hasPerModelQuota(provider); + if ((isLocalProvider(connBaseUrl) || isPerModelQuotaProvider) && status === 404 && provider && model) { const localCooldown = COOLDOWN_MS.notFoundLocal; lockModel(provider, connectionId, model, "not_found", localCooldown); log.info( @@ -803,12 +830,12 @@ 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) { + // ── 429 model-only lockout for per-model quota providers ── + // For providers where each model has independent quota (passthrough providers, + // Gemini AI Studio), 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. + if (isPerModelQuotaProvider && status === 429 && provider && model) { const modelCooldown = cooldownMs || COOLDOWN_MS.rateLimit; lockModel(provider, connectionId, model, reason || "rate_limited", modelCooldown); log.info( diff --git a/tests/unit/t28-model-catalog-updates.test.mjs b/tests/unit/t28-model-catalog-updates.test.mjs index a1d3440bdb..4b0c796e55 100644 --- a/tests/unit/t28-model-catalog-updates.test.mjs +++ b/tests/unit/t28-model-catalog-updates.test.mjs @@ -5,12 +5,14 @@ import { getModelInfoCore } from "../../open-sse/services/model.ts"; import { REGISTRY } from "../../open-sse/config/providerRegistry.ts"; import { getStaticModelsForProvider } from "../../src/app/api/providers/[id]/models/route.ts"; -test("T28: gemini catalog includes preview models from 9router", () => { +test("T28: gemini-cli catalog includes preview models, gemini uses API sync", () => { + // Gemini (AI Studio) no longer has a hardcoded registry — models come from + // API sync via /api/providers/:id/models with pageSize=1000. const geminiIds = REGISTRY.gemini.models.map((m) => m.id); - const geminiCliIds = REGISTRY["gemini-cli"].models.map((m) => m.id); + assert.equal(geminiIds.length, 0, "gemini models should be empty (populated by API sync)"); - assert.ok(geminiIds.includes("gemini-3.1-flash-lite-preview")); - assert.ok(geminiIds.includes("gemini-3-flash-preview")); + // gemini-cli still has hardcoded models (Cloud Code doesn't have a models API) + const geminiCliIds = REGISTRY["gemini-cli"].models.map((m) => m.id); assert.ok(geminiCliIds.includes("gemini-3.1-flash-lite-preview")); assert.ok(geminiCliIds.includes("gemini-3-flash-preview")); }); diff --git a/tests/unit/t31-t33-t34-t38-model-specs.test.mjs b/tests/unit/t31-t33-t34-t38-model-specs.test.mjs index 23c5a50680..9a067c50c0 100644 --- a/tests/unit/t31-t33-t34-t38-model-specs.test.mjs +++ b/tests/unit/t31-t33-t34-t38-model-specs.test.mjs @@ -2,6 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; const { REGISTRY } = await import("../../open-sse/config/providerRegistry.ts"); +const { getStaticModelsForProvider } = await import("../../src/app/api/providers/[id]/models/route.ts"); const { resolveModelAlias: resolveDeprecatedAlias } = await import("../../open-sse/services/modelDeprecation.ts"); const { normalizeThinkingLevel } = await import("../../open-sse/services/thinkingBudget.ts"); @@ -14,10 +15,12 @@ const { capThinkingBudget, } = await import("../../src/shared/constants/modelSpecs.ts"); -test("T31: registry exposes Gemini 3.1 Pro High/Low model IDs", () => { - const geminiIds = REGISTRY.gemini.models.map((m) => m.id); - assert.ok(geminiIds.includes("gemini-3.1-pro-high")); - assert.ok(geminiIds.includes("gemini-3.1-pro-low")); +test("T31: antigravity static catalog exposes Gemini 3.1 Pro High/Low model IDs", () => { + // gemini-3.1-pro-high/low are Antigravity (Cloud Code sandbox) models, + // not Gemini AI Studio models. They live in the static catalog, not the registry. + const staticIds = (getStaticModelsForProvider("antigravity") || []).map((m) => m.id); + assert.ok(staticIds.includes("gemini-3.1-pro-high")); + assert.ok(staticIds.includes("gemini-3.1-pro-low")); }); test("T31: legacy Gemini aliases resolve to Gemini 3.1 IDs", () => {