Merge pull request #938 from diegosouzapw/release/v3.4.7

chore(release): v3.4.7 — OmniRoute Stability Release
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-04-02 22:50:20 -03:00
committed by GitHub
51 changed files with 6486 additions and 157 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

@@ -4,6 +4,30 @@
---
## [3.4.7] — 2026-04-03
### Features
- Added `Cryptography` node to Monitoring and MCP health checks (#798)
- Hardened model-catalog route permissions mapping (`/models`) (#781)
### Bug Fixes
- Fixed Claude OAuth token refreshes failing to preserve cache contexts (#937)
- Fixed CC-Compatible provider errors rendering cached models unreachable (#937)
- Fixed GitHub Executor errors related to invalid context arrays (#937)
- Fixed NPM-installed CLI tools healthcheck failures on Windows (#935)
- Fixed payload translation dropping valid content due to invalid API fields (#927)
- Fixed runtime crash in Node 25 regarding API key execution (#867)
- Fixed MCP standalone module-resolution (`ERR_MODULE_NOT_FOUND`) via `esbuild` (#936)
- Fixed NVIDIA NIM routing credential resolution alias mismatch (#931)
### Security
- Added safe strict input boundary protection against raw `shell: true` remote-code execution injections.
---
## [3.4.6] - 2026-04-02
### ✨ New Features

View File

@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: OmniRoute API
version: 3.4.6
version: 3.4.7
description: |
OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible
endpoint that routes requests to multiple AI providers with load balancing,

View File

@@ -1,6 +1,6 @@
{
"name": "omniroute-desktop",
"version": "3.4.6",
"version": "3.4.7",
"description": "OmniRoute Desktop Application",
"main": "main.js",
"author": {

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

@@ -76,7 +76,12 @@ export class GithubExecutor extends BaseExecutor {
if (!result || !result.response?.body) return result;
const isStreaming = input.stream === true;
if (isStreaming) {
const contentType = (result.response.headers.get("content-type") || "").toLowerCase();
if (isStreaming && result.response.ok && contentType.includes("text/event-stream")) {
// Preserve the original response body for downstream error handling.
const sourceResponse = result.response.clone();
if (!sourceResponse.body) return result;
const decoder = new TextDecoder();
const transformStream = new TransformStream({
transform(chunk, controller) {
@@ -88,10 +93,10 @@ export class GithubExecutor extends BaseExecutor {
},
});
const newResponse = new Response(result.response.body.pipeThrough(transformStream), {
status: result.response.status,
statusText: result.response.statusText,
headers: result.response.headers, // Headers class carries over correctly
const newResponse = new Response(sourceResponse.body.pipeThrough(transformStream), {
status: sourceResponse.status,
statusText: sourceResponse.statusText,
headers: new Headers(sourceResponse.headers),
});
result.response = newResponse;
}

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,
@@ -794,11 +796,13 @@ export async function handleChatCore({
translatedBody = buildClaudeCodeCompatibleRequest({
sourceBody: body,
normalizedBody: normalizedForCc,
claudeBody: sourceFormat === FORMATS.CLAUDE ? body : null,
model,
stream: upstreamStream,
sessionId: ccSessionId,
cwd: process.cwd(),
now: new Date(),
preserveCacheControl,
});
log?.debug?.("FORMAT", "claude-code-compatible bridge enabled");
} else if (isClaudePassthrough && preserveCacheControl) {
@@ -1375,16 +1379,20 @@ 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 +1410,28 @@ 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

@@ -69,6 +69,12 @@ export const getHealthOutput = z.object({
hitRate: z.number(),
})
.optional(),
cryptography: z
.object({
status: z.enum(["healthy", "missing_or_invalid"]),
provider: z.string(),
})
.optional(),
});
export const getHealthTool: McpToolDefinition<typeof getHealthInput, typeof getHealthOutput> = {

View File

@@ -212,6 +212,12 @@ async function handleGetHealth() {
hitRate: toNumber(cacheStatsRaw.hitRate, 0),
}
: undefined,
cryptography: health.cryptography
? {
status: toString(toRecord(health.cryptography).status, "missing_or_invalid"),
provider: toString(toRecord(health.cryptography).provider, "unknown"),
}
: undefined,
};
await logToolCall("omniroute_get_health", {}, result, Date.now() - start, true);

View File

@@ -1,6 +1,6 @@
{
"name": "@omniroute/open-sse",
"version": "3.4.6",
"version": "3.4.7",
"description": "Express SSE sidecar for OmniRoute — handles streaming, protocol translation, and provider orchestration",
"type": "module",
"main": "index.js",

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

@@ -16,6 +16,7 @@ export interface RoutingContext {
requestHasTools?: boolean;
requestHasVision?: boolean;
estimatedInputTokens?: number;
lastKnownGoodProvider?: string;
}
export interface RoutingDecision {
@@ -116,6 +117,34 @@ class LatencyStrategyImpl implements RouterStrategy {
}
}
// ── LKGPStrategy: tries last known good provider first ───────────────────────
class LKGPStrategyImpl implements RouterStrategy {
readonly name = "lkgp";
readonly description = "Tries last known good provider first, then falls back to rules";
select(pool: ProviderCandidate[], context: RoutingContext): RoutingDecision {
if (context.lastKnownGoodProvider) {
const best = pool.find(
(c) => c.provider === context.lastKnownGoodProvider && c.circuitBreakerState !== "OPEN"
);
if (best) {
return {
provider: best.provider,
model: best.model,
strategy: this.name,
reason: `LKGP: using last known good provider ${best.provider}`,
candidatesConsidered: 1,
finalScore: 1.0,
};
}
}
// Fallback to rules strategy
return getStrategy("rules").select(pool, context);
}
}
// ── Registry ──────────────────────────────────────────────────────────────────
const strategyRegistry = new Map<string, RouterStrategy>();
@@ -123,12 +152,14 @@ const strategyRegistry = new Map<string, RouterStrategy>();
const rulesStrategy = new RulesStrategyImpl();
const costStrategy = new CostStrategyImpl();
const latencyStrategy = new LatencyStrategyImpl();
const lkgpStrategy = new LKGPStrategyImpl();
strategyRegistry.set("rules", rulesStrategy);
strategyRegistry.set("cost", costStrategy);
strategyRegistry.set("eco", costStrategy); // alias
strategyRegistry.set("latency", latencyStrategy);
strategyRegistry.set("fast", latencyStrategy); // alias
strategyRegistry.set("lkgp", lkgpStrategy);
export function getStrategy(name: string): RouterStrategy {
const strategy = strategyRegistry.get(name);

View File

@@ -1,5 +1,7 @@
import { createHash, randomUUID } from "node:crypto";
import { prepareClaudeRequest } from "../translator/helpers/claudeHelper.ts";
export const CLAUDE_CODE_COMPATIBLE_PREFIX = "anthropic-compatible-cc-";
export const CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH = "/v1/messages?beta=true";
export const CLAUDE_CODE_COMPATIBLE_DEFAULT_MODELS_PATH = "/models";
@@ -26,11 +28,13 @@ type MessageLike = {
type BuildRequestOptions = {
sourceBody?: Record<string, unknown> | null;
normalizedBody?: Record<string, unknown> | null;
claudeBody?: Record<string, unknown> | null;
model: string;
stream?: boolean;
cwd?: string;
now?: Date;
sessionId?: string | null;
preserveCacheControl?: boolean;
};
export function isClaudeCodeCompatibleProvider(provider: string | null | undefined): boolean {
@@ -137,25 +141,38 @@ export function resolveClaudeCodeCompatibleSessionId(headers?: HeaderLike): stri
export function buildClaudeCodeCompatibleRequest({
sourceBody,
normalizedBody,
claudeBody,
model,
stream = false,
cwd = process.cwd(),
now = new Date(),
sessionId,
preserveCacheControl = false,
}: BuildRequestOptions) {
const normalized = normalizedBody || {};
const messages = Array.isArray(normalized.messages)
? buildClaudeCodeCompatibleMessages(normalized.messages as MessageLike[])
: [];
const system = buildClaudeCodeCompatibleSystemBlocks(
normalized.messages as MessageLike[],
const preparedClaudeBody = claudeBody
? prepareClaudeCodeCompatibleBody(claudeBody, preserveCacheControl)
: null;
const messages = preparedClaudeBody
? buildClaudeCodeCompatibleMessagesFromClaude(
preparedClaudeBody.messages as MessageLike[],
preserveCacheControl
)
: Array.isArray(normalized.messages)
? buildClaudeCodeCompatibleMessages(normalized.messages as MessageLike[])
: [];
const system = buildClaudeCodeCompatibleSystemBlocks({
messages: normalized.messages as MessageLike[],
systemBlocks: preparedClaudeBody?.system as Record<string, unknown>[] | undefined,
cwd,
now
);
now,
});
const resolvedSessionId = sessionId || randomUUID();
const effort = resolveClaudeCodeCompatibleEffort(sourceBody, normalizedBody, model);
const maxTokens = resolveClaudeCodeCompatibleMaxTokens(sourceBody, normalizedBody);
const tools = buildClaudeCodeCompatibleTools(normalizedBody, sourceBody);
const tools = preparedClaudeBody?.tools
? (preparedClaudeBody.tools as Record<string, unknown>[])
: buildClaudeCodeCompatibleTools(normalizedBody, sourceBody);
const toolChoice =
tools.length > 0
? buildClaudeCodeCompatibleToolChoice(
@@ -295,32 +312,83 @@ function buildClaudeCodeCompatibleMessages(messages: MessageLike[]) {
}
}
for (let i = merged.length - 1; i >= 0; i--) {
if (merged[i].role !== "user") continue;
const lastBlock = merged[i].content[merged[i].content.length - 1];
if (lastBlock) {
lastBlock.cache_control = { type: "ephemeral" };
applyClaudeCodeCompatibleMessageCacheStrategy(merged);
return merged;
}
function buildClaudeCodeCompatibleMessagesFromClaude(
messages: MessageLike[] | undefined,
preserveCacheControl: boolean
) {
const converted = Array.isArray(messages)
? messages
.map((message) => convertClaudeCodeCompatibleClaudeMessage(message, preserveCacheControl))
.filter(
(
message
): message is { role: "user" | "assistant"; content: Array<Record<string, unknown>> } =>
!!message && message.content.length > 0
)
: [];
const merged: Array<{ role: "user" | "assistant"; content: Array<Record<string, unknown>> }> = [];
for (const message of converted) {
const last = merged[merged.length - 1];
if (last && last.role === message.role) {
last.content.push(...message.content);
continue;
}
merged.push({ role: message.role, content: [...message.content] });
}
while (merged.length > 0 && merged[merged.length - 1].role === "assistant") {
merged.pop();
}
if (!preserveCacheControl) {
for (const message of merged) {
stripCacheControlFromContentBlocks(message.content);
}
applyClaudeCodeCompatibleMessageCacheStrategy(merged);
}
if (merged.length === 0) {
const fallbackText = converted
.flatMap((message) => message.content)
.map((block) => contentToText(block))
.filter(Boolean)
.join("\n")
.trim();
if (fallbackText) {
return [
{
role: "user" as const,
content: [{ type: "text", text: fallbackText, cache_control: { type: "ephemeral" } }],
},
];
}
break;
}
return merged;
}
function buildClaudeCodeCompatibleSystemBlocks(
messages: MessageLike[] | undefined,
cwd: string,
now: Date
) {
const customSystemBlocks = Array.isArray(messages)
? messages
.filter((message) => {
const role = String(message?.role || "").toLowerCase();
return role === "system" || role === "developer";
})
.map((message) => contentToText(message?.content))
.filter(Boolean)
: [];
function buildClaudeCodeCompatibleSystemBlocks({
messages,
systemBlocks,
cwd,
now,
}: {
messages: MessageLike[] | undefined;
systemBlocks?: Array<Record<string, unknown>> | undefined;
cwd: string;
now: Date;
}) {
const customSystemBlocks =
Array.isArray(systemBlocks) && systemBlocks.length > 0
? systemBlocks
: extractCustomSystemBlocks(messages);
const dateText = formatDate(now);
const blocks: Array<Record<string, unknown>> = [
@@ -340,12 +408,8 @@ function buildClaudeCodeCompatibleSystemBlocks(
},
];
for (const systemText of customSystemBlocks) {
blocks.push({
type: "text",
text: systemText,
cache_control: { type: "ephemeral" },
});
for (const systemBlock of customSystemBlocks) {
blocks.push(systemBlock);
}
return blocks;
@@ -383,7 +447,14 @@ function buildClaudeCodeCompatibleTools(
return rawTools
.map((tool) => convertClaudeCodeCompatibleTool(tool))
.filter((tool): tool is Record<string, unknown> => !!tool);
.filter((tool): tool is Record<string, unknown> => !!tool)
.map((tool, index, tools) => {
if (index !== findLastCacheableToolIndex(tools)) return tool;
return {
...tool,
cache_control: { type: "ephemeral", ttl: "1h" },
};
});
}
function convertClaudeCodeCompatibleTool(tool: unknown) {
@@ -444,6 +515,190 @@ function buildClaudeCodeCompatibleToolChoice(choice: unknown) {
return null;
}
function prepareClaudeCodeCompatibleBody(
claudeBody: Record<string, unknown>,
preserveCacheControl: boolean
) {
const prepared = prepareClaudeRequest(
{
system: normalizeClaudeSystemInput(claudeBody.system),
messages: normalizeClaudeMessageInput(claudeBody.messages),
tools: normalizeClaudeToolInput(claudeBody.tools),
thinking: readRecord(claudeBody.thinking) || claudeBody.thinking,
},
CLAUDE_CODE_COMPATIBLE_PREFIX,
preserveCacheControl
);
return readRecord(prepared);
}
function normalizeClaudeSystemInput(system: unknown) {
if (typeof system === "string") {
const text = system.trim();
return text ? [{ type: "text", text }] : [];
}
if (!Array.isArray(system)) return [];
return system
.map((block) => normalizeClaudeContentBlock(block))
.filter((block): block is Record<string, unknown> => !!block);
}
function normalizeClaudeMessageInput(messages: unknown) {
if (!Array.isArray(messages)) return [];
return messages
.map((message) => {
const record = readRecord(message);
if (!record) return null;
return {
...record,
content: normalizeClaudeContentInput(record.content),
};
})
.filter((message): message is Record<string, unknown> => !!message);
}
function normalizeClaudeToolInput(tools: unknown) {
if (!Array.isArray(tools)) return [];
return tools
.map((tool) => readRecord(cloneValue(tool)))
.filter((tool): tool is Record<string, unknown> => !!tool);
}
function normalizeClaudeContentInput(content: unknown) {
const blocks = normalizeClaudeContentBlocks(content);
return blocks.length > 0 ? blocks : content;
}
function normalizeClaudeContentBlocks(content: unknown) {
if (typeof content === "string") {
const text = content.trim();
return text ? [{ type: "text", text }] : [];
}
if (!Array.isArray(content)) {
const block = normalizeClaudeContentBlock(content);
return block ? [block] : [];
}
return content
.map((block) => normalizeClaudeContentBlock(block))
.filter((block): block is Record<string, unknown> => !!block);
}
function normalizeClaudeContentBlock(block: unknown) {
const record = readRecord(cloneValue(block));
if (!record) return null;
if (
record.type === "text" ||
(typeof record.type !== "string" && typeof record.text === "string")
) {
const text = toNonEmptyString(record.text);
if (!text) return null;
return {
...record,
type: "text",
text,
};
}
return record;
}
function convertClaudeCodeCompatibleClaudeMessage(
message: MessageLike | null | undefined,
preserveCacheControl: boolean
) {
const rawRole = String(message?.role || "").toLowerCase();
const role = rawRole === "user" ? "user" : rawRole === "assistant" ? "assistant" : null;
if (!role) return null;
const content = normalizeClaudeContentBlocks(message?.content).map((block) => {
if (preserveCacheControl) return block;
const { cache_control, ...rest } = block;
return rest;
});
if (content.length === 0) return null;
return {
role,
content,
};
}
function extractCustomSystemBlocks(messages: MessageLike[] | undefined) {
if (!Array.isArray(messages)) return [];
return messages
.filter((message) => {
const role = String(message?.role || "").toLowerCase();
return role === "system" || role === "developer";
})
.map((message) => contentToText(message?.content))
.filter(Boolean)
.map((text) => ({
type: "text",
text,
cache_control: { type: "ephemeral" },
}));
}
function applyClaudeCodeCompatibleMessageCacheStrategy(
messages: Array<{ role: "user" | "assistant"; content: Array<Record<string, unknown>> }>
) {
const userIndexes = messages.reduce((indexes, message, index) => {
if (message.role === "user") indexes.push(index);
return indexes;
}, [] as number[]);
const hasAssistant = messages.some((message) => message.role === "assistant");
const secondToLastUserIndex = userIndexes.length >= 2 ? userIndexes[userIndexes.length - 2] : -1;
if (secondToLastUserIndex >= 0) {
markLastContentCacheControl(messages[secondToLastUserIndex].content);
} else if (!hasAssistant && userIndexes.length > 0) {
markLastContentCacheControl(messages[userIndexes[userIndexes.length - 1]].content);
}
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role !== "assistant") continue;
if (markLastContentCacheControl(messages[i].content)) break;
}
}
function stripCacheControlFromContentBlocks(content: Array<Record<string, unknown>>) {
for (const block of content) {
delete block.cache_control;
}
}
function markLastContentCacheControl(content: Array<Record<string, unknown>>, ttl?: string) {
if (!Array.isArray(content) || content.length === 0) return false;
const lastBlock = content[content.length - 1];
if (!lastBlock) return false;
lastBlock.cache_control = ttl ? { type: "ephemeral", ttl } : { type: "ephemeral" };
return true;
}
function findLastCacheableToolIndex(tools: Array<Record<string, unknown>>) {
for (let i = tools.length - 1; i >= 0; i--) {
if (!tools[i].defer_loading) {
return i;
}
}
return -1;
}
function cloneValue<T>(value: T): T {
if (typeof structuredClone === "function") {
return structuredClone(value);
}
return JSON.parse(JSON.stringify(value)) as T;
}
function contentToText(content: unknown): string {
if (typeof content === "string") {
return content.trim();

View File

@@ -99,7 +99,10 @@ async function validateResponseQuality(
if (json?.output || json?.result || json?.data || json?.response) return { valid: true };
if (json?.error) {
const err = json.error as Record<string, unknown>;
return { valid: false, reason: `upstream error in 200 body: ${err?.message || JSON.stringify(json.error).substring(0, 200)}` };
return {
valid: false,
reason: `upstream error in 200 body: ${err?.message || JSON.stringify(json.error).substring(0, 200)}`,
};
}
return { valid: true };
}
@@ -809,6 +812,16 @@ export async function handleComboChat({
const modePack =
typeof autoConfigSource.modePack === "string" ? autoConfigSource.modePack : undefined;
// Retrieve last known good provider (LKGP) for this combo/model (#919)
let lastKnownGoodProvider: string | undefined;
try {
const { getLKGP } = await import("../../src/lib/localDb");
const lkgp = await getLKGP(combo.name, combo.id || combo.name);
if (lkgp) lastKnownGoodProvider = lkgp;
} catch {
/* ignore db errors */
}
const candidates = await buildAutoCandidates(eligibleModels, combo.name);
if (candidates.length > 0) {
let selectedProvider = null;
@@ -819,7 +832,7 @@ export async function handleComboChat({
try {
const decision = selectWithStrategy(
candidates,
{ taskType, requestHasTools },
{ taskType, requestHasTools, lastKnownGoodProvider },
routingStrategy
);
selectedProvider = decision.provider;
@@ -980,6 +993,14 @@ export async function handleComboChat({
fallbackCount,
strategy,
});
// Record last known good provider (LKGP) for this combo/model (#919)
if (provider) {
import("../../src/lib/localDb")
.then(({ setLKGP }) => setLKGP(combo.name, combo.id || combo.name, provider))
.catch(() => {});
}
return result;
}

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;
@@ -156,7 +156,6 @@ function openaiToGeminiBase(model, body, stream) {
});
parts.push({
thoughtSignature: DEFAULT_THINKING_GEMINI_SIGNATURE,
text: "",
});
}
@@ -175,7 +174,6 @@ function openaiToGeminiBase(model, body, stream) {
if (!hasSignatureAlready) {
parts.push({
thoughtSignature: DEFAULT_THINKING_GEMINI_SIGNATURE,
text: "",
});
}
@@ -317,7 +315,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 +323,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 +444,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}`,

6
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "omniroute",
"version": "3.4.6",
"version": "3.4.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "omniroute",
"version": "3.4.6",
"version": "3.4.7",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
@@ -21047,7 +21047,7 @@
},
"open-sse": {
"name": "@omniroute/open-sse",
"version": "3.4.6"
"version": "3.4.7"
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "omniroute",
"version": "3.4.6",
"version": "3.4.7",
"description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.",
"type": "module",
"bin": {

View File

@@ -254,6 +254,25 @@ if (existsSync(mitmSrc)) {
}
}
// ── Step 8.5: Bundle MCP server ────────────────────────────
const mcpSrcFile = join(ROOT, "open-sse", "mcp-server", "server.ts");
const mcpDestDir = join(APP_DIR, "open-sse", "mcp-server");
const mcpDestFile = join(mcpDestDir, "server.js");
if (existsSync(mcpSrcFile)) {
console.log(" 🔨 Bundling MCP Server (TypeScript → JavaScript)...");
mkdirSync(mcpDestDir, { recursive: true });
try {
execSync(
`npx esbuild ${JSON.stringify(mcpSrcFile)} --bundle --platform=node --packages=external --format=esm --outfile=${JSON.stringify(mcpDestFile)}`,
{ cwd: ROOT, stdio: "inherit" }
);
console.log(" ✅ MCP Server bundled to app/open-sse/mcp-server/server.js");
} catch (err) {
console.warn(" ⚠️ MCP Server bundle error:", err.message);
}
}
// ── Step 9: Copy shared utilities needed at runtime ────────
const sharedApiKey = join(ROOT, "src", "shared", "utils", "apiKey.js");
const sharedApiKeyDest = join(APP_DIR, "src", "shared", "utils");

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

@@ -67,6 +67,13 @@ export async function GET() {
dedup: {
inflightRequests: getInflightCount(),
},
cryptography: {
status:
process.env.STORAGE_ENCRYPTION_KEY && process.env.STORAGE_ENCRYPTION_KEY.length >= 32
? "healthy"
: "missing_or_invalid",
provider: "aes-256-gcm",
},
setupComplete: settings?.setupComplete || false,
});
} catch (error) {

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,8 @@ 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";
import { getCompatibleFallbackModels } from "@/lib/providers/managedAvailableModels";
const FALLBACK_ALIAS_TO_PROVIDER = {
ag: "antigravity",
@@ -179,7 +181,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 +298,61 @@ 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 +451,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 +513,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 +538,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 || {}),
});
}
@@ -485,6 +550,47 @@ export async function getUnifiedModelsResponse(
console.log("Could not fetch custom models");
}
// Add managed fallback models for compatible providers that don't import a model list.
for (const conn of connections) {
const providerId = typeof conn.provider === "string" ? conn.provider : null;
if (!providerId) continue;
if (blockedProviders.has(providerId)) continue;
const fallbackModels = getCompatibleFallbackModels(providerId);
if (!Array.isArray(fallbackModels) || fallbackModels.length === 0) continue;
const prefix = providerIdToPrefix[providerId];
const alias = prefix || providerIdToAlias[providerId] || providerId;
for (const model of fallbackModels) {
const modelId = typeof model.id === "string" ? model.id : null;
if (!modelId) continue;
if (getModelIsHidden(providerId, modelId)) continue;
const aliasId = `${alias}/${modelId}`;
if (models.some((m) => m.id === aliasId)) continue;
const visionFields =
getVisionCapabilityFields(aliasId) || getVisionCapabilityFields(modelId);
const contextLength =
typeof (model as any).contextLength === "number"
? (model as any).contextLength
: undefined;
models.push({
id: aliasId,
object: "model",
created: timestamp,
owned_by: providerId,
permission: [],
root: modelId,
parent: null,
...(contextLength ? { context_length: contextLength } : {}),
...(visionFields || {}),
});
}
}
// Filter by API key permissions if requested
const authHeader = request.headers.get("authorization");
let finalModels = models;

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

@@ -248,6 +248,31 @@ export async function resetAllPricing() {
return {};
}
// ──────────────── LKGP (Last Known Good Provider) ────────────────
export async function getLKGP(comboName: string, modelId: string): Promise<string | null> {
const db = getDbInstance();
const key = `${comboName}:${modelId}`;
const row = db
.prepare("SELECT value FROM key_value WHERE namespace = 'lkgp' AND key = ?")
.get(key) as { value?: string } | undefined;
if (!row?.value) return null;
try {
return JSON.parse(row.value);
} catch {
return row.value;
}
}
export async function setLKGP(comboName: string, modelId: string, providerId: string) {
const db = getDbInstance();
const key = `${comboName}:${modelId}`;
db.prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('lkgp', ?, ?)").run(
key,
JSON.stringify(providerId)
);
}
// ──────────────── Proxy Config ────────────────
const DEFAULT_PROXY_CONFIG: ProxyConfig = { global: null, providers: {}, combos: {}, keys: {} };

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
@@ -92,6 +98,10 @@ export {
updateSettings,
isCloudEnabled,
// LKGP (Last Known Good Provider) (#919)
getLKGP,
setLKGP,
// Pricing
getPricing,
getPricingForModel,

View File

@@ -3,12 +3,12 @@ import { CLAUDE_CONFIG } from "../constants/oauth";
export const claude = {
config: CLAUDE_CONFIG,
flowType: "authorization_code_pkce",
buildAuthUrl: (config, _redirectUri, state, codeChallenge) => {
buildAuthUrl: (config, redirectUri, state, codeChallenge) => {
const params = new URLSearchParams({
code: "true",
client_id: config.clientId,
response_type: "code",
redirect_uri: config.redirectUri,
redirect_uri: redirectUri,
scope: config.scopes.join(" "),
code_challenge: codeChallenge,
code_challenge_method: config.codeChallengeMethod,
@@ -16,7 +16,7 @@ export const claude = {
});
return `${config.authorizeUrl}?${params.toString()}`;
},
exchangeToken: async (config, code, _redirectUri, codeVerifier, state) => {
exchangeToken: async (config, code, redirectUri, codeVerifier, state) => {
let authCode = code;
let codeState = "";
if (authCode.includes("#")) {
@@ -36,7 +36,7 @@ export const claude = {
state: codeState || state,
grant_type: "authorization_code",
client_id: config.clientId,
redirect_uri: config.redirectUri,
redirect_uri: redirectUri,
code_verifier: codeVerifier,
}),
});

View File

@@ -4,6 +4,7 @@ import { useState, useMemo, useEffect } from "react";
import PropTypes from "prop-types";
import Modal from "./Modal";
import { getModelsByProviderId, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models";
import { getCompatibleFallbackModels } from "@/lib/providers/managedAvailableModels";
import {
OAUTH_PROVIDERS,
FREE_PROVIDERS,
@@ -160,9 +161,24 @@ export default function ModelSelectModal({
value: `${nodePrefix}/${fullModel.replace(`${providerId}/`, "")}`,
}));
const fallbackEntries = (
getCompatibleFallbackModels(providerId, providerCustomModels) || []
)
.filter((fm) => !nodeModels.some((nm) => nm.id === fm.id))
.map((fm) => ({
id: fm.id,
name: fm.name || fm.id,
value: `${nodePrefix}/${fm.id}`,
isFallback: true,
}));
// Merge custom models for custom providers
const customEntries = providerCustomModels
.filter((cm) => !nodeModels.some((nm) => nm.id === cm.id))
.filter(
(cm) =>
!nodeModels.some((nm) => nm.id === cm.id) &&
!fallbackEntries.some((fm) => fm.id === cm.id)
)
.map((cm) => ({
id: cm.id,
name: cm.name || cm.id,
@@ -170,7 +186,7 @@ export default function ModelSelectModal({
isCustom: true,
}));
const allModels = [...nodeModels, ...customEntries];
const allModels = [...nodeModels, ...fallbackEntries, ...customEntries];
if (allModels.length > 0) {
groups[providerId] = {

View File

@@ -660,7 +660,13 @@ const checkRunnable = async (
const minimalEnv: Record<string, string | undefined> = {
PATH: env.PATH,
HOME: env.HOME || env.USERPROFILE,
USERPROFILE: env.USERPROFILE, // Windows needs this for os.homedir()
APPDATA: env.APPDATA, // Many npm CLI tools rely on APPDATA
LOCALAPPDATA: env.LOCALAPPDATA,
TEMP: env.TEMP,
TMP: env.TMP,
SystemRoot: env.SystemRoot, // Windows needs this
ComSpec: env.ComSpec, // Windows shell
PATHEXT: env.PATHEXT, // Windows cmd.exe needs this to resolve .cmd/.bat/.exe extensions
};

View File

@@ -6,7 +6,7 @@ if (!process.env.API_KEY_SECRET) {
}
function getApiKeySecret(): string {
const secret = process.env.API_KEY_SECRET;
const secret = process.env.API_KEY_SECRET || "omniroute-default-insecure-api-key-secret";
if (!secret || secret.trim() === "") {
throw new Error("API_KEY_SECRET is required for API key CRC operations");
}

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,8 +14,12 @@ import {
checkFallbackError,
isModelLocked,
lockModel,
hasPerModelQuota,
} from "@omniroute/open-sse/services/accountFallback.ts";
import { isLocalProvider, getPassthroughProviders } from "@omniroute/open-sse/config/providerRegistry.ts";
import {
isLocalProvider,
getPassthroughProviders,
} from "@omniroute/open-sse/config/providerRegistry.ts";
import { COOLDOWN_MS } from "@omniroute/open-sse/config/constants.ts";
import { getCodexModelScope } from "@omniroute/open-sse/executors/codex.ts";
import * as log from "../utils/logger";
@@ -307,6 +311,16 @@ const markMutexes = new Map<string, Promise<void>>();
// Re-export for backwards compat with existing test imports.
export { fisherYatesShuffle, getNextFromDeckSync as getNextFromDeck };
/**
* Resolve provider aliases (e.g., nvidia -> nvidia_nim) for DB lookup
*/
function getProviderSearchPool(provider: string): string[] {
const pool = [provider];
if (provider === "nvidia") pool.push("nvidia_nim");
if (provider === "nvidia_nim") pool.push("nvidia");
return [...new Set(pool)];
}
/**
* Get provider credentials from localDb
* Filters out unavailable accounts and returns the selected account based on strategy
@@ -333,7 +347,14 @@ export async function getProviderCredentials(
const allowSuppressedConnections = options.allowSuppressedConnections === true;
const bypassQuotaPolicy = options.bypassQuotaPolicy === true;
const connectionsRaw = await getProviderConnections({ provider, isActive: true });
// Fix #922: Check for aliases (nvidia/nvidia_nim) to ensure credentials are found
const providersToSearch = getProviderSearchPool(provider);
let connectionsRaw: any[] = [];
for (const p of providersToSearch) {
const results = await getProviderConnections({ provider: p, isActive: true });
if (Array.isArray(results)) connectionsRaw.push(...results);
}
let connections = (Array.isArray(connectionsRaw) ? connectionsRaw : [])
.map(toProviderConnection)
.filter((conn) => conn.id.length > 0);
@@ -348,7 +369,12 @@ export async function getProviderCredentials(
if (connections.length === 0) {
// Check all connections (including inactive) to see if rate limited
const allConnectionsRaw = await getProviderConnections({ provider });
// Fix #922: Also search aliases here
let allConnectionsRaw: any[] = [];
for (const p of providersToSearch) {
const results = await getProviderConnections({ provider: p });
if (Array.isArray(results)) allConnectionsRaw.push(...results);
}
const allConnections = (Array.isArray(allConnectionsRaw) ? allConnectionsRaw : [])
.map(toProviderConnection)
.filter((conn) => conn.id.length > 0);
@@ -408,6 +434,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 +752,27 @@ 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 +842,13 @@ 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 +858,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

@@ -0,0 +1,989 @@
import {
getProviderConnections,
validateApiKey,
updateProviderConnection,
getSettings,
getCachedSettings,
} from "@/lib/localDb";
import { getQuotaWindowStatus, isAccountQuotaExhausted } from "@/domain/quotaCache";
import {
isAccountUnavailable,
getUnavailableUntil,
getEarliestRateLimitedUntil,
formatRetryAfter,
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";
import { getCodexModelScope } from "@omniroute/open-sse/executors/codex.ts";
import * as log from "../utils/logger";
import { fisherYatesShuffle, getNextFromDeckSync } from "@/shared/utils/shuffleDeck";
type JsonRecord = Record<string, unknown>;
interface ProviderConnectionView {
id: string;
isActive: boolean;
rateLimitedUntil: string | null;
testStatus: string | null;
apiKey: string | null;
accessToken: string | null;
refreshToken: string | null;
tokenExpiresAt: string | null;
expiresAt: string | null;
projectId: string | null;
providerSpecificData: JsonRecord;
lastUsedAt: string | null;
consecutiveUseCount: number;
priority: number;
lastError: string | null;
lastErrorType: string | null;
lastErrorSource: string | null;
errorCode: string | number | null;
backoffLevel: number;
}
interface RecoverableConnectionState {
connectionId: string;
testStatus?: string | null;
lastError?: string | null;
rateLimitedUntil?: string | null;
errorCode?: string | number | null;
lastErrorType?: string | null;
lastErrorSource?: string | null;
}
interface CredentialSelectionOptions {
allowSuppressedConnections?: boolean;
bypassQuotaPolicy?: boolean;
}
const CODEX_QUOTA_THRESHOLD_PERCENT = 90;
const MIN_QUOTA_THRESHOLD_PERCENT = 1;
const MAX_QUOTA_THRESHOLD_PERCENT = 100;
function asRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
}
function toStringOrNull(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value : null;
}
function toNumber(value: unknown, fallback = 0): number {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim().length > 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
return fallback;
}
function toProviderConnection(value: unknown): ProviderConnectionView {
const row = asRecord(value);
return {
id: toStringOrNull(row.id) || "",
isActive: row.isActive === true,
rateLimitedUntil: toStringOrNull(row.rateLimitedUntil),
testStatus: toStringOrNull(row.testStatus),
apiKey: toStringOrNull(row.apiKey),
accessToken: toStringOrNull(row.accessToken),
refreshToken: toStringOrNull(row.refreshToken),
tokenExpiresAt: toStringOrNull(row.tokenExpiresAt),
expiresAt: toStringOrNull(row.expiresAt),
projectId: toStringOrNull(row.projectId),
providerSpecificData: asRecord(row.providerSpecificData),
lastUsedAt: toStringOrNull(row.lastUsedAt),
consecutiveUseCount: toNumber(row.consecutiveUseCount, 0),
priority: toNumber(row.priority, 999),
lastError: toStringOrNull(row.lastError),
lastErrorType: toStringOrNull(row.lastErrorType),
lastErrorSource: toStringOrNull(row.lastErrorSource),
errorCode:
typeof row.errorCode === "string" || typeof row.errorCode === "number" ? row.errorCode : null,
backoffLevel: toNumber(row.backoffLevel, 0),
};
}
function toBooleanOrDefault(value: unknown, fallback: boolean): boolean {
return typeof value === "boolean" ? value : fallback;
}
function getCodexLimitPolicy(providerSpecificData: JsonRecord): {
use5h: boolean;
useWeekly: boolean;
} {
const policy = asRecord(providerSpecificData.codexLimitPolicy);
return {
use5h: toBooleanOrDefault(policy.use5h, true),
useWeekly: toBooleanOrDefault(policy.useWeekly, true),
};
}
interface QuotaLimitPolicy {
enabled: boolean;
thresholdPercent: number;
windows: string[];
}
function normalizeQuotaThreshold(value: unknown, fallback = CODEX_QUOTA_THRESHOLD_PERCENT): number {
const parsed = toNumber(value, fallback);
return Math.min(MAX_QUOTA_THRESHOLD_PERCENT, Math.max(MIN_QUOTA_THRESHOLD_PERCENT, parsed));
}
function normalizeWindowName(windowName: unknown): string | null {
if (typeof windowName !== "string") return null;
const normalized = windowName.trim().toLowerCase();
return normalized.length > 0 ? normalized : null;
}
function uniqueWindows(windows: string[]): string[] {
return [...new Set(windows)];
}
function normalizeCodexWindowName(windowName: unknown): string | null {
if (typeof windowName !== "string") return null;
const normalized = windowName.trim().toLowerCase();
if (normalized === "session (5h)" || normalized === "5h" || normalized === "five_hour") {
return "session";
}
if (normalized === "weekly (7d)" || normalized === "7d" || normalized === "seven_day") {
return "weekly";
}
return normalized;
}
function applyCodexWindowPolicy(rawWindows: string[], providerSpecificData: JsonRecord): string[] {
const codexPolicy = getCodexLimitPolicy(providerSpecificData);
const normalizedRaw = rawWindows.map(normalizeCodexWindowName).filter(Boolean) as string[];
// Preserve explicitly configured custom windows, but enforce canonical Codex windows
// from toggles so weekly exhaustion is never skipped when useWeekly=true.
let windows = [...normalizedRaw];
windows = windows.filter((windowName) => {
if (windowName === "session") return codexPolicy.use5h;
if (windowName === "weekly") return codexPolicy.useWeekly;
return true;
});
if (codexPolicy.use5h) windows.push("session");
if (codexPolicy.useWeekly) windows.push("weekly");
return uniqueWindows(windows);
}
function getCodexScopeRateLimitedUntil(
providerSpecificData: JsonRecord,
model: string | null
): string | null {
if (!model) return null;
const scope = getCodexModelScope(model);
const scopeMap = asRecord(providerSpecificData.codexScopeRateLimitedUntil);
const value = scopeMap[scope];
return typeof value === "string" && value.trim().length > 0 ? value : null;
}
function isCodexScopeUnavailable(
connection: ProviderConnectionView,
model: string | null
): boolean {
const until = getCodexScopeRateLimitedUntil(connection.providerSpecificData, model);
if (!until) return false;
return new Date(until).getTime() > Date.now();
}
function getEarliestCodexScopeRateLimitedUntil(
connections: ProviderConnectionView[],
model: string | null
): string | null {
let earliest: string | null = null;
let earliestMs = Infinity;
for (const conn of connections) {
const until = getCodexScopeRateLimitedUntil(conn.providerSpecificData, model);
if (!until) continue;
const ms = new Date(until).getTime();
if (!Number.isFinite(ms) || ms <= Date.now()) continue;
if (ms < earliestMs) {
earliest = until;
earliestMs = ms;
}
}
return earliest;
}
function normalizeStatus(value: string | null): string {
return (value || "").trim().toLowerCase();
}
function isTerminalConnectionStatus(connection: ProviderConnectionView): boolean {
const status = normalizeStatus(connection.testStatus);
return status === "credits_exhausted" || status === "banned" || status === "expired";
}
export function resolveQuotaLimitPolicy(
provider: string,
providerSpecificData: JsonRecord
): QuotaLimitPolicy {
const rawPolicy = asRecord(providerSpecificData.limitPolicy);
const rawWindows = Array.isArray(rawPolicy.windows) ? rawPolicy.windows : [];
const windows = rawWindows.map(normalizeWindowName).filter(Boolean) as string[];
if (provider === "codex") {
const defaultWindows = applyCodexWindowPolicy(windows, providerSpecificData);
const enabled = toBooleanOrDefault(rawPolicy.enabled, defaultWindows.length > 0);
return {
enabled,
thresholdPercent: normalizeQuotaThreshold(rawPolicy.thresholdPercent),
windows: defaultWindows,
};
}
return {
enabled: toBooleanOrDefault(rawPolicy.enabled, false),
thresholdPercent: normalizeQuotaThreshold(rawPolicy.thresholdPercent),
windows,
};
}
export function evaluateQuotaLimitPolicy(
provider: string,
connection: ProviderConnectionView
): { blocked: boolean; reasons: string[]; resetAt: string | null } {
const policy = resolveQuotaLimitPolicy(provider, connection.providerSpecificData);
if (!policy.enabled || policy.windows.length === 0) {
return { blocked: false, reasons: [], resetAt: null };
}
const reasons: string[] = [];
const resetCandidates: Array<string | null> = [];
for (const windowName of policy.windows) {
const status = getQuotaWindowStatus(connection.id, windowName, policy.thresholdPercent);
if (!status?.reachedThreshold) continue;
reasons.push(`${windowName} usage ${Math.round(status.usedPercentage)}%`);
resetCandidates.push(status.resetAt);
}
return {
blocked: reasons.length > 0,
reasons,
resetAt: getEarliestFutureDate(resetCandidates),
};
}
function parseFutureDateMs(value: string | null): number | null {
if (!value) return null;
const ms = new Date(value).getTime();
if (!Number.isFinite(ms) || ms <= Date.now()) return null;
return ms;
}
function getEarliestFutureDate(candidates: Array<string | null>): string | null {
return (
candidates
.map((candidate) => ({
raw: candidate,
ms: parseFutureDateMs(candidate),
}))
.filter((entry) => entry.ms !== null)
.sort((a, b) => (a.ms as number) - (b.ms as number))[0]?.raw || null
);
}
// Mutex to prevent race conditions during account selection
let selectionMutex = Promise.resolve();
// ─── Anti-Thundering Herd: per-connection mutex for markAccountUnavailable ───
// Prevents multiple concurrent requests from marking the same connection
// unavailable in parallel, which was the root cause of cascading 502 lockouts.
const markMutexes = new Map<string, Promise<void>>();
// Strict-Random shuffle deck moved to src/shared/utils/shuffleDeck.ts
// auth.ts uses getNextFromDeckSync (already inside selectionMutex).
// Re-export for backwards compat with existing test imports.
export { fisherYatesShuffle, getNextFromDeckSync as getNextFromDeck };
/**
* Get provider credentials from localDb
* Filters out unavailable accounts and returns the selected account based on strategy
* @param {string} provider - Provider name
* @param {string|null} excludeConnectionId - Connection ID to exclude (for retry with next account)
*/
export async function getProviderCredentials(
provider: string,
excludeConnectionId: string | null = null,
allowedConnections: string[] | null = null,
requestedModel: string | null = null,
options: CredentialSelectionOptions = {}
) {
// Acquire mutex to prevent race conditions
const currentMutex = selectionMutex;
let resolveMutex: (() => void) | undefined;
selectionMutex = new Promise((resolve) => {
resolveMutex = resolve;
});
try {
await currentMutex;
const allowSuppressedConnections = options.allowSuppressedConnections === true;
const bypassQuotaPolicy = options.bypassQuotaPolicy === true;
const connectionsRaw = await getProviderConnections({ provider, isActive: true });
let connections = (Array.isArray(connectionsRaw) ? connectionsRaw : [])
.map(toProviderConnection)
.filter((conn) => conn.id.length > 0);
// allowedConnections: restrict to specific connection IDs (from API key policy, #363)
if (allowedConnections && allowedConnections.length > 0) {
connections = connections.filter((conn) => allowedConnections.includes(conn.id));
}
log.debug(
"AUTH",
`${provider} | total connections: ${connections.length}, excludeId: ${excludeConnectionId || "none"}`
);
if (connections.length === 0) {
// Check all connections (including inactive) to see if rate limited
const allConnectionsRaw = await getProviderConnections({ provider });
const allConnections = (Array.isArray(allConnectionsRaw) ? allConnectionsRaw : [])
.map(toProviderConnection)
.filter((conn) => conn.id.length > 0);
log.debug("AUTH", `${provider} | all connections (incl inactive): ${allConnections.length}`);
if (allConnections.length > 0) {
const earliest = getEarliestRateLimitedUntil(allConnections);
if (earliest) {
log.warn(
"AUTH",
`${provider} | all ${allConnections.length} accounts rate limited (${formatRetryAfter(earliest)})`
);
return {
allRateLimited: true,
retryAfter: earliest,
retryAfterHuman: formatRetryAfter(earliest),
};
}
log.warn("AUTH", `${provider} | ${allConnections.length} accounts found but none active`);
allConnections.forEach((c) => {
log.debug(
"AUTH",
`${c.id?.slice(0, 8)} | isActive=${c.isActive} | rateLimitedUntil=${c.rateLimitedUntil || "none"} | testStatus=${c.testStatus}`
);
});
}
log.warn("AUTH", `No credentials for ${provider}`);
return null;
}
// Auto-decay backoffLevel for accounts whose rateLimitedUntil has passed.
// Without this, high backoffLevel permanently deprioritizes accounts even
// after the rate limit window expires, creating a deadlock where the account
// needs a successful request to reset but never gets selected.
for (const c of connections) {
if (
c.backoffLevel > 0 &&
!isTerminalConnectionStatus(c) &&
!isAccountUnavailable(c.rateLimitedUntil)
) {
c.backoffLevel = 0;
updateProviderConnection(c.id, {
backoffLevel: 0,
testStatus: "active",
lastError: null,
lastErrorAt: null,
lastErrorType: null,
lastErrorSource: null,
errorCode: null,
}).catch(() => {});
}
}
// Filter out unavailable accounts and excluded connection
const availableConnections = connections.filter((c) => {
if (excludeConnectionId && c.id === excludeConnectionId) return false;
if (!allowSuppressedConnections) {
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;
});
log.debug(
"AUTH",
`${provider} | available: ${availableConnections.length}/${connections.length}`
);
connections.forEach((c) => {
const excluded = excludeConnectionId && c.id === excludeConnectionId;
const rateLimited = isAccountUnavailable(c.rateLimitedUntil);
const terminalStatus = isTerminalConnectionStatus(c);
const codexScopeLimited = provider === "codex" && isCodexScopeUnavailable(c, requestedModel);
if (excluded || rateLimited) {
log.debug(
"AUTH",
`${c.id?.slice(0, 8)} | ${excluded ? "excluded" : ""} ${rateLimited ? `rateLimited until ${c.rateLimitedUntil}` : ""}${allowSuppressedConnections && rateLimited ? " (retained for combo live test)" : ""}`
);
} else if (terminalStatus) {
log.debug(
"AUTH",
allowSuppressedConnections
? `${c.id?.slice(0, 8)} | retained terminal status=${c.testStatus} for combo live test`
: `${c.id?.slice(0, 8)} | skipped terminal status=${c.testStatus}`
);
} else if (codexScopeLimited) {
const scopeUntil = getCodexScopeRateLimitedUntil(c.providerSpecificData, requestedModel);
log.debug(
"AUTH",
allowSuppressedConnections
? `${c.id?.slice(0, 8)} | retained codex scope-limited account until ${scopeUntil} for combo live test`
: `${c.id?.slice(0, 8)} | codex scope-limited until ${scopeUntil}`
);
}
});
if (availableConnections.length === 0) {
const earliest =
getEarliestRateLimitedUntil(connections) ||
(provider === "codex"
? getEarliestCodexScopeRateLimitedUntil(connections, requestedModel)
: null);
if (earliest) {
// Find the connection with the earliest rateLimitedUntil to get its error info
const rateLimitedConns = connections.filter(
(c) => c.rateLimitedUntil && new Date(c.rateLimitedUntil).getTime() > Date.now()
);
const earliestConn = rateLimitedConns.sort(
(a, b) =>
new Date(a.rateLimitedUntil || 0).getTime() -
new Date(b.rateLimitedUntil || 0).getTime()
)[0];
log.warn(
"AUTH",
`${provider} | all ${connections.length} active accounts rate limited (${formatRetryAfter(earliest)}) | lastErrorCode=${earliestConn?.errorCode}, lastError=${earliestConn?.lastError?.slice(0, 50)}`
);
return {
allRateLimited: true,
retryAfter: earliest,
retryAfterHuman: formatRetryAfter(earliest),
lastError: earliestConn?.lastError || null,
lastErrorCode: earliestConn?.errorCode || null,
};
}
log.warn("AUTH", `${provider} | all ${connections.length} accounts unavailable`);
return null;
}
let policyEligibleConnections = availableConnections;
const blockedByPolicy: Array<{
id: string;
reasons: string[];
resetAt: string | null;
}> = [];
if (!bypassQuotaPolicy) {
policyEligibleConnections = availableConnections.filter((connection) => {
const evaluation = evaluateQuotaLimitPolicy(provider, connection);
if (!evaluation.blocked) return true;
blockedByPolicy.push({
id: connection.id,
reasons: evaluation.reasons,
resetAt: evaluation.resetAt,
});
return false;
});
} else if (availableConnections.length > 0) {
log.debug("AUTH", `${provider} | bypassing quota policy for combo live test`);
}
if (blockedByPolicy.length > 0) {
log.info(
"AUTH",
`${provider} | quota policy filtered ${blockedByPolicy.length} account(s): ${blockedByPolicy
.map((entry) => `${entry.id.slice(0, 8)}(${entry.reasons.join(", ")})`)
.join("; ")}`
);
}
if (policyEligibleConnections.length === 0 && availableConnections.length > 0) {
const earliestResetAt = getEarliestFutureDate(blockedByPolicy.map((entry) => entry.resetAt));
const earliestResetMs = parseFutureDateMs(earliestResetAt);
const retryAfter = earliestResetMs
? new Date(earliestResetMs).toISOString()
: new Date(Date.now() + 5 * 60 * 1000).toISOString();
return {
allRateLimited: true,
retryAfter,
retryAfterHuman: formatRetryAfter(retryAfter),
lastError: `All ${provider} accounts reached configured quota threshold`,
lastErrorCode: 429,
};
}
// Quota-aware: prioritize accounts with available quota
const withQuota = policyEligibleConnections.filter((c) => !isAccountQuotaExhausted(c.id));
const exhaustedQuota = policyEligibleConnections.filter((c) => isAccountQuotaExhausted(c.id));
const orderedConnections =
withQuota.length > 0 ? [...withQuota, ...exhaustedQuota] : policyEligibleConnections;
if (exhaustedQuota.length > 0) {
log.debug(
"AUTH",
`${provider} | quota-aware: ${withQuota.length} with quota, ${exhaustedQuota.length} exhausted`
);
}
const settings = await getSettings();
const strategy = settings.fallbackStrategy || "fill-first";
let connection;
if (strategy === "round-robin") {
const stickyLimit = toNumber((settings as Record<string, unknown>).stickyRoundRobinLimit, 3);
// If excluding an account (fallback scenario), skip sticky logic and go straight to LRU
// This prevents the system from getting stuck on a failed account
const isFallbackScenario = excludeConnectionId !== null;
if (!isFallbackScenario) {
// Sort by lastUsed (most recent first) to find current candidate
const byRecency = [...orderedConnections].sort((a: any, b: any) => {
if (!a.lastUsedAt && !b.lastUsedAt) return (a.priority || 999) - (b.priority || 999);
if (!a.lastUsedAt) return 1;
if (!b.lastUsedAt) return -1;
return new Date(b.lastUsedAt).getTime() - new Date(a.lastUsedAt).getTime();
});
const current = byRecency[0];
const currentCount = current?.consecutiveUseCount || 0;
if (current && current.lastUsedAt && currentCount < stickyLimit) {
// Stay with current account
connection = current;
log.debug(
"AUTH",
`${provider} round-robin: staying with ${current.id?.slice(0, 8)}... (count=${currentCount}/${stickyLimit})`
);
// Update lastUsedAt and increment count (await to ensure persistence)
await updateProviderConnection(connection.id, {
lastUsedAt: new Date().toISOString(),
consecutiveUseCount: (connection.consecutiveUseCount || 0) + 1,
});
} else {
// Pick the least recently used (excluding current if possible)
// Also penalize accounts with high backoffLevel (previously rate-limited)
// so they don't get immediately re-selected after cooldown (#340)
const sortedByOldest = [...orderedConnections].sort((a: any, b: any) => {
// Penalize previously rate-limited accounts (backoffLevel > 0)
const aBackoff = a.backoffLevel || 0;
const bBackoff = b.backoffLevel || 0;
if (aBackoff !== bBackoff) return aBackoff - bBackoff; // lower backoff first
if (!a.lastUsedAt && !b.lastUsedAt) return (a.priority || 999) - (b.priority || 999);
if (!a.lastUsedAt) return -1;
if (!b.lastUsedAt) return 1;
return new Date(a.lastUsedAt).getTime() - new Date(b.lastUsedAt).getTime();
});
connection = sortedByOldest[0];
log.debug(
"AUTH",
`${provider} round-robin: switching to LRU ${connection.id?.slice(0, 8)}... (current count=${currentCount} >= limit=${stickyLimit} or no lastUsedAt)`
);
// Update lastUsedAt and reset count to 1 (await to ensure persistence)
await updateProviderConnection(connection.id, {
lastUsedAt: new Date().toISOString(),
consecutiveUseCount: 1,
});
}
} else {
// Fallback scenario: excluded an account due to failure
// Always pick the least recently used to ensure proper cycling
// Also penalize accounts with high backoffLevel (#340)
const sortedByOldest = [...orderedConnections].sort((a: any, b: any) => {
const aBackoff = a.backoffLevel || 0;
const bBackoff = b.backoffLevel || 0;
if (aBackoff !== bBackoff) return aBackoff - bBackoff;
if (!a.lastUsedAt && !b.lastUsedAt) return (a.priority || 999) - (b.priority || 999);
if (!a.lastUsedAt) return -1;
if (!b.lastUsedAt) return 1;
return new Date(a.lastUsedAt).getTime() - new Date(b.lastUsedAt).getTime();
});
connection = sortedByOldest[0];
log.info(
"AUTH",
`${provider} round-robin: FALLBACK MODE - excluded ${excludeConnectionId?.slice(0, 8)}..., picked LRU ${connection.id?.slice(0, 8)}...`
);
// Update lastUsedAt and reset count to 1 (await to ensure persistence)
await updateProviderConnection(connection.id, {
lastUsedAt: new Date().toISOString(),
consecutiveUseCount: 1,
});
}
} else if (strategy === "p2c") {
// Power of Two Choices: pick 2 random, choose the one with fewer failures
if (orderedConnections.length <= 2) {
connection = orderedConnections[0];
} else {
const i = Math.floor(Math.random() * orderedConnections.length);
let j = Math.floor(Math.random() * (orderedConnections.length - 1));
if (j >= i) j++;
const a = orderedConnections[i];
const b = orderedConnections[j];
// Prefer the one with fewer consecutive uses / better health
const scoreA = (a.consecutiveUseCount || 0) + (a.lastError ? 10 : 0);
const scoreB = (b.consecutiveUseCount || 0) + (b.lastError ? 10 : 0);
connection = scoreA <= scoreB ? a : b;
}
} else if (strategy === "random") {
// Random: Fisher-Yates-inspired random pick
const idx = Math.floor(Math.random() * orderedConnections.length);
connection = orderedConnections[idx];
} else if (strategy === "least-used") {
// Least Used: pick the one with oldest lastUsedAt
const sorted = [...orderedConnections].sort((a, b) => {
if (!a.lastUsedAt && !b.lastUsedAt) return (a.priority || 999) - (b.priority || 999);
if (!a.lastUsedAt) return -1;
if (!b.lastUsedAt) return 1;
return new Date(a.lastUsedAt).getTime() - new Date(b.lastUsedAt).getTime();
});
connection = sorted[0];
} else if (strategy === "cost-optimized") {
// Cost Optimized: sort by priority ascending (lower = cheaper/preferred)
// Future: can be enhanced with actual cost data per provider
const sorted = [...orderedConnections].sort(
(a, b) => (a.priority || 999) - (b.priority || 999)
);
connection = sorted[0];
} else if (strategy === "strict-random") {
// Strict Random: shuffle deck — uses each account once before reshuffling
const ids = orderedConnections.map((c) => c.id);
const selectedId = getNextFromDeckSync(`conn:${provider}`, ids);
connection = orderedConnections.find((c) => c.id === selectedId) || orderedConnections[0];
} else {
// Default: fill-first (already sorted by priority in getProviderConnections)
connection = orderedConnections[0];
}
return {
apiKey: connection.apiKey,
accessToken: connection.accessToken,
refreshToken: connection.refreshToken,
expiresAt: connection.tokenExpiresAt || connection.expiresAt || null,
projectId: connection.projectId,
copilotToken:
typeof connection.providerSpecificData.copilotToken === "string"
? connection.providerSpecificData.copilotToken
: null,
providerSpecificData: connection.providerSpecificData,
connectionId: connection.id,
// Include current status for optimization check
testStatus: connection.testStatus,
lastError: connection.lastError,
lastErrorType: connection.lastErrorType,
lastErrorSource: connection.lastErrorSource,
errorCode: connection.errorCode,
rateLimitedUntil: connection.rateLimitedUntil,
};
} finally {
if (resolveMutex) resolveMutex();
}
}
/**
* Mark account as unavailable — reads backoffLevel from DB, calculates cooldown with exponential backoff, saves new level
* @param {string} connectionId
* @param {number} status - HTTP status code
* @param {string} errorText - Error message
* @param {string|null} provider
* @param {string|null} model - Model name for per-model lockout
* @returns {{ shouldFallback: boolean, cooldownMs: number }}
*/
export async function markAccountUnavailable(
connectionId: string,
status: number,
errorText: string,
provider: string | null = null,
model: string | null = null
) {
const currentMutex = markMutexes.get(connectionId) || Promise.resolve();
let resolveMutex: (() => void) | undefined;
markMutexes.set(
connectionId,
new Promise((resolve) => {
resolveMutex = resolve;
})
);
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 : [])
.map(toProviderConnection)
.filter((connection) => connection.id.length > 0);
const conn = connections.find((connection) => connection.id === connectionId);
const backoffLevel = conn?.backoffLevel || 0;
// T06/T10/T36: terminal statuses should not be overwritten by transient cooldown state.
if (conn && isTerminalConnectionStatus(conn)) {
log.info(
"AUTH",
`${connectionId.slice(0, 8)} terminal status=${conn.testStatus}, skipping cooldown overwrite`
);
return { shouldFallback: true, cooldownMs: 0 };
}
// ─── Anti-Thundering Herd Guard ─────────────────────────────────
// If this connection was ALREADY marked unavailable by a prior concurrent
// request (within the mutex window), skip re-marking to avoid resetting
// the cooldown timer or double-incrementing the backoff level.
if (conn?.rateLimitedUntil && new Date(conn.rateLimitedUntil).getTime() > Date.now()) {
log.info(
"AUTH",
`${connectionId.slice(0, 8)} already marked unavailable (until ${conn.rateLimitedUntil}), skipping duplicate mark`
);
return {
shouldFallback: true,
cooldownMs: new Date(conn.rateLimitedUntil).getTime() - Date.now(),
};
}
// T09: Codex scope-aware lockout guard (codex vs spark independent pools).
if (provider === "codex" && model) {
const scopeRateLimitedUntil = getCodexScopeRateLimitedUntil(
conn?.providerSpecificData || {},
model
);
if (scopeRateLimitedUntil && new Date(scopeRateLimitedUntil).getTime() > Date.now()) {
log.info(
"AUTH",
`${connectionId.slice(0, 8)} already scope-limited for ${getCodexModelScope(model)} (until ${scopeRateLimitedUntil}), skipping duplicate mark`
);
return {
shouldFallback: true,
cooldownMs: new Date(scopeRateLimitedUntil).getTime() - Date.now(),
};
}
}
const result = checkFallbackError(
status,
errorText,
backoffLevel,
model,
provider // ← Now passes provider for profile-aware cooldowns
);
const { shouldFallback, cooldownMs, newBackoffLevel, reason } = result;
if (!shouldFallback) return { shouldFallback: false, cooldownMs: 0 };
// ── 404 model-only lockout: connection stays active ──
// For local providers (detected by URL) and cloud providers with passthrough models
// (like Antigravity), a 404 means the specific model doesn't exist or isn't available
// for this account — it should NOT lock out the entire connection.
const connBaseUrl = (conn?.providerSpecificData as Record<string, unknown>)?.baseUrl as
| string
| undefined;
const isPassthroughProvider = provider && getPassthroughProviders().has(provider);
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(
"AUTH",
`Model-only lockout for ${model} — 404 lockout ${localCooldown / 1000}s (connection stays active)`
);
return { shouldFallback: true, cooldownMs: localCooldown };
}
// ── 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(
"AUTH",
`Model-only lockout for ${model} — 429 rate limit ${Math.ceil(modelCooldown / 1000)}s (connection stays active)`
);
return { shouldFallback: true, cooldownMs: modelCooldown };
}
const rateLimitedUntil = getUnavailableUntil(cooldownMs);
const errorMsg = typeof errorText === "string" ? errorText.slice(0, 100) : "Provider error";
// T09: Codex per-scope lockout (do not block the whole account globally).
if (provider === "codex" && status === 429 && model && conn) {
const scope = getCodexModelScope(model);
const existingScopeMap = asRecord(conn.providerSpecificData.codexScopeRateLimitedUntil);
const persistedScopeUntil = getCodexScopeRateLimitedUntil(conn.providerSpecificData, model);
const scopeRateLimitedUntil = persistedScopeUntil || rateLimitedUntil;
const scopeCooldownMs = Math.max(new Date(scopeRateLimitedUntil).getTime() - Date.now(), 0);
await updateProviderConnection(connectionId, {
testStatus: "unavailable",
lastError: errorMsg,
errorCode: status,
lastErrorAt: new Date().toISOString(),
backoffLevel: newBackoffLevel ?? backoffLevel,
providerSpecificData: {
...conn.providerSpecificData,
codexScopeRateLimitedUntil: {
...existingScopeMap,
[scope]: scopeRateLimitedUntil,
},
},
});
if (scopeCooldownMs > 0) {
lockModel(provider, connectionId, model, reason || "unknown", scopeCooldownMs);
}
if (status && errorMsg) {
console.error(`${provider} [${status}] (${scope}): ${errorMsg}`);
}
return { shouldFallback: true, cooldownMs: scopeCooldownMs };
}
await updateProviderConnection(connectionId, {
rateLimitedUntil,
testStatus: "unavailable",
lastError: errorMsg,
errorCode: status,
lastErrorAt: new Date().toISOString(),
backoffLevel: newBackoffLevel ?? backoffLevel,
});
// T-AUTODISABLE: If auto-disable setting is enabled and error is permanent/terminal,
// mark account as inactive so it is never retried again.
// Uses getCachedSettings() to avoid DB overhead on hot error path.
// NOTE: For permanent bans we disable immediately — no threshold needed,
// because a permanent ban (403 "Verify your account" / ToS violation) will
// NEVER recover, so retrying is pointless regardless of attempt count.
if (result.permanent) {
try {
const settings = await getCachedSettings();
const autoDisableEnabled = settings.autoDisableBannedAccounts ?? false;
if (autoDisableEnabled) {
await updateProviderConnection(connectionId, { isActive: false });
log.info(
"AUTH",
`Auto-disabled ${connectionId.slice(0, 8)} — permanent ban detected (autoDisableBannedAccounts=true)`
);
}
} catch (e) {
log.info("AUTH", `Auto-disable check failed (non-fatal): ${e}`);
}
}
// Per-model lockout: lock the specific model if known
if (provider && model && cooldownMs > 0) {
lockModel(provider, connectionId, model, reason || "unknown", cooldownMs);
}
if (provider && status && errorMsg) {
console.error(`${provider} [${status}]: ${errorMsg}`);
}
return { shouldFallback: true, cooldownMs };
} finally {
if (resolveMutex) resolveMutex();
// Cleanup stale mutex entries (avoid memory leak)
markMutexes.delete(connectionId);
}
}
/**
* Clear account error status (only if currently has error)
* Optimized to avoid unnecessary DB updates
*/
export async function clearAccountError(
connectionId: string,
currentConnection: Partial<RecoverableConnectionState>
) {
// Only update if currently has error status
const hasError =
(currentConnection.testStatus && currentConnection.testStatus !== "active") ||
currentConnection.lastError ||
currentConnection.rateLimitedUntil ||
currentConnection.errorCode ||
currentConnection.lastErrorType ||
currentConnection.lastErrorSource;
if (!hasError) return; // Skip if already clean
await updateProviderConnection(connectionId, {
testStatus: "active",
lastError: null,
lastErrorAt: null,
lastErrorType: null,
lastErrorSource: null,
errorCode: null,
rateLimitedUntil: null,
backoffLevel: 0,
});
log.info("AUTH", `Account ${connectionId.slice(0, 8)} error cleared`);
}
export async function clearRecoveredProviderState(
credentials: Partial<RecoverableConnectionState> | null
) {
if (!credentials?.connectionId) return;
await clearAccountError(credentials.connectionId, credentials);
}
/**
* Extract API key from request headers
*/
export function extractApiKey(request: Request) {
const authHeader = request.headers.get("Authorization");
if (authHeader?.startsWith("Bearer ")) {
return authHeader.slice(7);
}
return null;
}
/**
* Validate API key (optional - for local use can skip)
*/
export async function isValidApiKey(apiKey: string) {
if (!apiKey) return false;
return await validateApiKey(apiKey);
}

3923
test-mcp-bundle.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,67 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-cc-models-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts");
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
test.afterEach(async () => {
await resetStorage();
});
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("v1 models exposes CC-compatible fallback models under the provider node prefix", async () => {
await providersDb.createProviderNode({
id: "anthropic-compatible-cc-cm",
type: "anthropic-compatible",
name: "Claude Max",
prefix: "cm",
baseUrl: "https://proxy.example.com",
chatPath: "/v1/messages?beta=true",
modelsPath: "/v1/models",
});
await providersDb.createProviderConnection({
provider: "anthropic-compatible-cc-cm",
authType: "apikey",
name: "cm-main",
apiKey: "sk-test",
isActive: true,
providerSpecificData: {
baseUrl: "https://proxy.example.com",
chatPath: "/v1/messages?beta=true",
modelsPath: "/v1/models",
},
});
const response = await v1ModelsCatalog.getUnifiedModelsResponse(
new Request("http://localhost/api/v1/models", { method: "GET" })
);
assert.equal(response.status, 200);
const body = await response.json();
const ids = new Set(body.data.map((item) => item.id));
assert.ok(ids.has("cm/claude-opus-4-6"));
assert.ok(ids.has("cm/claude-sonnet-4-6"));
assert.equal(
[...ids].some((id) => id.startsWith("anthropic-compatible-cc-cm/")),
false
);
});

View File

@@ -104,11 +104,14 @@ test("buildClaudeCodeCompatibleRequest keeps prior role history while dropping t
{ role: "user", text: "u2" },
]
);
assert.deepEqual(payload.messages.at(-1).content.at(-1).cache_control, { type: "ephemeral" });
assert.deepEqual(payload.messages[0].content.at(-1).cache_control, { type: "ephemeral" });
assert.deepEqual(payload.messages[1].content.at(-1).cache_control, { type: "ephemeral" });
assert.equal(payload.messages[2].content.at(-1).cache_control, undefined);
assert.equal(payload.system.length, 4);
assert.equal(payload.system.at(-1).text, "sys");
assert.equal(payload.tools.length, 1);
assert.deepEqual(payload.tools[0], {
const { cache_control, ...toolWithoutCacheControl } = payload.tools[0];
assert.deepEqual(toolWithoutCacheControl, {
name: "lookup_weather",
description: "Fetch weather",
input_schema: {
@@ -119,11 +122,70 @@ test("buildClaudeCodeCompatibleRequest keeps prior role history while dropping t
required: ["city"],
},
});
assert.deepEqual(payload.tools[0].cache_control, { type: "ephemeral", ttl: "1h" });
assert.deepEqual(payload.tool_choice, { type: "any" });
assert.equal(payload.context_management.edits[0].type, "clear_thinking_20251015");
assert.equal(JSON.parse(payload.metadata.user_id).session_id, "session-1");
});
test("buildClaudeCodeCompatibleRequest preserves Claude cache markers when requested", () => {
const payload = buildClaudeCodeCompatibleRequest({
sourceBody: {
max_tokens: 64,
},
normalizedBody: {
max_tokens: 64,
messages: [{ role: "user", content: "fallback" }],
},
claudeBody: {
system: [{ type: "text", text: "sys", cache_control: { type: "ephemeral", ttl: "5m" } }],
messages: [
{
role: "user",
content: [{ type: "text", text: "u1", cache_control: { type: "ephemeral" } }],
},
{
role: "assistant",
content: [
{
type: "text",
text: "a1",
cache_control: { type: "ephemeral", ttl: "10m" },
},
],
},
{ role: "user", content: [{ type: "text", text: "u2" }] },
],
tools: [
{
name: "lookup_weather",
description: "Fetch weather",
input_schema: {
type: "object",
properties: {
city: { type: "string" },
},
required: ["city"],
},
cache_control: { type: "ephemeral", ttl: "30m" },
},
],
},
model: "claude-sonnet-4-6",
sessionId: "session-preserve",
preserveCacheControl: true,
});
assert.deepEqual(payload.system.at(-1).cache_control, { type: "ephemeral", ttl: "5m" });
assert.deepEqual(payload.messages[0].content[0].cache_control, { type: "ephemeral" });
assert.deepEqual(payload.messages[1].content[0].cache_control, {
type: "ephemeral",
ttl: "10m",
});
assert.equal(payload.messages[2].content[0].cache_control, undefined);
assert.deepEqual(payload.tools[0].cache_control, { type: "ephemeral", ttl: "30m" });
});
test("buildClaudeCodeCompatibleRequest falls back to a user turn when the source only has assistant/model text", () => {
const payload = buildClaudeCodeCompatibleRequest({
sourceBody: {
@@ -181,6 +243,7 @@ test("buildClaudeCodeCompatibleRequest omits auto tool_choice while preserving t
});
assert.equal(payload.tools.length, 1);
assert.deepEqual(payload.tools[0].cache_control, { type: "ephemeral", ttl: "1h" });
assert.equal(payload.tool_choice, undefined);
});
@@ -356,6 +419,126 @@ test("handleChatCore forces upstream streaming for CC compatible while returning
assert.equal(payload.usage.completion_tokens, 5);
});
test("handleChatCore preserves Claude cache_control when CC-compatible requests originate from Claude", async () => {
const calls = [];
globalThis.fetch = async (url, init = {}) => {
calls.push({
url: String(url),
method: init.method || "GET",
headers: init.headers,
body: init.body ? JSON.parse(String(init.body)) : null,
});
return new Response(
[
"event: message_start",
'data: {"type":"message_start","message":{"id":"msg_2","type":"message","role":"assistant","model":"claude-sonnet-4-6","usage":{"input_tokens":12,"output_tokens":0}}}',
"",
"event: content_block_start",
'data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}',
"",
"event: content_block_delta",
'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Preserved"}}',
"",
"event: message_delta",
'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":3}}',
"",
"event: message_stop",
'data: {"type":"message_stop"}',
"",
].join("\n"),
{
status: 200,
headers: {
"content-type": "text/event-stream",
},
}
);
};
const claudeBody = {
model: "claude-sonnet-4-6",
max_tokens: 64,
system: [{ type: "text", text: "system", cache_control: { type: "ephemeral", ttl: "5m" } }],
messages: [
{
role: "user",
content: [{ type: "text", text: "u1", cache_control: { type: "ephemeral" } }],
},
{
role: "assistant",
content: [
{
type: "text",
text: "a1",
cache_control: { type: "ephemeral", ttl: "10m" },
},
],
},
{ role: "user", content: [{ type: "text", text: "u2" }] },
],
tools: [
{
name: "lookup_weather",
description: "Fetch weather",
input_schema: {
type: "object",
properties: {
city: { type: "string" },
},
},
cache_control: { type: "ephemeral", ttl: "30m" },
},
],
};
const result = await handleChatCore({
body: claudeBody,
modelInfo: {
provider: "anthropic-compatible-cc-test",
model: "claude-sonnet-4-6",
extendedContext: false,
},
credentials: {
apiKey: "sk-test",
providerSpecificData: {
baseUrl: "https://proxy.example.com",
chatPath: CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH,
},
},
clientRawRequest: {
endpoint: "/v1/messages",
body: claudeBody,
headers: new Headers({ accept: "application/json" }),
},
userAgent: "Claude-Code/1.0.0",
log: {
debug() {},
info() {},
warn() {},
error() {},
},
});
assert.equal(result.success, true);
assert.equal(calls.length, 1);
assert.deepEqual(calls[0].body.system.at(-1).cache_control, {
type: "ephemeral",
ttl: "5m",
});
assert.deepEqual(calls[0].body.messages[0].content[0].cache_control, {
type: "ephemeral",
});
assert.deepEqual(calls[0].body.messages[1].content[0].cache_control, {
type: "ephemeral",
ttl: "10m",
});
assert.deepEqual(calls[0].body.tools[0].cache_control, {
type: "ephemeral",
ttl: "30m",
});
});
test("provider-nodes create route rejects CC mode when feature flag is disabled", async () => {
delete process.env.ENABLE_CC_COMPATIBLE_PROVIDER;

View File

@@ -0,0 +1,55 @@
import test from "node:test";
import assert from "node:assert/strict";
import { claude } from "../../src/lib/oauth/providers/claude.ts";
import { CLAUDE_CONFIG } from "../../src/lib/oauth/constants/oauth.ts";
const originalFetch = globalThis.fetch;
test.afterEach(() => {
globalThis.fetch = originalFetch;
});
test("Claude OAuth provider uses the runtime redirectUri when building the auth URL", () => {
const redirectUri = "http://localhost:43121/callback";
const authUrl = claude.buildAuthUrl(CLAUDE_CONFIG, redirectUri, "state-123", "challenge-456");
const parsed = new URL(authUrl);
assert.equal(parsed.searchParams.get("redirect_uri"), redirectUri);
assert.equal(parsed.searchParams.get("state"), "state-123");
assert.equal(parsed.searchParams.get("code_challenge"), "challenge-456");
});
test("Claude OAuth provider uses the runtime redirectUri during token exchange", async () => {
let captured = null;
globalThis.fetch = async (url, init = {}) => {
captured = {
url: String(url),
method: init.method,
headers: init.headers,
body: JSON.parse(String(init.body)),
};
return new Response(JSON.stringify({ access_token: "token-1" }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
};
const redirectUri = "http://localhost:43121/callback";
await claude.exchangeToken(
CLAUDE_CONFIG,
"auth-code#state-from-fragment",
redirectUri,
"verifier-123",
"state-from-request"
);
assert.equal(captured.url, CLAUDE_CONFIG.tokenUrl);
assert.equal(captured.method, "POST");
assert.equal(captured.body.redirect_uri, redirectUri);
assert.equal(captured.body.code, "auth-code");
assert.equal(captured.body.state, "state-from-fragment");
assert.equal(captured.body.code_verifier, "verifier-123");
});

View File

@@ -82,3 +82,30 @@ test("T27: SSE [DONE] guard applies only in streaming mode", async () => {
BaseExecutor.prototype.execute = originalExecute;
}
});
test("T27: streaming error responses keep their original body readable", async () => {
const executor = new GithubExecutor();
const originalExecute = BaseExecutor.prototype.execute;
BaseExecutor.prototype.execute = async () => ({
response: new Response("IDE token expired: unauthorized: token expired\n", {
status: 401,
headers: { "content-type": "text/plain; charset=utf-8" },
}),
url: "https://api.githubcopilot.com/chat/completions",
});
try {
const result = await executor.execute({
model: "claude-sonnet-4.5",
body: { messages: [] },
stream: true,
credentials: { accessToken: "token" },
});
assert.equal(result.response.status, 401);
assert.equal(await result.response.text(), "IDE token expired: unauthorized: token expired\n");
} finally {
BaseExecutor.prototype.execute = originalExecute;
}
});

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