mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 23:02:10 +03:00
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:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -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/
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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})`
|
||||
);
|
||||
|
||||
@@ -84,7 +84,7 @@ export function convertOpenAIContentToParts(content) {
|
||||
const mimeType = mimePart.split(";")[0];
|
||||
|
||||
parts.push({
|
||||
inlineData: { mime_type: mimeType, data: data },
|
||||
inlineData: { mimeType, data },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
33
src/app/api/synced-available-models/route.ts
Normal file
33
src/app/api/synced-available-models/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 || {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user