Merge pull request #933 from christopher-s/gemini-google-ai-studio-audit

fix(gemini): API field casing, safety finish reasons, pagination timeout
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-04-02 20:46:05 -03:00
committed by GitHub
26 changed files with 670 additions and 94 deletions

6
.gitignore vendored
View File

@@ -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/
.gemini/
# Superpowers plans/specs (internal tooling, not project code)
docs/superpowers/

View File

@@ -192,22 +192,9 @@ export const REGISTRY: Record<string, RegistryEntry> = {
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": {

View File

@@ -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,

View File

@@ -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}

View File

@@ -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})`
);

View File

@@ -84,7 +84,7 @@ export function convertOpenAIContentToParts(content) {
const mimeType = mimePart.split(";")[0];
parts.push({
inlineData: { mime_type: mimeType, data: data },
inlineData: { mimeType, data },
});
}
}

View File

@@ -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,
};
}

View File

@@ -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}`,
},
});
}

View File

@@ -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,
},
});

View File

@@ -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";
}

View File

@@ -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<string, unknown> = {
id: `chatcmpl-${state.messageId}`,

View File

@@ -840,6 +840,7 @@ export default function ProviderDetailPage() {
customModels: CompatModelRow[];
modelCompatOverrides: Array<CompatModelRow & { id: string }>;
}>({ customModels: [], modelCompatOverrides: [] });
const [syncedAvailableModels, setSyncedAvailableModels] = useState<any[]>([]);
const [compatSavingModelId, setCompatSavingModelId] = useState<string | null>(null);
const [applyingCodexAuthId, setApplyingCodexAuthId] = useState<string | null>(null);
const [exportingCodexAuthId, setExportingCodexAuthId] = useState<string | null>(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 : (
<div className="flex items-center gap-2 mb-4">
<Button
size="sm"
@@ -2469,7 +2556,7 @@ export default function ProviderDetailPage() {
{renderModelsSection()}
{/* Custom Models — available for providers without managed available-model metadata */}
{!isManagedAvailableModelsProvider && (
{!isManagedAvailableModelsProvider && providerId !== "gemini" && (
<CustomModelsSection
providerId={providerId}
providerAlias={providerDisplayAlias}
@@ -2758,11 +2845,16 @@ export default function ProviderDetailPage() {
</div>
)}
{/* Auto-reload notice */}
{importProgress.phase === "done" && importProgress.importedCount > 0 && (
<p className="text-xs text-text-muted text-center animate-pulse">
{t("pageAutoRefresh")}
</p>
{/* Close button */}
{importProgress.phase === "done" && (
<div className="flex justify-center">
<button
onClick={() => setShowImportModal(false)}
className="px-4 py-2 text-sm font-medium rounded-lg bg-primary text-white hover:opacity-90 transition-opacity"
>
{t("close") || "Close"}
</button>
</div>
)}
</div>
</Modal>

View File

@@ -130,16 +130,56 @@ const PROVIDER_MODELS_CONFIG: Record<string, ProviderModelsConfigEntry> = {
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<string, string> = {
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<string, unknown>) => {
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<string>();
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);

View File

@@ -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();

View File

@@ -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;

View File

@@ -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<string, any> = { ...newConnection };
delete result.apiKey;

View File

@@ -0,0 +1,33 @@
import { getSyncedAvailableModels, getAllSyncedAvailableModels } from "@/lib/db/models";
import { isAuthenticated } from "@/shared/utils/apiAuth";
/**
* GET /api/synced-available-models?provider=<id>
* 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 }
);
}
}

View File

@@ -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<string, unknown>;
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<string, unknown> =>
@@ -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 || {}),
});
}

View File

@@ -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<string, unknown>;
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<string, unknown>;
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 });
}

View File

@@ -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",

View File

@@ -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 = '<providerId>:<connectionId>'
// 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<SyncedAvailableModel[]> {
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<string, SyncedAvailableModel>();
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<Record<string, SyncedAvailableModel[]>> {
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<string, Map<string, SyncedAvailableModel>>();
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<string, SyncedAvailableModel[]> = {};
for (const [providerId, map] of byProvider) {
result[providerId] = Array.from(map.values());
}
return result;
}
/**
* Replace the model list for a specific connection.
* Key format: '<providerId>:<connectionId>'
*/
export async function replaceSyncedAvailableModelsForConnection(
providerId: string,
connectionId: string,
models: SyncedAvailableModel[]
): Promise<SyncedAvailableModel[]> {
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<SyncedAvailableModel[]> {
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,

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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(

View File

@@ -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"));
});

View File

@@ -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", () => {