diff --git a/.gitignore b/.gitignore index f32c82f9a5..88bcf2d3f3 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,7 @@ next-env.d.ts # data and logs data/ +.data/ logs/* # analysis directories (generated, not tracked) @@ -153,4 +154,7 @@ vscode-extension/ typescript # Gemini Antigravity agent data -.gemini/ \ No newline at end of file +.gemini/ + +# Superpowers plans/specs (internal tooling, not project code) +docs/superpowers/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index dbdb434295..2a3dba18b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 045844f897..39fda43097 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -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, diff --git a/electron/package.json b/electron/package.json index 2a5d3823d8..7c1b2dbe64 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "omniroute-desktop", - "version": "3.4.6", + "version": "3.4.7", "description": "OmniRoute Desktop Application", "main": "main.js", "author": { diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 1e493b355d..ab69c66df1 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -192,22 +192,9 @@ export const REGISTRY: Record = { clientSecretEnv: "GEMINI_OAUTH_CLIENT_SECRET", clientSecretDefault: "", }, - models: [ - { id: "gemini-3.1-pro-high", name: "Gemini 3.1 Pro High" }, - { id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro Low" }, - { id: "gemini-3.1-pro", name: "Gemini 3.1 Pro" }, - { id: "gemini-3-1-pro", name: "Gemini 3.1 Pro (Alt ID)" }, - { id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" }, - { id: "gemini-3.1-flash-lite-preview", name: "Gemini 3.1 Flash Lite Preview" }, - { id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview" }, - { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" }, - { id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" }, - { id: "gemini-2.5-flash-lite", name: "Gemini 2.5 Flash Lite" }, - { id: "gemini-2.0-flash", name: "Gemini 2.0 Flash" }, - { id: "gemini-2.0-flash-exp", name: "Gemini 2.0 Flash Exp" }, - { id: "gemini-1.5-pro", name: "Gemini 1.5 Pro" }, - { id: "gemini-1.5-flash", name: "Gemini 1.5 Flash" }, - ], + models: [], + // Models are populated from Google's API via sync-models (per API key). + // No hardcoded fallback — show nothing until a key is added. }, "gemini-cli": { diff --git a/open-sse/executors/github.ts b/open-sse/executors/github.ts index 42abe949a7..5399ae0901 100644 --- a/open-sse/executors/github.ts +++ b/open-sse/executors/github.ts @@ -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; } diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 024cca1839..ca0d493c49 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -13,7 +13,9 @@ import { refreshWithRetry } from "../services/tokenRefresh.ts"; import { createRequestLogger } from "../utils/requestLogger.ts"; import { getModelTargetFormat, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.ts"; import { resolveModelAlias } from "../services/modelDeprecation.ts"; -import { getUnsupportedParams, getPassthroughProviders } from "../config/providerRegistry.ts"; +import { getUnsupportedParams } from "../config/providerRegistry.ts"; +import { hasPerModelQuota, lockModelIfPerModelQuota } from "../services/accountFallback.ts"; +import { COOLDOWN_MS } from "../config/constants.ts"; import { buildErrorBody, createErrorResult, @@ -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, diff --git a/open-sse/mcp-server/schemas/tools.ts b/open-sse/mcp-server/schemas/tools.ts index 414390583f..b501e90e55 100644 --- a/open-sse/mcp-server/schemas/tools.ts +++ b/open-sse/mcp-server/schemas/tools.ts @@ -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 = { diff --git a/open-sse/mcp-server/server.ts b/open-sse/mcp-server/server.ts index cdf16d2fc8..234934af3f 100644 --- a/open-sse/mcp-server/server.ts +++ b/open-sse/mcp-server/server.ts @@ -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); diff --git a/open-sse/package.json b/open-sse/package.json index 71785da198..d9fb9107e0 100644 --- a/open-sse/package.json +++ b/open-sse/package.json @@ -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", diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index d485184976..d0c09e845e 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -100,13 +100,49 @@ export function lockModel(provider, connectionId, model, reason, cooldownMs) { if (!model) return; // No model → skip model-level locking ensureCleanupTimer(); const key = `${provider}:${connectionId}:${model}`; + const newUntil = Date.now() + cooldownMs; + // Preserve the longer cooldown if an existing lock has more time remaining. + // Safe without a mutex: no await between get/set, so this runs atomically + // within Node.js's single-threaded event loop. + const existing = modelLockouts.get(key); + if (existing && existing.until > newUntil) return; modelLockouts.set(key, { reason, - until: Date.now() + cooldownMs, + until: newUntil, lockedAt: Date.now(), }); } +/** + * Whether a provider should use per-model lockouts instead of connection-wide cooldowns. + * Gemini AI Studio has per-model quotas; passthrough providers have independent model limits. + */ +export function hasPerModelQuota(provider: string): boolean { + if (provider === "gemini") return true; + try { + const { getPassthroughProviders } = require("../config/providerRegistry.ts"); + return getPassthroughProviders().has(provider); + } catch { + return false; + } +} + +/** + * Lock a model (not connection) for a provider with per-model quotas. + * No-ops for providers that don't use per-model lockouts. + */ +export function lockModelIfPerModelQuota( + provider: string, + connectionId: string, + model: string | null, + reason: string, + cooldownMs: number +): boolean { + if (!hasPerModelQuota(provider) || !model) return false; + lockModel(provider, connectionId, model, reason, cooldownMs); + return true; +} + /** * Check if a specific model on a specific account is locked * @returns {boolean} diff --git a/open-sse/services/autoCombo/routerStrategy.ts b/open-sse/services/autoCombo/routerStrategy.ts index 1278d80b1b..ec60b72939 100644 --- a/open-sse/services/autoCombo/routerStrategy.ts +++ b/open-sse/services/autoCombo/routerStrategy.ts @@ -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(); @@ -123,12 +152,14 @@ const strategyRegistry = new Map(); 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); diff --git a/open-sse/services/claudeCodeCompatible.ts b/open-sse/services/claudeCodeCompatible.ts index baff923d87..de5de80369 100644 --- a/open-sse/services/claudeCodeCompatible.ts +++ b/open-sse/services/claudeCodeCompatible.ts @@ -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 | null; normalizedBody?: Record | null; + claudeBody?: Record | 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[] | 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[]) + : 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> } => + !!message && message.content.length > 0 + ) + : []; + + const merged: Array<{ role: "user" | "assistant"; content: Array> }> = []; + + 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> | undefined; + cwd: string; + now: Date; +}) { + const customSystemBlocks = + Array.isArray(systemBlocks) && systemBlocks.length > 0 + ? systemBlocks + : extractCustomSystemBlocks(messages); const dateText = formatDate(now); const blocks: Array> = [ @@ -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 => !!tool); + .filter((tool): tool is Record => !!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, + 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 => !!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 => !!message); +} + +function normalizeClaudeToolInput(tools: unknown) { + if (!Array.isArray(tools)) return []; + return tools + .map((tool) => readRecord(cloneValue(tool))) + .filter((tool): tool is Record => !!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 => !!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> }> +) { + 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>) { + for (const block of content) { + delete block.cache_control; + } +} + +function markLastContentCacheControl(content: Array>, 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>) { + for (let i = tools.length - 1; i >= 0; i--) { + if (!tools[i].defer_loading) { + return i; + } + } + return -1; +} + +function cloneValue(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(); diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index 582418ddea..13e9ffc0b1 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -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; - 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; } diff --git a/open-sse/services/rateLimitManager.ts b/open-sse/services/rateLimitManager.ts index 2d7ba8549c..2b0dd54aa2 100644 --- a/open-sse/services/rateLimitManager.ts +++ b/open-sse/services/rateLimitManager.ts @@ -200,6 +200,11 @@ function getLimiterKey(provider, connectionId, model = null) { if (provider === "codex" && model) { return `${provider}:${getCodexRateLimitKey(connectionId, model)}`; } + // Gemini AI Studio has per-model quotas — use model-scoped limiter keys + // so a 429 on one model doesn't pause requests for other models. + if (provider === "gemini" && model) { + return `${provider}:${connectionId}:${model}`; + } return `${provider}:${connectionId}`; } @@ -570,7 +575,7 @@ export function updateFromResponseBody(provider, connectionId, responseBody, sta const { retryAfterMs, reason } = parseRetryAfterFromBody(responseBody); if (retryAfterMs && retryAfterMs > 0) { - const limiter = getLimiter(provider, connectionId, null); + const limiter = getLimiter(provider, connectionId, model); console.log( `🚫 [RATE-LIMIT] ${provider}:${connectionId.slice(0, 8)} — body-parsed retry: ${Math.ceil(retryAfterMs / 1000)}s (${reason})` ); diff --git a/open-sse/translator/helpers/geminiHelper.ts b/open-sse/translator/helpers/geminiHelper.ts index 1a9ff11208..dafdfd3a1b 100644 --- a/open-sse/translator/helpers/geminiHelper.ts +++ b/open-sse/translator/helpers/geminiHelper.ts @@ -84,7 +84,7 @@ export function convertOpenAIContentToParts(content) { const mimeType = mimePart.split(";")[0]; parts.push({ - inlineData: { mime_type: mimeType, data: data }, + inlineData: { mimeType, data }, }); } } diff --git a/open-sse/translator/request/claude-to-gemini.ts b/open-sse/translator/request/claude-to-gemini.ts index 76c53084fd..dc4212ec95 100644 --- a/open-sse/translator/request/claude-to-gemini.ts +++ b/open-sse/translator/request/claude-to-gemini.ts @@ -192,7 +192,7 @@ export function claudeToGeminiRequest(model, body, stream) { if (body.thinking?.type === "enabled" && body.thinking.budget_tokens) { result.generationConfig.thinkingConfig = { thinkingBudget: body.thinking.budget_tokens, - include_thoughts: true, + includeThoughts: true, }; } diff --git a/open-sse/translator/request/gemini-to-openai.ts b/open-sse/translator/request/gemini-to-openai.ts index 7208ad66e8..8be1012e74 100644 --- a/open-sse/translator/request/gemini-to-openai.ts +++ b/open-sse/translator/request/gemini-to-openai.ts @@ -92,11 +92,13 @@ function convertGeminiContent(content) { parts.push({ type: "text", text: part.text }); } - if (part.inlineData) { + if (part.inlineData || part.inline_data) { + const data = part.inlineData || part.inline_data; + const mimeType = data.mimeType || data.mime_type || "image/png"; parts.push({ type: "image_url", image_url: { - url: `data:${part.inlineData.mimeType};base64,${part.inlineData.data}`, + url: `data:${mimeType};base64,${data.data}`, }, }); } diff --git a/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index eafdada22b..fafe9c6796 100644 --- a/open-sse/translator/request/openai-to-gemini.ts +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -33,7 +33,7 @@ type GeminiGenerationConfig = { maxOutputTokens?: unknown; thinkingConfig?: { thinkingBudget: number; - include_thoughts: boolean; + includeThoughts: boolean; }; responseMimeType?: string; responseSchema?: unknown; @@ -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, }, }); diff --git a/open-sse/translator/response/gemini-to-claude.ts b/open-sse/translator/response/gemini-to-claude.ts index 03578ea1b1..a9b438ab05 100644 --- a/open-sse/translator/response/gemini-to-claude.ts +++ b/open-sse/translator/response/gemini-to-claude.ts @@ -171,6 +171,11 @@ export function geminiToClaudeResponse(chunk, state) { stopReason = "tool_use"; } else if (reason === "max_tokens" || reason === "length") { stopReason = "max_tokens"; + } else if (reason === "safety" || reason === "recitation" || reason === "blocklist") { + // Content blocked by Gemini safety. Any text streamed before this finish + // reason has already been emitted to the client — this is unavoidable in + // SSE streaming. Map to end_turn (Claude has no "content blocked" reason). + stopReason = "end_turn"; } else { stopReason = "end_turn"; } diff --git a/open-sse/translator/response/gemini-to-openai.ts b/open-sse/translator/response/gemini-to-openai.ts index 8db3d51cbd..df40b6062a 100644 --- a/open-sse/translator/response/gemini-to-openai.ts +++ b/open-sse/translator/response/gemini-to-openai.ts @@ -225,6 +225,11 @@ export function geminiToOpenAIResponse(chunk, state) { if (finishReason === "stop" && state.toolCalls.size > 0) { finishReason = "tool_calls"; } + // Content blocked by Gemini safety filters — pass through as "content_filter" + // so downstream clients can distinguish from normal completion. + if (finishReason === "safety" || finishReason === "recitation" || finishReason === "blocklist") { + finishReason = "content_filter"; + } const finalChunk: Record = { id: `chatcmpl-${state.messageId}`, diff --git a/package-lock.json b/package-lock.json index 0d81cbc84c..b83e1a0c48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" } } } diff --git a/package.json b/package.json index a135cc3f80..ff57a96450 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/scripts/prepublish.mjs b/scripts/prepublish.mjs index 00f1345b26..aa4c4033b8 100644 --- a/scripts/prepublish.mjs +++ b/scripts/prepublish.mjs @@ -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"); diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index 06f81eedfd..d71b1bebb7 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -840,6 +840,7 @@ export default function ProviderDetailPage() { customModels: CompatModelRow[]; modelCompatOverrides: Array; }>({ customModels: [], modelCompatOverrides: [] }); + const [syncedAvailableModels, setSyncedAvailableModels] = useState([]); const [compatSavingModelId, setCompatSavingModelId] = useState(null); const [applyingCodexAuthId, setApplyingCodexAuthId] = useState(null); const [exportingCodexAuthId, setExportingCodexAuthId] = useState(null); @@ -881,7 +882,11 @@ export default function ProviderDetailPage() { !!(FREE_PROVIDERS as any)[providerId] || !!(OAUTH_PROVIDERS as any)[providerId]; const providerSupportsPat = supportsApiKeyOnFreeProvider(providerId); const isOAuth = providerSupportsOAuth && !providerSupportsPat; - const models = getModelsByProviderId(providerId); + const registryModels = getModelsByProviderId(providerId); + // For Gemini: always use synced API models (empty if no keys added yet) + const models = providerId === "gemini" + ? syncedAvailableModels + : registryModels; const providerAlias = getProviderAlias(providerId); const isManagedAvailableModelsProvider = isCompatible || providerId === "openrouter"; const isSearchProvider = providerId.endsWith("-search"); @@ -915,6 +920,20 @@ export default function ProviderDetailPage() { customModels: data.models || [], modelCompatOverrides: data.modelCompatOverrides || [], }); + // Fetch synced available models for Gemini + if (providerId === "gemini") { + try { + const syncRes = await fetch("/api/synced-available-models?provider=gemini", { + cache: "no-store", + }); + if (syncRes.ok) { + const syncData = await syncRes.json(); + setSyncedAvailableModels(syncData.models || []); + } + } catch { + // Non-critical + } + } } catch (e) { console.error("fetchProviderModelMeta", e); } @@ -1060,6 +1079,10 @@ export default function ProviderDetailPage() { const res = await fetch(`/api/providers/${id}`, { method: "DELETE" }); if (res.ok) { setConnections(connections.filter((c) => c.id !== id)); + // Refresh model list after connection deletion (synced models may change) + if (providerId === "gemini") { + await fetchProviderModelMeta(); + } } } catch (error) { console.log("Error deleting connection:", error); @@ -1087,8 +1110,72 @@ export default function ProviderDetailPage() { body: JSON.stringify({ provider: providerId, ...formData }), }); if (res.ok) { + const connectionData = await res.json(); + const newConnection = connectionData?.connection; await fetchConnections(); setShowAddApiKeyModal(false); + + // For Gemini: show progress dialog and sync models from endpoint + if (providerId === "gemini" && newConnection?.id) { + setShowImportModal(true); + setImportProgress({ + current: 0, + total: 0, + phase: "fetching", + status: t("fetchingModels"), + logs: [], + error: "", + importedCount: 0, + }); + + try { + const syncRes = await fetch(`/api/providers/${newConnection.id}/sync-models`, { + method: "POST", + signal: AbortSignal.timeout(30_000), // 30s timeout — model sync shouldn't hang + }); + const syncData = await syncRes.json(); + + if (!syncRes.ok || syncData.error) { + setImportProgress((prev) => ({ + ...prev, + phase: "error", + status: t("failedFetchModels"), + error: syncData.error?.message || syncData.error || t("failedImportModels"), + })); + return null; + } + + const syncedCount = syncData.syncedModels || 0; + const syncedModelList: Array<{ id: string; name?: string }> = syncData.models || []; + const logs: string[] = []; + if (syncedModelList.length > 0) { + logs.push(`✓ ${syncedCount} models available`); + logs.push(""); + for (const m of syncedModelList) { + logs.push(` ${m.name || m.id}`); + } + } + + setImportProgress((prev) => ({ + ...prev, + phase: "done", + status: t("modelsImported", { count: syncedCount }), + total: syncedCount, + current: syncedCount, + importedCount: syncedCount, + logs, + })); + + await fetchProviderModelMeta(); + } catch (syncError) { + setImportProgress((prev) => ({ + ...prev, + phase: "error", + status: t("failedFetchModels"), + error: String(syncError), + })); + } + } return null; } const data = await res.json().catch(() => ({})); @@ -1963,7 +2050,7 @@ export default function ProviderDetailPage() { ); } - const importButton = ( + const importButton = providerId === "gemini" ? null : (
+
)} diff --git a/src/app/api/monitoring/health/route.ts b/src/app/api/monitoring/health/route.ts index 38c5f71d97..2a24fc598f 100644 --- a/src/app/api/monitoring/health/route.ts +++ b/src/app/api/monitoring/health/route.ts @@ -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) { diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index 40a27d4166..b9fb870b1a 100644 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -130,16 +130,56 @@ const PROVIDER_MODELS_CONFIG: Record = { parseResponse: (data) => data.data || [], }, gemini: { - url: "https://generativelanguage.googleapis.com/v1beta/models", + url: "https://generativelanguage.googleapis.com/v1beta/models?pageSize=1000", method: "GET", headers: { "Content-Type": "application/json" }, authQuery: "key", // Use query param for API key - parseResponse: (data) => - (data.models || []).map((m) => ({ - ...m, - id: (m.name || m.id || "").replace(/^models\//, ""), - name: m.displayName || (m.name || "").replace(/^models\//, ""), - })), + parseResponse: (data) => { + const METHOD_TO_ENDPOINT: Record = { + generateContent: "chat", + embedContent: "embeddings", + predict: "images", + predictLongRunning: "images", + bidiGenerateContent: "audio", + generateAnswer: "chat", + }; + const IGNORED_METHODS = new Set([ + "countTokens", + "countTextTokens", + "createCachedContent", + "batchGenerateContent", + "asyncBatchEmbedContent", + ]); + + return (data.models || []).map((m: Record) => { + const methods: string[] = Array.isArray(m.supportedGenerationMethods) + ? m.supportedGenerationMethods + : []; + const endpoints = [ + ...new Set( + methods + .filter((method) => !IGNORED_METHODS.has(method)) + .map((method) => METHOD_TO_ENDPOINT[method] || "chat") + ), + ]; + if (endpoints.length === 0) endpoints.push("chat"); + + return { + ...m, + id: ((m.name as string) || (m.id as string) || "").replace(/^models\//, ""), + name: (m.displayName as string) || ((m.name as string) || "").replace(/^models\//, ""), + supportedEndpoints: endpoints, + ...(typeof m.inputTokenLimit === "number" + ? { inputTokenLimit: m.inputTokenLimit } + : {}), + ...(typeof m.outputTokenLimit === "number" + ? { outputTokenLimit: m.outputTokenLimit } + : {}), + ...(typeof m.description === "string" ? { description: m.description } : {}), + ...(m.thinking === true ? { supportsThinking: true } : {}), + }; + }); + }, }, // gemini-cli handled via retrieveUserQuota (see GET handler) qwen: { @@ -648,7 +688,7 @@ export async function GET( // Build request URL let url = config.url; if (config.authQuery) { - url += `?${config.authQuery}=${token}`; + url += `${url.includes("?") ? "&" : "?"}${config.authQuery}=${token}`; } // Build headers @@ -657,7 +697,7 @@ export async function GET( headers[config.authHeader] = (config.authPrefix || "") + token; } - // Make request + // Make request (with pagination for providers that use nextPageToken, e.g. Gemini) const fetchOptions: any = { method: config.method, headers, @@ -667,24 +707,53 @@ export async function GET( fetchOptions.body = JSON.stringify(config.body); } - const response = await fetch(url, fetchOptions); + let allModels: any[] = []; + let pageUrl = url; + let pageCount = 0; + const MAX_PAGES = 20; // Safety limit + const seenTokens = new Set(); - if (!response.ok) { - const errorText = await response.text(); - console.log(`Error fetching models from ${provider}:`, errorText); - return NextResponse.json( - { error: `Failed to fetch models: ${response.status}` }, - { status: response.status } - ); + while (pageUrl && pageCount < MAX_PAGES) { + pageCount++; + const response = await fetch(pageUrl, { + ...fetchOptions, + signal: AbortSignal.timeout(15_000), + }); + + if (!response.ok) { + const errorText = await response.text(); + console.log(`Error fetching models from ${provider}:`, errorText); + return NextResponse.json( + { error: `Failed to fetch models: ${response.status}` }, + { status: response.status } + ); + } + + const data = await response.json(); + const pageModels = config.parseResponse(data); + allModels = allModels.concat(pageModels); + + const nextPageToken = data.nextPageToken; + if (!nextPageToken) break; + if (seenTokens.has(nextPageToken)) { + console.warn(`[models] ${provider}: duplicate nextPageToken detected, stopping pagination`); + break; + } + seenTokens.add(nextPageToken); + pageUrl = `${config.url}${config.url.includes("?") ? "&" : "?"}pageToken=${encodeURIComponent(nextPageToken)}`; + if (config.authQuery) { + pageUrl += `&${config.authQuery}=${token}`; + } } - const data = await response.json(); - const models = config.parseResponse(data); + if (pageCount > 1) { + console.log(`[models] ${provider}: fetched ${allModels.length} models across ${pageCount} pages`); + } return buildResponse({ provider, connectionId, - models, + models: allModels, }); } catch (error) { console.log("Error fetching provider models:", error); diff --git a/src/app/api/providers/[id]/route.ts b/src/app/api/providers/[id]/route.ts index 6d91e3f3f1..43c6624736 100644 --- a/src/app/api/providers/[id]/route.ts +++ b/src/app/api/providers/[id]/route.ts @@ -169,11 +169,27 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i try { const { id } = await params; + // Fetch connection before deleting to check provider type + const connection = await getProviderConnectionById(id); + if (!connection) { + return NextResponse.json({ error: "Connection not found" }, { status: 404 }); + } + const deleted = await deleteProviderConnection(id); if (!deleted) { return NextResponse.json({ error: "Connection not found" }, { status: 404 }); } + // Clean up synced available models for this connection + if (connection.provider === "gemini") { + try { + const { deleteSyncedAvailableModelsForConnection } = await import("@/lib/db/models"); + await deleteSyncedAvailableModelsForConnection("gemini", id); + } catch (e) { + console.error("Failed to clean up synced models for deleted gemini connection:", e); + } + } + // Auto sync to Cloud if enabled await syncToCloudIfEnabled(); diff --git a/src/app/api/providers/[id]/sync-models/route.ts b/src/app/api/providers/[id]/sync-models/route.ts index 3792aa204b..d4f9a40e0f 100644 --- a/src/app/api/providers/[id]/sync-models/route.ts +++ b/src/app/api/providers/[id]/sync-models/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { getProviderConnectionById } from "@/models"; -import { getCustomModels, replaceCustomModels } from "@/lib/db/models"; +import { getCustomModels, replaceCustomModels, replaceSyncedAvailableModelsForConnection } from "@/lib/db/models"; import { syncManagedAvailableModelAliases, usesManagedAvailableModels, @@ -188,11 +188,38 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: id: m.id || m.name || m.model, name: m.name || m.displayName || m.id || m.model, source: "auto-sync", + ...(Array.isArray(m.supportedEndpoints) && m.supportedEndpoints.length > 0 + ? { supportedEndpoints: m.supportedEndpoints } + : {}), + ...(typeof m.inputTokenLimit === "number" ? { inputTokenLimit: m.inputTokenLimit } : {}), + ...(typeof m.outputTokenLimit === "number" ? { outputTokenLimit: m.outputTokenLimit } : {}), + ...(typeof m.description === "string" ? { description: m.description } : {}), + ...(m.supportsThinking === true ? { supportsThinking: true } : {}), })) .filter((m: any) => m.id && !registryIds.has(m.id)); const previousModels = await getCustomModels(logProvider); const replaced = await replaceCustomModels(logProvider, models); + + // For Gemini: also write to syncedAvailableModels (unioned across API keys) + if (logProvider === "gemini") { + try { + const syncedModels = models.map((m: any) => ({ + id: m.id, + name: m.name || m.id, + source: "api-sync" as const, + ...(m.supportedEndpoints ? { supportedEndpoints: m.supportedEndpoints } : {}), + ...(typeof m.inputTokenLimit === "number" ? { inputTokenLimit: m.inputTokenLimit } : {}), + ...(typeof m.outputTokenLimit === "number" ? { outputTokenLimit: m.outputTokenLimit } : {}), + ...(typeof m.description === "string" ? { description: m.description } : {}), + ...(m.supportsThinking === true ? { supportsThinking: true } : {}), + })); + await replaceSyncedAvailableModelsForConnection(logProvider, id, syncedModels); + } catch (e) { + console.error("Failed to union synced available models for gemini:", e); + } + } + const modelChanges = summarizeModelChanges(previousModels, replaced); let syncedAliases = 0; diff --git a/src/app/api/providers/route.ts b/src/app/api/providers/route.ts index 06ff7ebb1a..42275e7c8b 100644 --- a/src/app/api/providers/route.ts +++ b/src/app/api/providers/route.ts @@ -145,6 +145,8 @@ export async function POST(request: Request) { testStatus: testStatus || "unknown", }); + // Note: Gemini model sync is now triggered client-side with progress dialog + // Hide sensitive fields const result: Record = { ...newConnection }; delete result.apiKey; diff --git a/src/app/api/synced-available-models/route.ts b/src/app/api/synced-available-models/route.ts new file mode 100644 index 0000000000..1dd9686548 --- /dev/null +++ b/src/app/api/synced-available-models/route.ts @@ -0,0 +1,33 @@ +import { getSyncedAvailableModels, getAllSyncedAvailableModels } from "@/lib/db/models"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; + +/** + * GET /api/synced-available-models?provider= + * List synced available models for a provider (or all providers). + */ +export async function GET(request: Request) { + try { + if (!(await isAuthenticated(request))) { + return Response.json( + { error: { message: "Authentication required", type: "invalid_api_key" } }, + { status: 401 } + ); + } + + const { searchParams } = new URL(request.url); + const provider = searchParams.get("provider"); + + if (provider) { + const models = await getSyncedAvailableModels(provider); + return Response.json({ models }); + } + + const allModels = await getAllSyncedAvailableModels(); + return Response.json(allModels); + } catch { + return Response.json( + { error: { message: "Failed to fetch synced available models", type: "server_error" } }, + { status: 500 } + ); + } +} diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index 73d4998c6f..c39df2f274 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -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; for (const [providerId, rawProviderCustomModels] of Object.entries(customModelsMap)) { + // Skip Gemini — handled by syncedAvailableModels above + if (providerId === "gemini") continue; const providerCustomModels = Array.isArray(rawProviderCustomModels) ? rawProviderCustomModels.filter( (model): model is Record => @@ -454,6 +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; diff --git a/src/app/api/v1beta/models/route.ts b/src/app/api/v1beta/models/route.ts index 1838e63375..87393213f4 100644 --- a/src/app/api/v1beta/models/route.ts +++ b/src/app/api/v1beta/models/route.ts @@ -1,5 +1,6 @@ import { CORS_ORIGIN } from "@/shared/utils/cors"; import { PROVIDER_MODELS } from "@/shared/constants/models"; +import { getAllCustomModels, getSyncedAvailableModels } from "@/lib/db/models"; /** * Handle CORS preflight @@ -16,13 +17,13 @@ export async function OPTIONS() { /** * GET /v1beta/models - Gemini compatible models list - * Returns models in Gemini API format + * Returns models in Gemini API format with real token limits when available. */ export async function GET() { try { - // Collect all models from all providers const models = []; + // Built-in models (hardcoded defaults) for (const [provider, providerModels] of Object.entries(PROVIDER_MODELS)) { for (const model of providerModels) { models.push({ @@ -36,8 +37,60 @@ export async function GET() { } } + // Gemini: always replace hardcoded entries with synced models (no fallback) + // Always remove hardcoded gemini entries — even if sync returns empty + for (let i = models.length - 1; i >= 0; i--) { + if (typeof (models[i] as any).name === "string" && (models[i] as any).name.startsWith("models/gemini/")) { + models.splice(i, 1); + } + } + try { + const syncedGeminiModels = await getSyncedAvailableModels("gemini"); + for (const m of syncedGeminiModels) { + models.push({ + name: `models/gemini/${m.id}`, + displayName: m.name || m.id, + ...(typeof m.description === "string" ? { description: m.description } : {}), + supportedGenerationMethods: ["generateContent"], + inputTokenLimit: typeof m.inputTokenLimit === "number" ? m.inputTokenLimit : 128000, + outputTokenLimit: typeof m.outputTokenLimit === "number" ? m.outputTokenLimit : 8192, + ...(m.supportsThinking === true ? { thinking: true } : {}), + }); + } + } catch (err) { + console.error("[v1beta/models] Error fetching synced Gemini models:", err); + } + + // Custom models (use stored metadata from provider APIs) + try { + const customModelsMap = (await getAllCustomModels()) as Record; + for (const [providerId, rawModels] of Object.entries(customModelsMap)) { + if (!Array.isArray(rawModels)) continue; + // Skip Gemini — handled by syncedAvailableModels above + if (providerId === "gemini") continue; + for (const model of rawModels) { + if (!model || typeof model !== "object" || typeof (model as any).id !== "string") continue; + const m = model as Record; + if (m.isHidden === true) continue; + models.push({ + name: `models/${providerId}/${m.id}`, + displayName: m.name || m.id, + ...(typeof m.description === "string" ? { description: m.description } : {}), + supportedGenerationMethods: ["generateContent"], + inputTokenLimit: + typeof m.inputTokenLimit === "number" ? m.inputTokenLimit : 128000, + outputTokenLimit: + typeof m.outputTokenLimit === "number" ? m.outputTokenLimit : 8192, + ...(m.supportsThinking === true ? { thinking: true } : {}), + }); + } + } + } catch { + // Custom models are optional — skip on error + } + return Response.json({ models }); - } catch (error) { + } catch (error: any) { console.log("Error fetching models:", error); return Response.json({ error: { message: error.message } }, { status: 500 }); } diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 969492f42a..9fd535b5d6 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1595,6 +1595,7 @@ "chatCompletions": "Chat Completions", "importingModels": "Importing...", "importFromModels": "Import from /models", + "modelsImported": "{count} models imported", "allModelsAlreadyImported": "All models already imported", "noNewModelsToImport": "No new models to import — all models are already in the registry or custom models list", "skippingExistingModels": "Skipping {count} existing models", @@ -1616,6 +1617,7 @@ "importFailed": "Import failed", "noNewModelsAdded": "No new models were added.", "adding": "Adding...", + "close": "Close", "importingModelsTitle": "Importing Models", "copyModel": "Copy model", "removeModel": "Remove model", diff --git a/src/lib/db/models.ts b/src/lib/db/models.ts index 74234a7f12..e5545de694 100644 --- a/src/lib/db/models.ts +++ b/src/lib/db/models.ts @@ -383,6 +383,10 @@ export async function replaceCustomModels( source?: string; apiFormat?: string; supportedEndpoints?: string[]; + inputTokenLimit?: number; + outputTokenLimit?: number; + description?: string; + supportsThinking?: boolean; }>, { allowEmpty = false }: { allowEmpty?: boolean } = {} ) { @@ -412,6 +416,27 @@ export async function replaceCustomModels( source: m.source || "auto-sync", apiFormat: m.apiFormat || (prev as any)?.apiFormat || "chat-completions", supportedEndpoints: m.supportedEndpoints || (prev as any)?.supportedEndpoints || ["chat"], + // Preserve metadata from provider API (or previous sync) + ...(m.inputTokenLimit != null + ? { inputTokenLimit: m.inputTokenLimit } + : (prev as any)?.inputTokenLimit != null + ? { inputTokenLimit: (prev as any).inputTokenLimit } + : {}), + ...(m.outputTokenLimit != null + ? { outputTokenLimit: m.outputTokenLimit } + : (prev as any)?.outputTokenLimit != null + ? { outputTokenLimit: (prev as any).outputTokenLimit } + : {}), + ...(m.description != null + ? { description: m.description } + : (prev as any)?.description != null + ? { description: (prev as any).description } + : {}), + ...(m.supportsThinking != null + ? { supportsThinking: m.supportsThinking } + : (prev as any)?.supportsThinking != null + ? { supportsThinking: (prev as any).supportsThinking } + : {}), // Preserve existing compat flags ...(prev && (prev as any).normalizeToolCallId !== undefined ? { normalizeToolCallId: (prev as any).normalizeToolCallId } @@ -481,6 +506,108 @@ export async function removeCustomModel(providerId: string, modelId: string) { return true; } +// ──────────────── Synced Available Models ──────────────── +// Storage: namespace = 'syncedAvailableModels', key = ':' +// Each connection stores its own model list. Reads union across all connections +// for a provider. Deleting a connection removes only its models. + +export interface SyncedAvailableModel { + id: string; + name: string; + source: "api-sync"; + supportedEndpoints?: string[]; + inputTokenLimit?: number; + outputTokenLimit?: number; + description?: string; + supportsThinking?: boolean; +} + +/** + * Get all synced available models for a provider, unioned across all connections. + */ +export async function getSyncedAvailableModels(providerId: string): Promise { + const db = getDbInstance(); + const rows = db + .prepare("SELECT key, value FROM key_value WHERE namespace = 'syncedAvailableModels' AND key LIKE ?") + .all(`${providerId}:%`); + const map = new Map(); + for (const row of rows) { + const { key, value } = getKeyValue(row); + if (!key || value === null) continue; + const models: SyncedAvailableModel[] = JSON.parse(value); + for (const m of models) { + if (m.id) map.set(m.id, m); + } + } + return Array.from(map.values()); +} + +/** + * Get all synced available models across all providers. + */ +export async function getAllSyncedAvailableModels(): Promise> { + const db = getDbInstance(); + const rows = db + .prepare("SELECT key, value FROM key_value WHERE namespace = 'syncedAvailableModels'") + .all(); + // Group by providerId (before the colon) + const byProvider = new Map>(); + for (const row of rows) { + const { key, value } = getKeyValue(row); + if (!key || value === null) continue; + const providerId = key.split(":")[0]; + if (!byProvider.has(providerId)) byProvider.set(providerId, new Map()); + const models: SyncedAvailableModel[] = JSON.parse(value); + const map = byProvider.get(providerId)!; + for (const m of models) { + if (m.id) map.set(m.id, m); + } + } + const result: Record = {}; + for (const [providerId, map] of byProvider) { + result[providerId] = Array.from(map.values()); + } + return result; +} + +/** + * Replace the model list for a specific connection. + * Key format: ':' + */ +export async function replaceSyncedAvailableModelsForConnection( + providerId: string, + connectionId: string, + models: SyncedAvailableModel[] +): Promise { + const db = getDbInstance(); + const key = `${providerId}:${connectionId}`; + if (models.length === 0) { + db.prepare("DELETE FROM key_value WHERE namespace = 'syncedAvailableModels' AND key = ?").run(key); + } else { + db.prepare( + "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('syncedAvailableModels', ?, ?)" + ).run(key, JSON.stringify(models)); + } + backupDbFile("pre-write"); + // Return the full unioned list for the provider + return getSyncedAvailableModels(providerId); +} + +/** + * Delete all synced models for a specific connection. + * Returns the remaining unioned list for the provider. + */ +export async function deleteSyncedAvailableModelsForConnection( + providerId: string, + connectionId: string +): Promise { + const db = getDbInstance(); + const key = `${providerId}:${connectionId}`; + db.prepare("DELETE FROM key_value WHERE namespace = 'syncedAvailableModels' AND key = ?").run(key); + backupDbFile("pre-write"); + return getSyncedAvailableModels(providerId); +} + export async function updateCustomModel( providerId: string, modelId: string, diff --git a/src/lib/db/settings.ts b/src/lib/db/settings.ts index 77b65ef4f0..f723748fbd 100644 --- a/src/lib/db/settings.ts +++ b/src/lib/db/settings.ts @@ -248,6 +248,31 @@ export async function resetAllPricing() { return {}; } +// ──────────────── LKGP (Last Known Good Provider) ──────────────── + +export async function getLKGP(comboName: string, modelId: string): Promise { + 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: {} }; diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index 31301a67e7..521416daa5 100644 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -58,9 +58,15 @@ export { getModelPreserveOpenAIDeveloperRole, getModelUpstreamExtraHeaders, getModelIsHidden, + + // Synced Available Models + getSyncedAvailableModels, + getAllSyncedAvailableModels, + replaceSyncedAvailableModelsForConnection, + deleteSyncedAvailableModelsForConnection, } from "./db/models"; -export type { ModelCompatPerProtocol, ModelCompatPatch } from "./db/models"; +export type { ModelCompatPerProtocol, ModelCompatPatch, SyncedAvailableModel } from "./db/models"; export { // Combos @@ -92,6 +98,10 @@ export { updateSettings, isCloudEnabled, + // LKGP (Last Known Good Provider) (#919) + getLKGP, + setLKGP, + // Pricing getPricing, getPricingForModel, diff --git a/src/lib/oauth/providers/claude.ts b/src/lib/oauth/providers/claude.ts index 6f3f3eb560..9be6e75938 100644 --- a/src/lib/oauth/providers/claude.ts +++ b/src/lib/oauth/providers/claude.ts @@ -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, }), }); diff --git a/src/shared/components/ModelSelectModal.tsx b/src/shared/components/ModelSelectModal.tsx index 95bf930435..f4ff8a1579 100644 --- a/src/shared/components/ModelSelectModal.tsx +++ b/src/shared/components/ModelSelectModal.tsx @@ -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] = { diff --git a/src/shared/services/cliRuntime.ts b/src/shared/services/cliRuntime.ts index 29f8306123..86da91a085 100644 --- a/src/shared/services/cliRuntime.ts +++ b/src/shared/services/cliRuntime.ts @@ -660,7 +660,13 @@ const checkRunnable = async ( const minimalEnv: Record = { 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 }; diff --git a/src/shared/utils/apiKey.ts b/src/shared/utils/apiKey.ts index abfa2f9e5e..2832d978b5 100644 --- a/src/shared/utils/apiKey.ts +++ b/src/shared/utils/apiKey.ts @@ -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"); } diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index ca57661076..efc81736ca 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -567,7 +567,9 @@ async function handleSingleModelChat( } // 6. Mark account as quota-exhausted on 429 response - if (result.status === 429) { + // For per-model quota providers (Gemini), a 429 on one model doesn't mean + // the entire account is exhausted — skip connection-wide exhaustion marking. + if (result.status === 429 && provider !== "gemini") { markAccountExhaustedFrom429(credentials.connectionId, provider); } diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index e465a7fd49..bcd9ae88d7 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -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>(); // 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( diff --git a/src/sse/services/auth.ts.orig b/src/sse/services/auth.ts.orig new file mode 100644 index 0000000000..76e5ee1aa9 --- /dev/null +++ b/src/sse/services/auth.ts.orig @@ -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; + +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 = []; + + 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 { + 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>(); + +// 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).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)?.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 +) { + // 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 | 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); +} diff --git a/test-mcp-bundle.js b/test-mcp-bundle.js new file mode 100644 index 0000000000..35608c6875 --- /dev/null +++ b/test-mcp-bundle.js @@ -0,0 +1,3923 @@ +// open-sse/mcp-server/server.ts +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; + +// open-sse/mcp-server/schemas/tools.ts +import { z } from "zod"; +var getHealthInput = z.object({}).describe("No parameters required"); +var getHealthOutput = z.object({ + uptime: z.string(), + version: z.string(), + memoryUsage: z.object({ + heapUsed: z.number(), + heapTotal: z.number(), + }), + circuitBreakers: z.array( + z.object({ + provider: z.string(), + state: z.enum(["CLOSED", "OPEN", "HALF_OPEN"]), + failureCount: z.number(), + lastFailure: z.string().nullable(), + }) + ), + rateLimits: z.array( + z.object({ + provider: z.string(), + rpm: z.number(), + currentUsage: z.number(), + isLimited: z.boolean(), + }) + ), + cacheStats: z + .object({ + hits: z.number(), + misses: z.number(), + hitRate: z.number(), + }) + .optional(), +}); +var getHealthTool = { + name: "omniroute_get_health", + description: + "Returns the current health status of OmniRoute including uptime, memory usage, circuit breaker states for all providers, rate limit status, and cache statistics.", + inputSchema: getHealthInput, + outputSchema: getHealthOutput, + scopes: ["read:health"], + auditLevel: "basic", + phase: 1, + sourceEndpoints: ["/api/monitoring/health", "/api/resilience", "/api/rate-limits"], +}; +var listCombosInput = z.object({ + includeMetrics: z + .boolean() + .optional() + .describe("Include request count, success rate, latency, and cost metrics per combo"), +}); +var listCombosOutput = z.object({ + combos: z.array( + z.object({ + id: z.string(), + name: z.string(), + models: z.array( + z.object({ + provider: z.string(), + model: z.string(), + priority: z.number(), + }) + ), + strategy: z.enum([ + "priority", + "weighted", + "round-robin", + "strict-random", + "random", + "least-used", + "cost-optimized", + "auto", + ]), + enabled: z.boolean(), + metrics: z + .object({ + requestCount: z.number(), + successRate: z.number(), + avgLatencyMs: z.number(), + totalCost: z.number(), + }) + .optional(), + }) + ), +}); +var listCombosTool = { + name: "omniroute_list_combos", + description: + "Lists all configured combos (model chains) with their strategies and optionally includes performance metrics. Combos define how requests are routed across multiple providers.", + inputSchema: listCombosInput, + outputSchema: listCombosOutput, + scopes: ["read:combos"], + auditLevel: "basic", + phase: 1, + sourceEndpoints: ["/api/combos", "/api/combos/metrics"], +}; +var getComboMetricsInput = z.object({ + comboId: z.string().describe("ID of the combo to get metrics for"), +}); +var getComboMetricsOutput = z.object({ + requests: z.number(), + successRate: z.number(), + avgLatency: z.number(), + costTotal: z.number(), + fallbackCount: z.number(), + byProvider: z.array( + z.object({ + provider: z.string(), + requests: z.number(), + successRate: z.number(), + avgLatency: z.number(), + }) + ), +}); +var getComboMetricsTool = { + name: "omniroute_get_combo_metrics", + description: + "Returns detailed performance metrics for a specific combo including request count, success rate, average latency, total cost, and per-provider breakdowns.", + inputSchema: getComboMetricsInput, + outputSchema: getComboMetricsOutput, + scopes: ["read:combos"], + auditLevel: "basic", + phase: 1, + sourceEndpoints: ["/api/combos/metrics"], +}; +var switchComboInput = z.object({ + comboId: z.string().describe("ID of the combo to activate/deactivate"), + active: z.boolean().describe("Whether to enable or disable the combo"), +}); +var switchComboOutput = z.object({ + success: z.boolean(), + combo: z.object({ + id: z.string(), + name: z.string(), + enabled: z.boolean(), + }), +}); +var switchComboTool = { + name: "omniroute_switch_combo", + description: + "Activates or deactivates a combo. When deactivated, requests will not be routed through this combo. Use to toggle between different routing strategies.", + inputSchema: switchComboInput, + outputSchema: switchComboOutput, + scopes: ["write:combos"], + auditLevel: "full", + phase: 1, + sourceEndpoints: ["/api/combos"], +}; +var checkQuotaInput = z.object({ + provider: z + .string() + .optional() + .describe( + "Filter by provider name (e.g., 'claude', 'gemini'). If omitted, returns all providers." + ), + connectionId: z.string().optional().describe("Filter by specific connection ID"), +}); +var checkQuotaOutput = z.object({ + providers: z.array( + z.object({ + name: z.string(), + provider: z.string(), + connectionId: z.string(), + quotaUsed: z.number(), + quotaTotal: z.number().nullable(), + percentRemaining: z.number(), + resetAt: z.string().nullable(), + tokenStatus: z.enum(["valid", "expiring", "expired", "refreshing"]), + }) + ), + meta: z + .object({ + generatedAt: z.string(), + filters: z.object({ + provider: z.string().nullable(), + connectionId: z.string().nullable(), + }), + totalProviders: z.number(), + }) + .optional(), +}); +var checkQuotaTool = { + name: "omniroute_check_quota", + description: + "Checks the remaining API quota for one or all providers. Returns quota used/total, percentage remaining, reset time, and token health status.", + inputSchema: checkQuotaInput, + outputSchema: checkQuotaOutput, + scopes: ["read:quota"], + auditLevel: "basic", + phase: 1, + sourceEndpoints: ["/api/usage/quota", "/api/token-health", "/api/rate-limits"], +}; +var routeRequestInput = z.object({ + model: z.string().describe("Model identifier (e.g., 'claude-sonnet-4', 'gpt-4o')"), + messages: z + .array( + z.object({ + role: z.string(), + content: z.string(), + }) + ) + .describe("Chat messages in OpenAI format"), + combo: z.string().optional().describe("Specific combo to route through"), + budget: z.number().optional().describe("Maximum cost in USD for this request"), + role: z + .enum(["coding", "review", "planning", "analysis"]) + .optional() + .describe("Task role hint for intelligent routing"), + stream: z.boolean().optional().default(false).describe("Whether to stream the response"), +}); +var routeRequestOutput = z.object({ + response: z.object({ + content: z.string(), + model: z.string(), + tokens: z.object({ + prompt: z.number(), + completion: z.number(), + }), + }), + routing: z.object({ + provider: z.string(), + combo: z.string().nullable(), + fallbacksTriggered: z.number(), + cost: z.number(), + latencyMs: z.number(), + routingExplanation: z.string(), + }), +}); +var routeRequestTool = { + name: "omniroute_route_request", + description: + "Sends a chat completion request through OmniRoute's intelligent routing pipeline. Supports combo selection, budget limits, and task role hints for optimal provider matching.", + inputSchema: routeRequestInput, + outputSchema: routeRequestOutput, + scopes: ["execute:completions"], + auditLevel: "full", + phase: 1, + sourceEndpoints: ["/v1/chat/completions", "/v1/responses"], +}; +var costReportInput = z.object({ + period: z + .enum(["session", "day", "week", "month"]) + .optional() + .default("session") + .describe("Time period for the cost report"), +}); +var costReportOutput = z.object({ + period: z.string(), + totalCost: z.number(), + requestCount: z.number(), + tokenCount: z.object({ + prompt: z.number(), + completion: z.number(), + }), + byProvider: z.array( + z.object({ + name: z.string(), + cost: z.number(), + requests: z.number(), + }) + ), + byModel: z.array( + z.object({ + model: z.string(), + cost: z.number(), + requests: z.number(), + }) + ), + budget: z.object({ + limit: z.number().nullable(), + remaining: z.number().nullable(), + }), +}); +var costReportTool = { + name: "omniroute_cost_report", + description: + "Generates a cost report for the specified period showing total cost, request count, token usage, and breakdowns by provider and model. Also shows budget status if configured.", + inputSchema: costReportInput, + outputSchema: costReportOutput, + scopes: ["read:usage"], + auditLevel: "basic", + phase: 1, + sourceEndpoints: ["/api/usage/analytics", "/api/usage/budget"], +}; +var listModelsCatalogInput = z.object({ + provider: z.string().optional().describe("Filter by provider name"), + capability: z + .enum(["chat", "embedding", "image", "audio", "video", "rerank", "moderation"]) + .optional() + .describe("Filter by model capability"), +}); +var listModelsCatalogOutput = z.object({ + models: z.array( + z.object({ + id: z.string(), + provider: z.string(), + capabilities: z.array(z.string()), + status: z.enum(["available", "degraded", "unavailable"]), + pricing: z + .object({ + inputPerMillion: z.number().nullable(), + outputPerMillion: z.number().nullable(), + }) + .optional(), + }) + ), +}); +var listModelsCatalogTool = { + name: "omniroute_list_models_catalog", + description: + "Lists all available AI models across all providers with their capabilities, current status, and pricing information.", + inputSchema: listModelsCatalogInput, + outputSchema: listModelsCatalogOutput, + scopes: ["read:models"], + auditLevel: "none", + phase: 1, + sourceEndpoints: ["/api/models/catalog", "/v1/models"], +}; +var webSearchInput = z.object({ + query: z + .string() + .min(1, "Query is required") + .max(1e3, "Query must be 1000 characters or fewer") + .describe("The search query string"), + max_results: z + .number() + .int() + .min(1) + .max(20) + .default(5) + .describe("Maximum number of search results to return"), + search_type: z.enum(["web", "news"]).default("web").describe("Type of search to perform"), + provider: z + .string() + .optional() + .describe("Specific search provider to use (serper, brave, perplexity, exa, tavily)"), +}); +var webSearchOutput = z.object({ + id: z.string(), + provider: z.string(), + query: z.string(), + results: z.array( + z.object({ + title: z.string(), + url: z.string(), + display_url: z.string().optional(), + snippet: z.string(), + position: z.number().int().positive(), + }) + ), + cached: z.boolean(), + usage: z.object({ + queries_used: z.number().int().min(0), + search_cost_usd: z.number().min(0), + }), +}); +var webSearchTool = { + name: "omniroute_web_search", + description: + "Performs a web search using OmniRoute's search gateway. Supports multiple providers (Serper, Brave, Perplexity, Exa, Tavily) with automatic failover. Returns search results with titles, URLs, snippets, and position data.", + inputSchema: webSearchInput, + outputSchema: webSearchOutput, + scopes: ["execute:search"], + auditLevel: "basic", + phase: 1, + sourceEndpoints: ["/v1/search"], +}; +var simulateRouteInput = z.object({ + model: z.string().describe("Target model for simulation"), + promptTokenEstimate: z.number().describe("Estimated prompt token count"), + combo: z.string().optional().describe("Specific combo to simulate (default: active combo)"), +}); +var simulateRouteOutput = z.object({ + simulatedPath: z.array( + z.object({ + provider: z.string(), + model: z.string(), + probability: z.number(), + estimatedCost: z.number(), + healthStatus: z.enum(["CLOSED", "OPEN", "HALF_OPEN"]), + quotaAvailable: z.number(), + }) + ), + fallbackTree: z.object({ + primary: z.string(), + fallbacks: z.array(z.string()), + worstCaseCost: z.number(), + bestCaseCost: z.number(), + }), +}); +var simulateRouteTool = { + name: "omniroute_simulate_route", + description: + "Simulates (dry-run) the routing path a request would take without actually executing it. Shows the fallback tree, provider probabilities, estimated costs, and health status.", + inputSchema: simulateRouteInput, + outputSchema: simulateRouteOutput, + scopes: ["read:health", "read:combos"], + auditLevel: "basic", + phase: 2, + sourceEndpoints: ["/api/combos", "/api/monitoring/health", "/api/resilience"], +}; +var setBudgetGuardInput = z.object({ + maxCost: z.number().describe("Maximum cost in USD for this session"), + action: z.enum(["degrade", "block", "alert"]).describe("Action when budget is exceeded"), + degradeToTier: z + .enum(["cheap", "free"]) + .optional() + .describe("If action=degrade, which tier to fall back to"), +}); +var setBudgetGuardOutput = z.object({ + sessionId: z.string(), + budgetTotal: z.number(), + budgetSpent: z.number(), + budgetRemaining: z.number(), + action: z.string(), + status: z.enum(["active", "warning", "exceeded"]), +}); +var setBudgetGuardTool = { + name: "omniroute_set_budget_guard", + description: + "Sets a budget guard that limits spending for the current session. When the budget is reached, it can degrade to cheaper models, block requests, or send alerts.", + inputSchema: setBudgetGuardInput, + outputSchema: setBudgetGuardOutput, + scopes: ["write:budget"], + auditLevel: "full", + phase: 2, + sourceEndpoints: ["/api/usage/budget"], +}; +var setRoutingStrategyInput = z.object({ + comboId: z.string().describe("Combo ID or name to update"), + strategy: z + .enum([ + "priority", + "weighted", + "round-robin", + "strict-random", + "random", + "least-used", + "cost-optimized", + "auto", + ]) + .describe("Routing strategy to apply"), + autoRoutingStrategy: z + .enum(["rules", "cost", "eco", "latency", "fast"]) + .optional() + .describe("Optional strategy used by auto mode (only used when strategy='auto')"), +}); +var setRoutingStrategyOutput = z.object({ + success: z.boolean(), + combo: z.object({ + id: z.string(), + name: z.string(), + strategy: z.string(), + autoRoutingStrategy: z.string().nullable(), + }), +}); +var setRoutingStrategyTool = { + name: "omniroute_set_routing_strategy", + description: + "Updates a combo routing strategy (priority/weighted/auto/etc.) at runtime. Supports selecting the sub-strategy used by auto mode (rules/cost/latency).", + inputSchema: setRoutingStrategyInput, + outputSchema: setRoutingStrategyOutput, + scopes: ["write:combos"], + auditLevel: "full", + phase: 2, + sourceEndpoints: ["/api/combos", "/api/combos/{id}"], +}; +var setResilienceProfileInput = z.object({ + profile: z + .enum(["aggressive", "balanced", "conservative"]) + .describe("Resilience profile to apply"), +}); +var setResilienceProfileOutput = z.object({ + applied: z.boolean(), + settings: z.object({ + circuitBreakerThreshold: z.number(), + retryCount: z.number(), + timeoutMs: z.number(), + fallbackDepth: z.number(), + }), +}); +var setResilienceProfileTool = { + name: "omniroute_set_resilience_profile", + description: + "Applies a resilience profile that adjusts circuit breaker thresholds, retry counts, timeouts, and fallback depth. 'aggressive' = fast fail, 'conservative' = max retries.", + inputSchema: setResilienceProfileInput, + outputSchema: setResilienceProfileOutput, + scopes: ["write:resilience"], + auditLevel: "full", + phase: 2, + sourceEndpoints: ["/api/resilience"], +}; +var testComboInput = z.object({ + comboId: z.string().describe("ID of the combo to test"), + testPrompt: z.string().max(500).describe("Short test prompt (max 500 chars)"), +}); +var testComboOutput = z.object({ + results: z.array( + z.object({ + provider: z.string(), + model: z.string(), + success: z.boolean(), + latencyMs: z.number(), + cost: z.number(), + tokenCount: z.number(), + error: z.string().optional(), + }) + ), + summary: z.object({ + totalProviders: z.number(), + successful: z.number(), + fastestProvider: z.string(), + cheapestProvider: z.string(), + }), +}); +var testComboTool = { + name: "omniroute_test_combo", + description: + "Tests a combo by sending a short test prompt to each provider in the combo and reporting individual results including latency, cost, and success status.", + inputSchema: testComboInput, + outputSchema: testComboOutput, + scopes: ["execute:completions", "read:combos"], + auditLevel: "full", + phase: 2, + sourceEndpoints: ["/api/combos/test", "/v1/chat/completions"], +}; +var getProviderMetricsInput = z.object({ + provider: z.string().describe("Provider name (e.g., 'claude', 'gemini-cli', 'codex')"), +}); +var getProviderMetricsOutput = z.object({ + provider: z.string(), + successRate: z.number(), + requestCount: z.number(), + avgLatencyMs: z.number(), + p50LatencyMs: z.number(), + p95LatencyMs: z.number(), + p99LatencyMs: z.number(), + errorRate: z.number(), + lastError: z + .object({ + message: z.string(), + timestamp: z.string(), + }) + .nullable(), + circuitBreakerState: z.enum(["CLOSED", "OPEN", "HALF_OPEN"]), + quotaInfo: z.object({ + used: z.number(), + total: z.number().nullable(), + resetAt: z.string().nullable(), + }), +}); +var getProviderMetricsTool = { + name: "omniroute_get_provider_metrics", + description: + "Returns detailed performance metrics for a specific provider including success/error rates, latency percentiles (p50/p95/p99), circuit breaker state, and quota information.", + inputSchema: getProviderMetricsInput, + outputSchema: getProviderMetricsOutput, + scopes: ["read:health"], + auditLevel: "basic", + phase: 2, + sourceEndpoints: ["/api/provider-metrics", "/api/resilience"], +}; +var bestComboForTaskInput = z.object({ + taskType: z + .enum(["coding", "review", "planning", "analysis", "debugging", "documentation"]) + .describe("Type of task to find the best combo for"), + budgetConstraint: z.number().optional().describe("Maximum cost in USD"), + latencyConstraint: z.number().optional().describe("Maximum acceptable latency in ms"), +}); +var bestComboForTaskOutput = z.object({ + recommendedCombo: z.object({ + id: z.string(), + name: z.string(), + reason: z.string(), + }), + alternatives: z.array( + z.object({ + id: z.string(), + name: z.string(), + tradeoff: z.string(), + }) + ), + freeAlternative: z + .object({ + id: z.string(), + name: z.string(), + }) + .nullable(), +}); +var bestComboForTaskTool = { + name: "omniroute_best_combo_for_task", + description: + "Recommends the best combo for a given task type (coding, review, planning, etc.) considering budget and latency constraints. Also suggests alternatives and free options.", + inputSchema: bestComboForTaskInput, + outputSchema: bestComboForTaskOutput, + scopes: ["read:combos", "read:health"], + auditLevel: "basic", + phase: 2, + sourceEndpoints: ["/api/combos", "/api/combos/metrics", "/api/monitoring/health"], +}; +var explainRouteInput = z.object({ + requestId: z.string().describe("Request ID from the X-Request-Id header"), +}); +var explainRouteOutput = z.object({ + requestId: z.string(), + decision: z.object({ + comboUsed: z.string(), + providerSelected: z.string(), + modelUsed: z.string(), + score: z.number(), + factors: z.array( + z.object({ + name: z.string(), + value: z.number(), + weight: z.number(), + contribution: z.number(), + }) + ), + fallbacksTriggered: z.array( + z.object({ + provider: z.string(), + reason: z.string(), + }) + ), + costActual: z.number(), + latencyActual: z.number(), + }), +}); +var explainRouteTool = { + name: "omniroute_explain_route", + description: + "Explains why a specific request was routed to a particular provider. Shows the scoring factors, weights, fallbacks triggered, actual cost, and latency.", + inputSchema: explainRouteInput, + outputSchema: explainRouteOutput, + scopes: ["read:health", "read:usage"], + auditLevel: "basic", + phase: 2, + sourceEndpoints: [], +}; +var getSessionSnapshotInput = z.object({}).describe("No parameters required"); +var getSessionSnapshotOutput = z.object({ + sessionStart: z.string(), + duration: z.string(), + requestCount: z.number(), + costTotal: z.number(), + tokenCount: z.object({ + prompt: z.number(), + completion: z.number(), + }), + topModels: z.array( + z.object({ + model: z.string(), + count: z.number(), + }) + ), + topProviders: z.array( + z.object({ + provider: z.string(), + count: z.number(), + }) + ), + errors: z.number(), + fallbacks: z.number(), + budgetGuard: z + .object({ + active: z.boolean(), + remaining: z.number(), + }) + .nullable(), +}); +var getSessionSnapshotTool = { + name: "omniroute_get_session_snapshot", + description: + "Returns a snapshot of the current working session including duration, request count, total cost, top models/providers used, error count, and budget guard status.", + inputSchema: getSessionSnapshotInput, + outputSchema: getSessionSnapshotOutput, + scopes: ["read:usage"], + auditLevel: "none", + phase: 2, + sourceEndpoints: ["/api/usage/analytics", "/api/telemetry/summary"], +}; +var syncPricingInput = z.object({ + sources: z + .array(z.string()) + .optional() + .describe("External pricing sources to sync from (default: ['litellm'])"), + dryRun: z + .boolean() + .optional() + .describe("If true, preview sync results without saving to database"), +}); +var syncPricingOutput = z.object({ + success: z.boolean(), + modelCount: z.number(), + providerCount: z.number(), + source: z.string(), + dryRun: z.boolean(), + error: z.string().optional(), + warnings: z.array(z.string()).optional(), + data: z.record(z.string(), z.record(z.string(), z.unknown())).optional(), +}); +var syncPricingTool = { + name: "omniroute_sync_pricing", + description: + "Syncs pricing data from external sources (LiteLLM) into OmniRoute. Synced pricing fills gaps not covered by hardcoded defaults without overwriting user-set prices. Use dryRun=true to preview.", + inputSchema: syncPricingInput, + outputSchema: syncPricingOutput, + scopes: ["pricing:write"], + auditLevel: "full", + phase: 2, + sourceEndpoints: ["/api/pricing/sync"], +}; +var cacheStatsInput = z.object({}).describe("No parameters required"); +var cacheStatsOutput = z.object({ + semanticCache: z.object({ + memoryEntries: z.number(), + dbEntries: z.number(), + hits: z.number(), + misses: z.number(), + hitRate: z.string(), + tokensSaved: z.number(), + }), + promptCache: z + .object({ + totalRequests: z.number(), + requestsWithCacheControl: z.number(), + totalCachedTokens: z.number(), + totalCacheCreationTokens: z.number(), + estimatedCostSaved: z.number(), + }) + .nullable(), + idempotency: z.object({ + activeKeys: z.number(), + windowMs: z.number(), + }), +}); +var cacheStatsTool = { + name: "omniroute_cache_stats", + description: + "Returns cache statistics including semantic cache hit rate, prompt cache metrics by provider, and idempotency layer stats.", + inputSchema: cacheStatsInput, + outputSchema: cacheStatsOutput, + scopes: ["read:cache"], + auditLevel: "basic", + phase: 2, + sourceEndpoints: ["/api/cache"], +}; +var cacheFlushInput = z.object({ + signature: z.string().optional().describe("Specific cache signature to invalidate"), + model: z.string().optional().describe("Invalidate all entries for a specific model"), +}); +var cacheFlushOutput = z.object({ + ok: z.boolean(), + invalidated: z.number().optional(), + scope: z.string().optional(), +}); +var cacheFlushTool = { + name: "omniroute_cache_flush", + description: + "Flush cache entries. Provide signature to invalidate a single entry, model to invalidate all entries for a model, or omit both to clear all.", + inputSchema: cacheFlushInput, + outputSchema: cacheFlushOutput, + scopes: ["write:cache"], + auditLevel: "full", + phase: 2, + sourceEndpoints: ["/api/cache"], +}; +var MCP_TOOLS = [ + getHealthTool, + listCombosTool, + getComboMetricsTool, + switchComboTool, + checkQuotaTool, + routeRequestTool, + costReportTool, + listModelsCatalogTool, + webSearchTool, + simulateRouteTool, + setBudgetGuardTool, + setRoutingStrategyTool, + setResilienceProfileTool, + testComboTool, + getProviderMetricsTool, + bestComboForTaskTool, + explainRouteTool, + getSessionSnapshotTool, + syncPricingTool, + cacheStatsTool, + cacheFlushTool, +]; +var MCP_ESSENTIAL_TOOLS = MCP_TOOLS.filter((t) => t.phase === 1); +var MCP_ADVANCED_TOOLS = MCP_TOOLS.filter((t) => t.phase === 2); +var MCP_TOOL_MAP = Object.fromEntries(MCP_TOOLS.map((t) => [t.name, t])); + +// open-sse/mcp-server/runtimeHeartbeat.ts +import { promises as fs } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +var HEARTBEAT_FILE = "mcp-heartbeat.json"; +var RUNTIME_DIR = "runtime"; +var DEFAULT_INTERVAL_MS = 5e3; +function resolveDataDir() { + const configured = process.env.DATA_DIR; + if (typeof configured === "string" && configured.trim().length > 0) { + return configured.trim(); + } + return join(homedir(), ".omniroute"); +} +function resolveMcpHeartbeatPath() { + return join(resolveDataDir(), RUNTIME_DIR, HEARTBEAT_FILE); +} +async function writeHeartbeat(snapshot) { + const heartbeatPath = resolveMcpHeartbeatPath(); + const runtimeDir = join(resolveDataDir(), RUNTIME_DIR); + await fs.mkdir(runtimeDir, { recursive: true }); + await fs.writeFile(heartbeatPath, JSON.stringify(snapshot, null, 2), "utf-8"); +} +function startMcpHeartbeat(config) { + const startedAt = /* @__PURE__ */ new Date().toISOString(); + let timer = null; + let stopped = false; + const intervalMs = + typeof config.intervalMs === "number" && config.intervalMs > 0 + ? config.intervalMs + : DEFAULT_INTERVAL_MS; + const tick = async () => { + if (stopped) return; + const snapshot = { + pid: process.pid, + startedAt, + lastHeartbeatAt: /* @__PURE__ */ new Date().toISOString(), + version: config.version, + transport: "stdio", + scopesEnforced: config.scopesEnforced, + allowedScopes: [...config.allowedScopes], + toolCount: config.toolCount, + }; + try { + await writeHeartbeat(snapshot); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error("[MCP Heartbeat] Failed to write heartbeat:", message); + } + }; + void tick(); + timer = setInterval(() => { + void tick(); + }, intervalMs); + return () => { + if (stopped) return; + stopped = true; + if (timer) { + clearInterval(timer); + timer = null; + } + void tick(); + }; +} + +// open-sse/mcp-server/schemas/audit.ts +async function hashInput(input) { + const data = JSON.stringify(input); + const encoder = new TextEncoder(); + const hashBuffer = await crypto.subtle.digest("SHA-256", encoder.encode(data)); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); +} +function summarizeOutput(output, maxLength = 200) { + if (output === null || output === void 0) return "(null)"; + const str = typeof output === "string" ? output : JSON.stringify(output); + if (str.length <= maxLength) return str; + return str.slice(0, maxLength) + "\u2026"; +} + +// open-sse/mcp-server/audit.ts +var db = null; +async function getDb() { + if (db) return db; + try { + const { homedir: homedir2 } = await import("node:os"); + const { join: join2 } = await import("node:path"); + const { existsSync } = await import("node:fs"); + const dbPath = process.env.DATA_DIR + ? join2(process.env.DATA_DIR, "storage.sqlite") + : join2(homedir2(), ".omniroute", "storage.sqlite"); + if (!existsSync(dbPath)) { + console.error(`[MCP Audit] Database not found at ${dbPath} \u2014 audit logging disabled`); + return null; + } + const Database2 = (await import("better-sqlite3")).default; + db = new Database2(dbPath); + return db; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error("[MCP Audit] Failed to connect to database:", message); + return null; + } +} +async function logToolCall(toolName, input, output, durationMs, success, errorCode) { + try { + const database = await getDb(); + if (!database) return; + const inputHash = await hashInput(input); + const outputSummary = summarizeOutput(output); + const apiKeyId = process.env.OMNIROUTE_API_KEY_ID || null; + database + .prepare( + `INSERT INTO mcp_tool_audit (tool_name, input_hash, output_summary, duration_ms, api_key_id, success, error_code) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ) + .run( + toolName, + inputHash, + outputSummary, + durationMs, + apiKeyId, + success ? 1 : 0, + errorCode || null + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error("[MCP Audit] Failed to log:", message); + } +} + +// open-sse/mcp-server/scopeEnforcement.ts +function normalizeScopeList(raw) { + if (!Array.isArray(raw)) return []; + const normalized = raw + .filter((value) => typeof value === "string") + .map((value) => value.trim()) + .filter(Boolean); + return Array.from(new Set(normalized)); +} +function extractMetaScopeList(meta) { + if (!meta || typeof meta !== "object") return []; + const metaRecord = meta; + const direct = normalizeScopeList(metaRecord.scopes); + if (direct.length > 0) return direct; + const auth = metaRecord.auth; + if (auth && typeof auth === "object") { + const authScopes = normalizeScopeList(auth.scopes); + if (authScopes.length > 0) return authScopes; + } + const omni = metaRecord.omniroute; + if (omni && typeof omni === "object") { + const omniScopes = normalizeScopeList(omni.scopes); + if (omniScopes.length > 0) return omniScopes; + } + return []; +} +function scopeMatches(grantedScope, requiredScope) { + if (grantedScope === "*" || grantedScope === requiredScope) { + return true; + } + if (grantedScope.endsWith("*")) { + const prefix = grantedScope.slice(0, -1); + return requiredScope.startsWith(prefix); + } + return false; +} +function resolveCallerScopeContext(extra, fallbackScopes = []) { + const callerId = + (typeof extra?.authInfo?.clientId === "string" && extra.authInfo.clientId.trim()) || + (typeof extra?.sessionId === "string" && extra.sessionId.trim()) || + "anonymous"; + const authScopes = normalizeScopeList(extra?.authInfo?.scopes); + if (authScopes.length > 0) { + return { callerId, scopes: authScopes, source: "authInfo" }; + } + const metaScopes = extractMetaScopeList(extra?._meta); + if (metaScopes.length > 0) { + return { callerId, scopes: metaScopes, source: "meta" }; + } + const fallback = normalizeScopeList(fallbackScopes); + if (fallback.length > 0) { + return { callerId, scopes: fallback, source: "env" }; + } + return { callerId, scopes: [], source: "none" }; +} +function evaluateToolScopes(toolName, callerScopes, enforceScopes) { + const toolDef = MCP_TOOL_MAP[toolName]; + if (!toolDef) { + return { + allowed: false, + required: [], + provided: Array.from(callerScopes), + missing: [], + reason: "tool_definition_missing", + }; + } + const required = Array.isArray(toolDef.scopes) ? Array.from(toolDef.scopes) : []; + const provided = normalizeScopeList(callerScopes); + if (!enforceScopes || required.length === 0) { + return { allowed: true, required, provided, missing: [] }; + } + const missing = required.filter( + (requiredScope) => !provided.some((grantedScope) => scopeMatches(grantedScope, requiredScope)) + ); + return { + allowed: missing.length === 0, + required, + provided, + missing, + reason: missing.length > 0 ? "missing_scopes" : void 0, + }; +} + +// src/shared/contracts/quota.ts +var TOKEN_STATUS_VALUES = ["valid", "expiring", "expired", "refreshing"]; +function toNumber(value) { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim() !== "") { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + return null; +} +function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)); +} +function normalizeTokenStatus(value) { + if (typeof value === "string" && TOKEN_STATUS_VALUES.includes(value)) { + return value; + } + return "valid"; +} +function sanitizeQuotaProvider(input) { + const source = input && typeof input === "object" ? input : {}; + const provider = typeof source.provider === "string" ? source.provider : "unknown"; + const name = typeof source.name === "string" && source.name.trim() ? source.name : provider; + const connectionId = + typeof source.connectionId === "string" && source.connectionId.trim() + ? source.connectionId + : "unknown"; + const quotaTotalRaw = toNumber(source.quotaTotal); + const quotaTotal = quotaTotalRaw !== null && quotaTotalRaw >= 0 ? quotaTotalRaw : null; + const quotaUsedRaw = toNumber(source.quotaUsed) ?? 0; + const quotaUsed = + quotaTotal !== null ? clamp(quotaUsedRaw, 0, quotaTotal) : Math.max(0, quotaUsedRaw); + let percentRemainingRaw = toNumber(source.percentRemaining); + if (percentRemainingRaw === null) { + if (quotaTotal && quotaTotal > 0) { + percentRemainingRaw = ((quotaTotal - quotaUsed) / quotaTotal) * 100; + } else { + percentRemainingRaw = 100; + } + } + const percentRemaining = clamp(percentRemainingRaw, 0, 100); + const resetAt = + typeof source.resetAt === "string" && source.resetAt.trim() ? source.resetAt : null; + return { + name, + provider, + connectionId, + quotaUsed, + quotaTotal, + percentRemaining, + resetAt, + tokenStatus: normalizeTokenStatus(source.tokenStatus), + }; +} +function normalizeQuotaResponse(raw, filters = {}) { + const source = raw && typeof raw === "object" ? raw : {}; + const providersRaw = Array.isArray(source.providers) + ? source.providers + : Array.isArray(raw) + ? raw + : []; + const providers = providersRaw.map((entry) => sanitizeQuotaProvider(entry)); + const sourceMeta = source.meta && typeof source.meta === "object" ? source.meta : {}; + const sourceFilters = + sourceMeta.filters && typeof sourceMeta.filters === "object" ? sourceMeta.filters : {}; + const providerFilter = + filters.provider ?? + (typeof sourceFilters.provider === "string" && sourceFilters.provider.trim() + ? sourceFilters.provider + : null); + const connectionFilter = + filters.connectionId ?? + (typeof sourceFilters.connectionId === "string" && sourceFilters.connectionId.trim() + ? sourceFilters.connectionId + : null); + const generatedAt = + typeof sourceMeta.generatedAt === "string" && sourceMeta.generatedAt.trim() + ? sourceMeta.generatedAt + : /* @__PURE__ */ new Date().toISOString(); + return { + providers, + meta: { + generatedAt, + filters: { + provider: providerFilter || null, + connectionId: connectionFilter || null, + }, + totalProviders: providers.length, + }, + }; +} + +// open-sse/mcp-server/tools/advancedTools.ts +var OMNIROUTE_BASE_URL = process.env.OMNIROUTE_BASE_URL || "http://localhost:20128"; +var OMNIROUTE_API_KEY = process.env.OMNIROUTE_API_KEY || ""; +async function apiFetch(path4, options = {}) { + const url = `${OMNIROUTE_BASE_URL}${path4}`; + const headers = { + "Content-Type": "application/json", + ...(OMNIROUTE_API_KEY ? { Authorization: `Bearer ${OMNIROUTE_API_KEY}` } : {}), + ...(options.headers || {}), + }; + const response = await fetch(url, { ...options, headers, signal: AbortSignal.timeout(3e4) }); + if (!response.ok) { + const text = await response.text().catch(() => "Unknown error"); + throw new Error(`API [${response.status}]: ${text}`); + } + return response.json(); +} +function isRecord(value) { + return !!value && typeof value === "object" && !Array.isArray(value); +} +function toRecord(value) { + return isRecord(value) ? value : {}; +} +function toArrayOfRecords(value) { + return Array.isArray(value) ? value.filter(isRecord) : []; +} +function toString(value, fallback = "") { + return typeof value === "string" ? value : fallback; +} +function toNumber2(value, fallback = 0) { + const parsed = + typeof value === "number" + ? value + : typeof value === "string" && value.trim().length > 0 + ? Number(value) + : Number.NaN; + return Number.isFinite(parsed) ? parsed : fallback; +} +function getComboModels(combo) { + const directModels = toArrayOfRecords(combo.models); + const nestedModels = toArrayOfRecords(toRecord(combo.data).models); + const sourceModels = directModels.length > 0 ? directModels : nestedModels; + return sourceModels.map((model) => ({ + provider: toString(model.provider, "unknown"), + model: toString(model.model, ""), + inputCostPer1M: toNumber2(model.inputCostPer1M, 3), + })); +} +function normalizeCombosResponse(raw) { + if (Array.isArray(raw)) return raw.filter(isRecord); + const source = toRecord(raw); + return Array.isArray(source.combos) ? source.combos.filter(isRecord) : []; +} +var activeBudgetGuard = null; +var RESILIENCE_PROFILES = { + aggressive: { + profiles: { + oauth: { + transientCooldown: 3e3, + rateLimitCooldown: 3e4, + maxBackoffLevel: 4, + circuitBreakerThreshold: 2, + circuitBreakerReset: 3e4, + }, + apikey: { + transientCooldown: 2e3, + rateLimitCooldown: 0, + maxBackoffLevel: 3, + circuitBreakerThreshold: 3, + circuitBreakerReset: 15e3, + }, + }, + defaults: { + requestsPerMinute: 180, + minTimeBetweenRequests: 100, + concurrentRequests: 16, + }, + }, + balanced: { + profiles: { + oauth: { + transientCooldown: 5e3, + rateLimitCooldown: 6e4, + maxBackoffLevel: 8, + circuitBreakerThreshold: 3, + circuitBreakerReset: 6e4, + }, + apikey: { + transientCooldown: 3e3, + rateLimitCooldown: 0, + maxBackoffLevel: 5, + circuitBreakerThreshold: 5, + circuitBreakerReset: 3e4, + }, + }, + defaults: { + requestsPerMinute: 100, + minTimeBetweenRequests: 200, + concurrentRequests: 10, + }, + }, + conservative: { + profiles: { + oauth: { + transientCooldown: 8e3, + rateLimitCooldown: 12e4, + maxBackoffLevel: 10, + circuitBreakerThreshold: 8, + circuitBreakerReset: 12e4, + }, + apikey: { + transientCooldown: 5e3, + rateLimitCooldown: 3e4, + maxBackoffLevel: 8, + circuitBreakerThreshold: 8, + circuitBreakerReset: 6e4, + }, + }, + defaults: { + requestsPerMinute: 60, + minTimeBetweenRequests: 350, + concurrentRequests: 6, + }, + }, +}; +var TASK_FITNESS = { + coding: { preferred: ["claude", "deepseek", "codex"], traits: ["fast", "code-optimized"] }, + review: { preferred: ["claude", "gemini", "openai"], traits: ["analytical", "thorough"] }, + planning: { preferred: ["gemini", "claude", "openai"], traits: ["reasoning", "structured"] }, + analysis: { preferred: ["gemini", "claude"], traits: ["deep-reasoning", "large-context"] }, + debugging: { preferred: ["claude", "deepseek", "codex"], traits: ["code-aware", "fast"] }, + documentation: { preferred: ["gemini", "claude", "openai"], traits: ["clear", "structured"] }, +}; +async function handleSimulateRoute(args) { + const start = Date.now(); + try { + const [combosRaw, healthRaw, quotaRaw] = await Promise.allSettled([ + apiFetch("/api/combos"), + apiFetch("/api/monitoring/health"), + apiFetch("/api/usage/quota"), + ]); + const combos = combosRaw.status === "fulfilled" ? normalizeCombosResponse(combosRaw.value) : []; + const health = healthRaw.status === "fulfilled" ? toRecord(healthRaw.value) : {}; + const quota = + quotaRaw.status === "fulfilled" + ? normalizeQuotaResponse(quotaRaw.value) + : normalizeQuotaResponse({}); + const targetCombo = args.combo + ? combos.find( + (combo) => toString(combo.id) === args.combo || toString(combo.name) === args.combo + ) + : combos.find((combo) => combo.enabled !== false); + if (!targetCombo) { + return { + content: [{ type: "text", text: JSON.stringify({ error: "No matching combo found" }) }], + isError: true, + }; + } + const models = getComboModels(targetCombo); + const breakers = toArrayOfRecords(health.circuitBreakers); + const providers = quota.providers; + const simulatedPath = models.map((model, idx) => { + const cb = breakers.find((breaker) => toString(breaker.provider) === model.provider); + const q = providers.find((providerEntry) => providerEntry.provider === model.provider); + const estimatedCost = (args.promptTokenEstimate / 1e6) * model.inputCostPer1M; + return { + provider: model.provider, + model: model.model || args.model, + probability: idx === 0 ? 0.85 : 0.15 / Math.max(models.length - 1, 1), + estimatedCost: Math.round(estimatedCost * 1e4) / 1e4, + healthStatus: toString(cb?.state, "CLOSED"), + quotaAvailable: q?.percentRemaining ?? 100, + }; + }); + const costs = simulatedPath.map((pathEntry) => pathEntry.estimatedCost); + const result = { + simulatedPath, + fallbackTree: { + primary: simulatedPath[0]?.provider || "unknown", + fallbacks: simulatedPath.slice(1).map((pathEntry) => pathEntry.provider), + worstCaseCost: Math.max(...costs, 0), + bestCaseCost: Math.min(...costs, 0), + }, + }; + await logToolCall("omniroute_simulate_route", args, result, Date.now() - start, true); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + await logToolCall("omniroute_simulate_route", args, null, Date.now() - start, false, msg); + return { content: [{ type: "text", text: `Error: ${msg}` }], isError: true }; + } +} +async function handleSetBudgetGuard(args) { + const start = Date.now(); + try { + let spent = 0; + try { + const analytics = toRecord(await apiFetch("/api/usage/analytics?period=session")); + spent = toNumber2(analytics.totalCost, 0); + } catch {} + activeBudgetGuard = { + sessionId: `budget_${Date.now()}`, + maxCost: args.maxCost, + action: args.action, + degradeToTier: args.degradeToTier, + spent, + createdAt: /* @__PURE__ */ new Date().toISOString(), + }; + const remaining = Math.max(0, args.maxCost - spent); + const result = { + sessionId: activeBudgetGuard.sessionId, + budgetTotal: args.maxCost, + budgetSpent: Math.round(spent * 1e4) / 1e4, + budgetRemaining: Math.round(remaining * 1e4) / 1e4, + action: args.action, + status: remaining <= 0 ? "exceeded" : remaining < args.maxCost * 0.2 ? "warning" : "active", + }; + await logToolCall( + "omniroute_set_budget_guard", + { maxCost: args.maxCost, action: args.action }, + result, + Date.now() - start, + true + ); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + await logToolCall("omniroute_set_budget_guard", args, null, Date.now() - start, false, msg); + return { content: [{ type: "text", text: `Error: ${msg}` }], isError: true }; + } +} +async function handleSetRoutingStrategy(args) { + const start = Date.now(); + try { + const combos = normalizeCombosResponse(await apiFetch("/api/combos")); + const combo = combos.find( + (comboEntry) => + toString(comboEntry.id) === args.comboId || toString(comboEntry.name) === args.comboId + ); + if (!combo) { + const msg = `Combo '${args.comboId}' not found`; + await logToolCall( + "omniroute_set_routing_strategy", + args, + null, + Date.now() - start, + false, + msg + ); + return { content: [{ type: "text", text: `Error: ${msg}` }], isError: true }; + } + const comboId = toString(combo.id); + if (!comboId) { + const msg = "Matched combo has no id"; + await logToolCall( + "omniroute_set_routing_strategy", + args, + null, + Date.now() - start, + false, + msg + ); + return { content: [{ type: "text", text: `Error: ${msg}` }], isError: true }; + } + const comboData = toRecord(combo.data); + const currentConfig = toRecord( + Object.keys(toRecord(combo.config)).length > 0 ? combo.config : comboData.config + ); + let nextConfig = void 0; + if (args.strategy === "auto" && args.autoRoutingStrategy) { + const currentAutoConfig = toRecord(currentConfig.auto); + nextConfig = { + ...currentConfig, + auto: { + ...currentAutoConfig, + routingStrategy: args.autoRoutingStrategy, + }, + }; + } + const payload = { strategy: args.strategy }; + if (nextConfig && Object.keys(nextConfig).length > 0) { + payload.config = nextConfig; + } + const updatedCombo = toRecord( + await apiFetch(`/api/combos/${encodeURIComponent(comboId)}`, { + method: "PUT", + body: JSON.stringify(payload), + }) + ); + const updatedConfig = toRecord(updatedCombo.config); + const resolvedAutoStrategy = + toString(toRecord(updatedConfig.auto).routingStrategy) || + (args.strategy === "auto" ? (args.autoRoutingStrategy ?? "rules") : ""); + const result = { + success: true, + combo: { + id: toString(updatedCombo.id, comboId), + name: toString(updatedCombo.name, toString(combo.name, comboId)), + strategy: toString(updatedCombo.strategy, args.strategy), + autoRoutingStrategy: + toString(updatedCombo.strategy, args.strategy) === "auto" ? resolvedAutoStrategy : null, + }, + }; + await logToolCall("omniroute_set_routing_strategy", args, result, Date.now() - start, true); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + await logToolCall("omniroute_set_routing_strategy", args, null, Date.now() - start, false, msg); + return { content: [{ type: "text", text: `Error: ${msg}` }], isError: true }; + } +} +async function handleSetResilienceProfile(args) { + const start = Date.now(); + try { + const settings = RESILIENCE_PROFILES[args.profile]; + if (!settings) { + return { + content: [{ type: "text", text: `Error: Invalid profile "${args.profile}"` }], + isError: true, + }; + } + await apiFetch("/api/resilience", { + method: "PATCH", + body: JSON.stringify({ + profiles: settings.profiles, + defaults: settings.defaults, + }), + }); + const result = { applied: true, profile: args.profile, settings }; + await logToolCall("omniroute_set_resilience_profile", args, result, Date.now() - start, true); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + await logToolCall( + "omniroute_set_resilience_profile", + args, + null, + Date.now() - start, + false, + msg + ); + return { content: [{ type: "text", text: `Error: ${msg}` }], isError: true }; + } +} +async function handleTestCombo(args) { + const start = Date.now(); + try { + const combos = normalizeCombosResponse(await apiFetch("/api/combos")); + const combo = combos.find( + (comboEntry) => + toString(comboEntry.id) === args.comboId || toString(comboEntry.name) === args.comboId + ); + if (!combo) { + return { + content: [ + { + type: "text", + text: JSON.stringify({ error: `Combo "${args.comboId}" not found` }), + }, + ], + isError: true, + }; + } + const models = getComboModels(combo); + const prompt = (args.testPrompt || "Say hello").slice(0, 200); + const results = await Promise.allSettled( + models.map(async (model) => { + const providerStart = Date.now(); + try { + const resp = toRecord( + await apiFetch("/v1/chat/completions", { + method: "POST", + body: JSON.stringify({ + model: model.model || "auto", + messages: [{ role: "user", content: prompt }], + max_tokens: 50, + stream: false, + "x-provider": model.provider, + }), + }) + ); + const usage = toRecord(resp.usage); + return { + provider: model.provider, + model: model.model || toString(resp.model, "unknown"), + success: true, + latencyMs: Date.now() - providerStart, + cost: toNumber2(resp.cost, 0), + tokenCount: toNumber2(usage.prompt_tokens, 0) + toNumber2(usage.completion_tokens, 0), + }; + } catch (err) { + return { + provider: model.provider, + model: model.model || "unknown", + success: false, + latencyMs: Date.now() - providerStart, + cost: 0, + tokenCount: 0, + error: err instanceof Error ? err.message : String(err), + }; + } + }) + ); + const providerResults = results.map((r) => + r.status === "fulfilled" + ? r.value + : { + provider: "unknown", + model: "unknown", + success: false, + latencyMs: 0, + cost: 0, + tokenCount: 0, + error: "Promise rejected", + } + ); + const successful = providerResults.filter((r) => r.success); + const fastest = successful.sort((a, b) => a.latencyMs - b.latencyMs)[0]; + const cheapest = successful.sort((a, b) => a.cost - b.cost)[0]; + const result = { + results: providerResults, + summary: { + totalProviders: providerResults.length, + successful: successful.length, + fastestProvider: fastest?.provider || "none", + cheapestProvider: cheapest?.provider || "none", + }, + }; + await logToolCall( + "omniroute_test_combo", + { comboId: args.comboId }, + result.summary, + Date.now() - start, + true + ); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + await logToolCall("omniroute_test_combo", args, null, Date.now() - start, false, msg); + return { content: [{ type: "text", text: `Error: ${msg}` }], isError: true }; + } +} +async function handleGetProviderMetrics(args) { + const start = Date.now(); + try { + const [healthRaw, quotaRaw, analyticsRaw] = await Promise.allSettled([ + apiFetch("/api/monitoring/health"), + apiFetch(`/api/usage/quota?provider=${encodeURIComponent(args.provider)}`), + apiFetch(`/api/usage/analytics?period=session&provider=${encodeURIComponent(args.provider)}`), + ]); + const health = healthRaw.status === "fulfilled" ? toRecord(healthRaw.value) : {}; + const quota = + quotaRaw.status === "fulfilled" + ? normalizeQuotaResponse(quotaRaw.value, { provider: args.provider }) + : normalizeQuotaResponse({}); + const analytics = analyticsRaw.status === "fulfilled" ? toRecord(analyticsRaw.value) : {}; + const cb = toArrayOfRecords(health.circuitBreakers).find( + (breaker) => toString(breaker.provider) === args.provider + ); + const providerQuota = quota.providers.find((p) => p.provider === args.provider) || null; + const result = { + provider: args.provider, + successRate: toNumber2(analytics.successRate, 1), + requestCount: toNumber2(analytics.requestCount, 0), + avgLatencyMs: toNumber2(analytics.avgLatencyMs, 0), + p50LatencyMs: toNumber2(analytics.p50LatencyMs, 0), + p95LatencyMs: toNumber2(analytics.p95LatencyMs, 0), + p99LatencyMs: toNumber2(analytics.p99LatencyMs, 0), + errorRate: toNumber2(analytics.errorRate, 0), + lastError: toString(analytics.lastError) || null, + circuitBreakerState: toString(cb?.state, "CLOSED"), + quotaInfo: providerQuota + ? { + used: providerQuota.quotaUsed, + total: providerQuota.quotaTotal, + resetAt: providerQuota.resetAt, + } + : { used: 0, total: null, resetAt: null }, + }; + await logToolCall("omniroute_get_provider_metrics", args, result, Date.now() - start, true); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + await logToolCall("omniroute_get_provider_metrics", args, null, Date.now() - start, false, msg); + return { content: [{ type: "text", text: `Error: ${msg}` }], isError: true }; + } +} +async function handleBestComboForTask(args) { + const start = Date.now(); + try { + const fitness = TASK_FITNESS[args.taskType] || TASK_FITNESS.coding; + const combos = normalizeCombosResponse(await apiFetch("/api/combos")); + const enabledCombos = combos.filter((combo) => combo.enabled !== false); + if (enabledCombos.length === 0) { + return { + content: [{ type: "text", text: JSON.stringify({ error: "No enabled combos available" }) }], + isError: true, + }; + } + const scored = enabledCombos.map((combo) => { + const models = getComboModels(combo); + let score = 0; + for (const model of models) { + const prefIdx = fitness.preferred.indexOf(model.provider); + if (prefIdx >= 0) score += (fitness.preferred.length - prefIdx) * 10; + } + const name = toString(combo.name).toLowerCase(); + for (const trait of fitness.traits) { + if (name.includes(trait)) score += 5; + } + const isFree = + name.includes("free") || + models.every((model) => model.provider.toLowerCase().includes("free")); + return { combo, score, isFree }; + }); + scored.sort((a, b) => b.score - a.score); + const best = scored[0]; + const alternatives = scored.slice(1, 4).map((s) => ({ + id: s.combo.id, + name: s.combo.name, + tradeoff: s.isFree + ? "free but may have limits" + : s.score < best.score * 0.5 + ? "cheaper but slower" + : "similar quality, different providers", + })); + const freeAlt = scored.find((s) => s.isFree && s !== best); + const result = { + recommendedCombo: { + id: best.combo.id, + name: best.combo.name, + reason: `Best match for "${args.taskType}": preferred providers (${fitness.preferred.slice(0, 3).join(", ")})`, + }, + alternatives, + freeAlternative: freeAlt ? { id: freeAlt.combo.id, name: freeAlt.combo.name } : null, + }; + await logToolCall( + "omniroute_best_combo_for_task", + args, + result.recommendedCombo, + Date.now() - start, + true + ); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + await logToolCall("omniroute_best_combo_for_task", args, null, Date.now() - start, false, msg); + return { content: [{ type: "text", text: `Error: ${msg}` }], isError: true }; + } +} +async function handleExplainRoute(args) { + const start = Date.now(); + try { + let decision = null; + try { + decision = toRecord( + await apiFetch(`/api/routing/decisions/${encodeURIComponent(args.requestId)}`) + ); + } catch {} + const result = decision + ? { + requestId: args.requestId, + decision: { + comboUsed: decision.comboUsed || "default", + providerSelected: decision.providerSelected || "unknown", + modelUsed: decision.modelUsed || "unknown", + score: decision.score || 0, + factors: decision.factors || [ + { name: "health", value: 1, weight: 0.3, contribution: 0.3 }, + { name: "quota", value: 1, weight: 0.25, contribution: 0.25 }, + { name: "cost", value: 0.8, weight: 0.2, contribution: 0.16 }, + { name: "latency", value: 0.9, weight: 0.15, contribution: 0.135 }, + { name: "task_fit", value: 0.7, weight: 0.1, contribution: 0.07 }, + ], + fallbacksTriggered: decision.fallbacksTriggered || [], + costActual: decision.costActual || 0, + latencyActual: decision.latencyActual || 0, + }, + } + : { + requestId: args.requestId, + decision: { + comboUsed: "unknown", + providerSelected: "unknown", + modelUsed: "unknown", + score: 0, + factors: [], + fallbacksTriggered: [], + costActual: 0, + latencyActual: 0, + }, + note: "Routing decision not found. The /api/routing/decisions endpoint may not be implemented yet, or the requestId is invalid.", + }; + await logToolCall( + "omniroute_explain_route", + args, + { requestId: args.requestId }, + Date.now() - start, + true + ); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + await logToolCall("omniroute_explain_route", args, null, Date.now() - start, false, msg); + return { content: [{ type: "text", text: `Error: ${msg}` }], isError: true }; + } +} +async function handleSyncPricing(args) { + const start = Date.now(); + try { + const result = toRecord( + await apiFetch("/api/pricing/sync", { + method: "POST", + body: JSON.stringify({ + sources: args.sources, + dryRun: args.dryRun ?? false, + }), + }) + ); + await logToolCall("omniroute_sync_pricing", args, result, Date.now() - start, true); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + await logToolCall("omniroute_sync_pricing", args, null, Date.now() - start, false, msg); + return { content: [{ type: "text", text: `Error: ${msg}` }], isError: true }; + } +} +async function handleGetSessionSnapshot() { + const start = Date.now(); + try { + const analytics = toRecord( + await apiFetch("/api/usage/analytics?period=session").catch(() => ({})) + ); + const tokenCount = toRecord(analytics.tokenCount); + const byModel = toArrayOfRecords(analytics.byModel); + const byProvider = toArrayOfRecords(analytics.byProvider); + const result = { + sessionStart: toString(analytics.sessionStart, /* @__PURE__ */ new Date().toISOString()), + duration: toString(analytics.duration, "unknown"), + requestCount: toNumber2(analytics.requestCount, 0), + costTotal: toNumber2(analytics.totalCost, 0), + tokenCount: { + prompt: toNumber2(tokenCount.prompt, 0), + completion: toNumber2(tokenCount.completion, 0), + }, + topModels: byModel.slice(0, 5).map((model) => ({ + model: toString(model.model, "unknown"), + count: toNumber2(model.requests, 0), + })), + topProviders: byProvider.slice(0, 5).map((provider) => ({ + provider: toString(provider.name, "unknown"), + count: toNumber2(provider.requests, 0), + })), + errors: toNumber2(analytics.errorCount, 0), + fallbacks: toNumber2(analytics.fallbackCount, 0), + budgetGuard: activeBudgetGuard + ? { + active: true, + remaining: Math.max(0, activeBudgetGuard.maxCost - activeBudgetGuard.spent), + action: activeBudgetGuard.action, + } + : null, + }; + await logToolCall( + "omniroute_get_session_snapshot", + {}, + { requestCount: result.requestCount }, + Date.now() - start, + true + ); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + await logToolCall("omniroute_get_session_snapshot", {}, null, Date.now() - start, false, msg); + return { content: [{ type: "text", text: `Error: ${msg}` }], isError: true }; + } +} + +// open-sse/mcp-server/tools/memoryTools.ts +import { z as z3 } from "zod"; + +// src/lib/db/core.ts +import Database from "better-sqlite3"; +import path3 from "path"; +import fs3 from "fs"; + +// src/lib/dataPaths.ts +import path from "path"; +import os from "os"; +var APP_NAME = "omniroute"; +function safeHomeDir() { + try { + return os.homedir(); + } catch { + return process.cwd(); + } +} +function normalizeConfiguredPath(dir) { + if (typeof dir !== "string") return null; + const trimmed = dir.trim(); + if (!trimmed) return null; + return path.resolve(trimmed); +} +function getLegacyDotDataDir() { + return path.join(safeHomeDir(), `.${APP_NAME}`); +} +function getDefaultDataDir() { + const homeDir = safeHomeDir(); + if (process.platform === "win32") { + const appData = process.env.APPDATA || path.join(homeDir, "AppData", "Roaming"); + return path.join(appData, APP_NAME); + } + const xdgConfigHome = normalizeConfiguredPath(process.env.XDG_CONFIG_HOME); + if (xdgConfigHome) { + return path.join(xdgConfigHome, APP_NAME); + } + return getLegacyDotDataDir(); +} +function resolveDataDir2({ isCloud: isCloud2 = false } = {}) { + if (isCloud2) return "/tmp"; + const configured = normalizeConfiguredPath(process.env.DATA_DIR); + if (configured) return configured; + return getDefaultDataDir(); +} + +// src/lib/db/migrationRunner.ts +import fs2 from "fs"; +import path2 from "path"; +import { fileURLToPath } from "url"; +function resolveMigrationsDir() { + try { + const metaUrl = import.meta.url; + if (metaUrl && metaUrl.startsWith("file://")) { + const __filename = fileURLToPath(metaUrl); + return path2.join(path2.dirname(__filename), "migrations"); + } + } catch {} + return path2.join(process.cwd(), "src", "lib", "db", "migrations"); +} +var MIGRATIONS_DIR = resolveMigrationsDir(); +function ensureMigrationsTable(db2) { + db2.exec(` + CREATE TABLE IF NOT EXISTS _omniroute_migrations ( + version TEXT PRIMARY KEY, + name TEXT NOT NULL, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + `); +} +function getMigrationFiles() { + if (!fs2.existsSync(MIGRATIONS_DIR)) return []; + return fs2 + .readdirSync(MIGRATIONS_DIR) + .filter((f) => f.endsWith(".sql")) + .sort() + .map((filename) => { + const match = filename.match(/^(\d+)_(.+)\.sql$/); + if (!match) return null; + return { + version: match[1], + name: match[2], + path: path2.join(MIGRATIONS_DIR, filename), + }; + }) + .filter(Boolean); +} +function getAppliedVersions(db2) { + const rows = db2.prepare("SELECT version FROM _omniroute_migrations").all(); + return new Set(rows.map((r) => r.version)); +} +function runMigrations(db2) { + ensureMigrationsTable(db2); + const files = getMigrationFiles(); + const applied = getAppliedVersions(db2); + let count = 0; + for (const migration of files) { + if (applied.has(migration.version)) continue; + const sql = fs2.readFileSync(migration.path, "utf-8"); + const applyMigration = db2.transaction(() => { + db2.exec(sql); + db2 + .prepare("INSERT INTO _omniroute_migrations (version, name) VALUES (?, ?)") + .run(migration.version, migration.name); + }); + try { + applyMigration(); + count++; + console.log(`[Migration] Applied: ${migration.version}_${migration.name}`); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(`[Migration] FAILED: ${migration.version}_${migration.name} \u2014 ${message}`); + throw err; + } + } + if (count > 0) { + console.log(`[Migration] ${count} migration(s) applied successfully.`); + } + return count; +} + +// src/lib/db/core.ts +var isCloud = typeof globalThis.caches === "object" && globalThis.caches !== null; +var isBuildPhase = process.env.NEXT_PHASE === "phase-production-build"; +var DATA_DIR = resolveDataDir2({ isCloud }); +var LEGACY_DATA_DIR = isCloud ? null : getLegacyDotDataDir(); +var SQLITE_FILE = isCloud ? null : path3.join(DATA_DIR, "storage.sqlite"); +var JSON_DB_FILE = isCloud ? null : path3.join(DATA_DIR, "db.json"); +var DB_BACKUPS_DIR = isCloud ? null : path3.join(DATA_DIR, "db_backups"); +if (!isCloud && !fs3.existsSync(DATA_DIR)) { + try { + fs3.mkdirSync(DATA_DIR, { recursive: true }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.warn( + `[DB] Cannot create data directory '${DATA_DIR}': ${msg} +[DB] Set the DATA_DIR environment variable to a writable path, e.g.: +[DB] DATA_DIR=/path/to/writable/dir omniroute` + ); + } +} +var SCHEMA_SQL = ` + CREATE TABLE IF NOT EXISTS provider_connections ( + id TEXT PRIMARY KEY, + provider TEXT NOT NULL, + auth_type TEXT, + name TEXT, + email TEXT, + priority INTEGER DEFAULT 0, + is_active INTEGER DEFAULT 1, + access_token TEXT, + refresh_token TEXT, + expires_at TEXT, + token_expires_at TEXT, + scope TEXT, + project_id TEXT, + test_status TEXT, + error_code TEXT, + last_error TEXT, + last_error_at TEXT, + last_error_type TEXT, + last_error_source TEXT, + backoff_level INTEGER DEFAULT 0, + rate_limited_until TEXT, + health_check_interval INTEGER, + last_health_check_at TEXT, + last_tested TEXT, + api_key TEXT, + id_token TEXT, + provider_specific_data TEXT, + expires_in INTEGER, + display_name TEXT, + global_priority INTEGER, + default_model TEXT, + token_type TEXT, + consecutive_use_count INTEGER DEFAULT 0, + rate_limit_protection INTEGER DEFAULT 0, + last_used_at TEXT, + "group" TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_pc_provider ON provider_connections(provider); + CREATE INDEX IF NOT EXISTS idx_pc_active ON provider_connections(is_active); + CREATE INDEX IF NOT EXISTS idx_pc_priority ON provider_connections(provider, priority); + + CREATE TABLE IF NOT EXISTS provider_nodes ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + name TEXT NOT NULL, + prefix TEXT, + api_type TEXT, + base_url TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS key_value ( + namespace TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY (namespace, key) + ); + + CREATE TABLE IF NOT EXISTS combos ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + data TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS api_keys ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + key TEXT NOT NULL UNIQUE, + machine_id TEXT, + allowed_models TEXT DEFAULT '[]', + no_log INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_ak_key ON api_keys(key); + + CREATE TABLE IF NOT EXISTS db_meta ( + key TEXT PRIMARY KEY, + value TEXT + ); + + CREATE TABLE IF NOT EXISTS usage_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider TEXT, + model TEXT, + connection_id TEXT, + api_key_id TEXT, + api_key_name TEXT, + tokens_input INTEGER DEFAULT 0, + tokens_output INTEGER DEFAULT 0, + tokens_cache_read INTEGER DEFAULT 0, + tokens_cache_creation INTEGER DEFAULT 0, + tokens_reasoning INTEGER DEFAULT 0, + status TEXT, + success INTEGER DEFAULT 1, + latency_ms INTEGER DEFAULT 0, + ttft_ms INTEGER DEFAULT 0, + error_code TEXT, + timestamp TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_uh_timestamp ON usage_history(timestamp); + CREATE INDEX IF NOT EXISTS idx_uh_provider ON usage_history(provider); + CREATE INDEX IF NOT EXISTS idx_uh_model ON usage_history(model); + + CREATE TABLE IF NOT EXISTS call_logs ( + id TEXT PRIMARY KEY, + timestamp TEXT NOT NULL, + method TEXT, + path TEXT, + status INTEGER, + model TEXT, + provider TEXT, + account TEXT, + connection_id TEXT, + duration INTEGER DEFAULT 0, + tokens_in INTEGER DEFAULT 0, + tokens_out INTEGER DEFAULT 0, + source_format TEXT, + target_format TEXT, + api_key_id TEXT, + api_key_name TEXT, + combo_name TEXT, + request_body TEXT, + response_body TEXT, + error TEXT, + artifact_relpath TEXT, + has_pipeline_details INTEGER DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_cl_timestamp ON call_logs(timestamp); + CREATE INDEX IF NOT EXISTS idx_cl_status ON call_logs(status); + + CREATE TABLE IF NOT EXISTS proxy_logs ( + id TEXT PRIMARY KEY, + timestamp TEXT NOT NULL, + status TEXT, + proxy_type TEXT, + proxy_host TEXT, + proxy_port INTEGER, + level TEXT, + level_id TEXT, + provider TEXT, + target_url TEXT, + public_ip TEXT, + latency_ms INTEGER DEFAULT 0, + error TEXT, + connection_id TEXT, + combo_id TEXT, + account TEXT, + tls_fingerprint INTEGER DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_pl_timestamp ON proxy_logs(timestamp); + CREATE INDEX IF NOT EXISTS idx_pl_status ON proxy_logs(status); + CREATE INDEX IF NOT EXISTS idx_pl_provider ON proxy_logs(provider); + + -- Domain State Persistence (Phase 5) + CREATE TABLE IF NOT EXISTS domain_fallback_chains ( + model TEXT PRIMARY KEY, + chain TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS domain_budgets ( + api_key_id TEXT PRIMARY KEY, + daily_limit_usd REAL NOT NULL, + monthly_limit_usd REAL DEFAULT 0, + warning_threshold REAL DEFAULT 0.8 + ); + + CREATE TABLE IF NOT EXISTS domain_cost_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + api_key_id TEXT NOT NULL, + cost REAL NOT NULL, + timestamp INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_dch_key ON domain_cost_history(api_key_id); + CREATE INDEX IF NOT EXISTS idx_dch_ts ON domain_cost_history(timestamp); + + CREATE TABLE IF NOT EXISTS domain_lockout_state ( + identifier TEXT PRIMARY KEY, + attempts TEXT NOT NULL, + locked_until INTEGER + ); + + CREATE TABLE IF NOT EXISTS domain_circuit_breakers ( + name TEXT PRIMARY KEY, + state TEXT NOT NULL DEFAULT 'CLOSED', + failure_count INTEGER DEFAULT 0, + last_failure_time INTEGER, + options TEXT + ); + + CREATE TABLE IF NOT EXISTS semantic_cache ( + id TEXT PRIMARY KEY, + signature TEXT NOT NULL UNIQUE, + model TEXT NOT NULL, + prompt_hash TEXT NOT NULL, + response TEXT NOT NULL, + tokens_saved INTEGER DEFAULT 0, + hit_count INTEGER DEFAULT 0, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_sc_sig ON semantic_cache(signature); + CREATE INDEX IF NOT EXISTS idx_sc_model ON semantic_cache(model); +`; +function getDb2() { + return globalThis.__omnirouteDb ?? null; +} +function setDb(db2) { + if (db2) { + globalThis.__omnirouteDb = db2; + } else { + delete globalThis.__omnirouteDb; + } +} +function ensureProviderConnectionsColumns(db2) { + try { + const columns = db2.prepare("PRAGMA table_info(provider_connections)").all(); + const columnNames = new Set(columns.map((column) => String(column.name ?? ""))); + if (!columnNames.has("rate_limit_protection")) { + db2.exec( + "ALTER TABLE provider_connections ADD COLUMN rate_limit_protection INTEGER DEFAULT 0" + ); + console.log("[DB] Added provider_connections.rate_limit_protection column"); + } + if (!columnNames.has("last_used_at")) { + db2.exec("ALTER TABLE provider_connections ADD COLUMN last_used_at TEXT"); + console.log("[DB] Added provider_connections.last_used_at column"); + } + if (!columnNames.has("group")) { + db2.exec('ALTER TABLE provider_connections ADD COLUMN "group" TEXT'); + console.log('[DB] Added provider_connections."group" column'); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn("[DB] Failed to verify provider_connections schema:", message); + } +} +function ensureUsageHistoryColumns(db2) { + try { + const columns = db2.prepare("PRAGMA table_info(usage_history)").all(); + const columnNames = new Set(columns.map((column) => String(column.name ?? ""))); + if (!columnNames.has("success")) { + db2.exec("ALTER TABLE usage_history ADD COLUMN success INTEGER DEFAULT 1"); + console.log("[DB] Added usage_history.success column"); + } + if (!columnNames.has("latency_ms")) { + db2.exec("ALTER TABLE usage_history ADD COLUMN latency_ms INTEGER DEFAULT 0"); + console.log("[DB] Added usage_history.latency_ms column"); + } + if (!columnNames.has("ttft_ms")) { + db2.exec("ALTER TABLE usage_history ADD COLUMN ttft_ms INTEGER DEFAULT 0"); + console.log("[DB] Added usage_history.ttft_ms column"); + } + if (!columnNames.has("error_code")) { + db2.exec("ALTER TABLE usage_history ADD COLUMN error_code TEXT"); + console.log("[DB] Added usage_history.error_code column"); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn("[DB] Failed to verify usage_history schema:", message); + } +} +function ensureCallLogsColumns(db2) { + try { + const columns = db2.prepare("PRAGMA table_info(call_logs)").all(); + const columnNames = new Set(columns.map((column) => String(column.name ?? ""))); + if (!columnNames.has("artifact_relpath")) { + db2.exec("ALTER TABLE call_logs ADD COLUMN artifact_relpath TEXT"); + console.log("[DB] Added call_logs.artifact_relpath column"); + } + if (!columnNames.has("has_pipeline_details")) { + db2.exec("ALTER TABLE call_logs ADD COLUMN has_pipeline_details INTEGER DEFAULT 0"); + console.log("[DB] Added call_logs.has_pipeline_details column"); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn("[DB] Failed to verify call_logs schema:", message); + } +} +function getDbInstance() { + const existing = getDb2(); + if (existing) return existing; + if (isCloud || isBuildPhase) { + if (isBuildPhase) { + console.log("[DB] Build phase detected \u2014 using in-memory SQLite (read-only)"); + } + const memoryDb = new Database(":memory:"); + memoryDb.pragma("journal_mode = WAL"); + memoryDb.exec(SCHEMA_SQL); + ensureUsageHistoryColumns(memoryDb); + setDb(memoryDb); + return memoryDb; + } + const sqliteFile = SQLITE_FILE; + if (!sqliteFile) { + throw new Error("SQLITE_FILE is unavailable for local mode"); + } + const jsonDbFile = JSON_DB_FILE; + if (fs3.existsSync(sqliteFile)) { + try { + const probe = new Database(sqliteFile, { readonly: true }); + const hasOldSchema = probe + .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='schema_migrations'") + .get(); + if (hasOldSchema) { + let hasData = false; + try { + const count = probe.prepare("SELECT COUNT(*) as c FROM provider_connections").get(); + hasData = Boolean(count && count.c > 0); + } catch {} + probe.close(); + if (hasData) { + console.log( + `[DB] Old schema_migrations table found but data exists \u2014 preserving data (#146)` + ); + const fixDb = new Database(sqliteFile); + try { + fixDb.exec("DROP TABLE IF EXISTS schema_migrations"); + fixDb.pragma("wal_checkpoint(TRUNCATE)"); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + console.warn("[DB] Could not clean up old schema table:", message); + } finally { + fixDb.close(); + } + } else { + const oldPath = sqliteFile + ".old-schema"; + console.log( + `[DB] Old incompatible schema detected (empty) \u2014 renaming to ${path3.basename(oldPath)}` + ); + fs3.renameSync(sqliteFile, oldPath); + for (const ext of ["-wal", "-shm"]) { + try { + if (fs3.existsSync(sqliteFile + ext)) fs3.unlinkSync(sqliteFile + ext); + } catch {} + } + } + } else { + probe.close(); + } + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + console.warn("[DB] Could not probe existing DB, will create fresh:", message); + try { + fs3.unlinkSync(sqliteFile); + } catch {} + } + } + const db2 = new Database(sqliteFile); + db2.pragma("journal_mode = WAL"); + db2.pragma("busy_timeout = 5000"); + db2.pragma("synchronous = NORMAL"); + db2.exec(SCHEMA_SQL); + ensureProviderConnectionsColumns(db2); + ensureUsageHistoryColumns(db2); + ensureCallLogsColumns(db2); + db2.exec(` + CREATE TABLE IF NOT EXISTS _omniroute_migrations ( + version TEXT PRIMARY KEY, + name TEXT NOT NULL, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + INSERT OR IGNORE INTO _omniroute_migrations (version, name) + VALUES ('001', 'initial_schema'); + `); + runMigrations(db2); + if (jsonDbFile && fs3.existsSync(jsonDbFile)) { + migrateFromJson(db2, jsonDbFile); + } + const versionStmt = db2.prepare( + "INSERT OR REPLACE INTO db_meta (key, value) VALUES ('schema_version', '1')" + ); + versionStmt.run(); + setDb(db2); + console.log(`[DB] SQLite database ready: ${sqliteFile}`); + return db2; +} +function migrateFromJson(db2, jsonPath) { + try { + const raw = fs3.readFileSync(jsonPath, "utf-8"); + const data = JSON.parse(raw); + const connCount = (data.providerConnections || []).length; + const nodeCount = (data.providerNodes || []).length; + const keyCount = (data.apiKeys || []).length; + if (connCount === 0 && nodeCount === 0 && keyCount === 0) { + console.log("[DB] db.json has no data to migrate, skipping"); + fs3.renameSync(jsonPath, jsonPath + ".empty"); + return; + } + console.log( + `[DB] Migrating db.json \u2192 SQLite (${connCount} connections, ${nodeCount} nodes, ${keyCount} keys)...` + ); + const migrate = db2.transaction(() => { + const insertConn = db2.prepare(` + INSERT OR REPLACE INTO provider_connections ( + id, provider, auth_type, name, email, priority, is_active, + access_token, refresh_token, expires_at, token_expires_at, + scope, project_id, test_status, error_code, last_error, + last_error_at, last_error_type, last_error_source, backoff_level, + rate_limited_until, health_check_interval, last_health_check_at, + last_tested, api_key, id_token, provider_specific_data, + expires_in, display_name, global_priority, default_model, + token_type, consecutive_use_count, rate_limit_protection, last_used_at, created_at, updated_at + ) VALUES ( + @id, @provider, @authType, @name, @email, @priority, @isActive, + @accessToken, @refreshToken, @expiresAt, @tokenExpiresAt, + @scope, @projectId, @testStatus, @errorCode, @lastError, + @lastErrorAt, @lastErrorType, @lastErrorSource, @backoffLevel, + @rateLimitedUntil, @healthCheckInterval, @lastHealthCheckAt, + @lastTested, @apiKey, @idToken, @providerSpecificData, + @expiresIn, @displayName, @globalPriority, @defaultModel, + @tokenType, @consecutiveUseCount, @rateLimitProtection, @lastUsedAt, @createdAt, @updatedAt + ) + `); + for (const conn of data.providerConnections || []) { + insertConn.run({ + id: conn.id, + provider: conn.provider, + authType: conn.authType || "oauth", + name: conn.name || null, + email: conn.email || null, + priority: conn.priority || 0, + isActive: conn.isActive === false ? 0 : 1, + accessToken: conn.accessToken || null, + refreshToken: conn.refreshToken || null, + expiresAt: conn.expiresAt || null, + tokenExpiresAt: conn.tokenExpiresAt || null, + scope: conn.scope || null, + projectId: conn.projectId || null, + testStatus: conn.testStatus || null, + errorCode: conn.errorCode || null, + lastError: conn.lastError || null, + lastErrorAt: conn.lastErrorAt || null, + lastErrorType: conn.lastErrorType || null, + lastErrorSource: conn.lastErrorSource || null, + backoffLevel: conn.backoffLevel || 0, + rateLimitedUntil: conn.rateLimitedUntil || null, + healthCheckInterval: conn.healthCheckInterval || null, + lastHealthCheckAt: conn.lastHealthCheckAt || null, + lastTested: conn.lastTested || null, + apiKey: conn.apiKey || null, + idToken: conn.idToken || null, + providerSpecificData: conn.providerSpecificData + ? JSON.stringify(conn.providerSpecificData) + : null, + expiresIn: conn.expiresIn || null, + displayName: conn.displayName || null, + globalPriority: conn.globalPriority || null, + defaultModel: conn.defaultModel || null, + tokenType: conn.tokenType || null, + consecutiveUseCount: conn.consecutiveUseCount || 0, + lastUsedAt: conn.lastUsedAt || null, + rateLimitProtection: + conn.rateLimitProtection === true || conn.rateLimitProtection === 1 ? 1 : 0, + createdAt: conn.createdAt || /* @__PURE__ */ new Date().toISOString(), + updatedAt: conn.updatedAt || /* @__PURE__ */ new Date().toISOString(), + }); + } + const insertNode = db2.prepare(` + INSERT OR REPLACE INTO provider_nodes (id, type, name, prefix, api_type, base_url, created_at, updated_at) + VALUES (@id, @type, @name, @prefix, @apiType, @baseUrl, @createdAt, @updatedAt) + `); + for (const node of data.providerNodes || []) { + insertNode.run({ + id: node.id, + type: node.type, + name: node.name, + prefix: node.prefix || null, + apiType: node.apiType || null, + baseUrl: node.baseUrl || null, + createdAt: node.createdAt || /* @__PURE__ */ new Date().toISOString(), + updatedAt: node.updatedAt || /* @__PURE__ */ new Date().toISOString(), + }); + } + const insertKv = db2.prepare( + "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)" + ); + for (const [alias, model] of Object.entries(data.modelAliases || {})) { + insertKv.run("modelAliases", alias, JSON.stringify(model)); + } + for (const [toolName, mappings] of Object.entries(data.mitmAlias || {})) { + insertKv.run("mitmAlias", toolName, JSON.stringify(mappings)); + } + for (const [key, value] of Object.entries(data.settings || {})) { + insertKv.run("settings", key, JSON.stringify(value)); + } + for (const [provider, models] of Object.entries(data.pricing || {})) { + insertKv.run("pricing", provider, JSON.stringify(models)); + } + for (const [providerId, models] of Object.entries(data.customModels || {})) { + insertKv.run("customModels", providerId, JSON.stringify(models)); + } + if (data.proxyConfig) { + insertKv.run("proxyConfig", "global", JSON.stringify(data.proxyConfig.global || null)); + insertKv.run("proxyConfig", "providers", JSON.stringify(data.proxyConfig.providers || {})); + insertKv.run("proxyConfig", "combos", JSON.stringify(data.proxyConfig.combos || {})); + insertKv.run("proxyConfig", "keys", JSON.stringify(data.proxyConfig.keys || {})); + } + const insertCombo = db2.prepare(` + INSERT OR REPLACE INTO combos (id, name, data, created_at, updated_at) + VALUES (@id, @name, @data, @createdAt, @updatedAt) + `); + for (const combo of data.combos || []) { + insertCombo.run({ + id: combo.id, + name: combo.name, + data: JSON.stringify(combo), + createdAt: combo.createdAt || /* @__PURE__ */ new Date().toISOString(), + updatedAt: combo.updatedAt || /* @__PURE__ */ new Date().toISOString(), + }); + } + const insertKey = db2.prepare(` + INSERT OR REPLACE INTO api_keys (id, name, key, machine_id, allowed_models, no_log, created_at) + VALUES (@id, @name, @key, @machineId, @allowedModels, @noLog, @createdAt) + `); + for (const apiKey of data.apiKeys || []) { + insertKey.run({ + id: apiKey.id, + name: apiKey.name, + key: apiKey.key, + machineId: apiKey.machineId || null, + allowedModels: JSON.stringify(apiKey.allowedModels || []), + noLog: apiKey.noLog ? 1 : 0, + createdAt: apiKey.createdAt || /* @__PURE__ */ new Date().toISOString(), + }); + } + }); + migrate(); + const migratedPath = jsonPath + ".migrated"; + fs3.renameSync(jsonPath, migratedPath); + console.log(`[DB] \u2713 Migration complete. Original saved as ${migratedPath}`); + const legacyBackupDir = path3.join(DATA_DIR, "db_backups"); + if (fs3.existsSync(legacyBackupDir)) { + const jsonBackups = fs3.readdirSync(legacyBackupDir).filter((f) => f.endsWith(".json")); + if (jsonBackups.length > 0) { + console.log( + `[DB] Note: ${jsonBackups.length} legacy .json backups remain in ${legacyBackupDir}` + ); + } + } + } catch (err) { + console.error("[DB] Migration from db.json failed:", err.message); + } +} + +// src/lib/memory/schemas.ts +import { z as z2 } from "zod"; + +// src/lib/memory/types.ts +var MemoryType = /* @__PURE__ */ ((MemoryType2) => { + MemoryType2["FACTUAL"] = "factual"; + MemoryType2["EPISODIC"] = "episodic"; + MemoryType2["PROCEDURAL"] = "procedural"; + MemoryType2["SEMANTIC"] = "semantic"; + return MemoryType2; +})(MemoryType || {}); + +// src/lib/memory/schemas.ts +var MemoryConfigSchema = z2.object({ + enabled: z2.boolean(), + maxTokens: z2.number().int().positive(), + retrievalStrategy: z2.enum(["exact", "semantic", "hybrid"]).optional(), + autoSummarize: z2.boolean(), + persistAcrossModels: z2.boolean(), + retentionDays: z2.number().int().positive(), + scope: z2.enum(["session", "apiKey", "global"]).optional(), +}); +var MemoryCreateInputSchema = z2 + .object({ + type: z2.nativeEnum(MemoryType), + key: z2.string().min(1), + content: z2.string().min(1), + metadata: z2.record(z2.string(), z2.unknown()).optional(), + }) + .strict(); +var MemoryUpdateInputSchema = z2 + .object({ + type: z2.nativeEnum(MemoryType).optional(), + key: z2.string().min(1).optional(), + content: z2.string().min(1).optional(), + metadata: z2.record(z2.string(), z2.unknown()).optional(), + }) + .strict(); + +// src/lib/memory/retrieval.ts +function estimateTokens(text) { + if (!text || typeof text !== "string") return 0; + return Math.ceil(text.length / 4); +} +async function retrieveMemories(apiKeyId, config = {}) { + const normalizedConfig = MemoryConfigSchema.parse({ + enabled: true, + maxTokens: 2e3, + retrievalStrategy: "recent", + autoSummarize: false, + persistAcrossModels: false, + retentionDays: 30, + scope: "apiKey", + ...config, + }); + const maxTokens = Math.min(Math.max(normalizedConfig.maxTokens, 100), 8e3); + const strategy = normalizedConfig.retrievalStrategy; + const db2 = getDbInstance(); + const memories = []; + let totalTokens = 0; + let query = "SELECT * FROM memory WHERE apiKeyId = ?"; + const params = [apiKeyId]; + switch (strategy) { + case "semantic": + query += " ORDER BY createdAt DESC"; + break; + case "hybrid": + query += " ORDER BY createdAt DESC"; + break; + case "exact": + default: + query += " ORDER BY createdAt DESC"; + } + query += " LIMIT 100"; + const stmt = db2.prepare(query); + const rows = stmt.all(...params); + for (const row of rows) { + const memory = { + id: String(row.id), + apiKeyId: String(row.apiKeyId), + sessionId: String(row.sessionId), + type: row.type, + key: String(row.key), + content: String(row.content), + metadata: JSON.parse(String(row.metadata)), + createdAt: new Date(String(row.createdAt)), + updatedAt: new Date(String(row.updatedAt)), + expiresAt: row.expiresAt ? new Date(String(row.expiresAt)) : null, + }; + const memoryTokens = estimateTokens(memory.content); + if (totalTokens + memoryTokens > maxTokens) { + if (memories.length === 0) { + memories.push(memory); + totalTokens += memoryTokens; + } + break; + } + memories.push(memory); + totalTokens += memoryTokens; + } + return memories; +} + +// src/lib/memory/store.ts +var MEMORY_MAX_CACHE_SIZE = 1e4; +var _memoryCache = /* @__PURE__ */ new Map(); +function parseJSON(value) { + if (!value || typeof value !== "string" || value.trim() === "") { + return {}; + } + try { + const parsed = JSON.parse(value); + return typeof parsed === "object" && parsed !== null ? parsed : {}; + } catch { + return {}; + } +} +function invalidateMemoryCache(key) { + _memoryCache.delete(key); +} +function evictIfNeeded(cache) { + if (cache.size > MEMORY_MAX_CACHE_SIZE) { + const keysArray = Array.from(cache.keys()); + const entriesToRemove = Math.floor(cache.size * 0.2); + for (let i = 0; i < entriesToRemove; i++) { + cache.delete(keysArray[i]); + } + } +} +var MEMORY_VALIDATION_CACHE_TTL = 60 * 1e3; +async function createMemory(memory) { + const db2 = getDbInstance(); + const id = crypto.randomUUID(); + const now = /* @__PURE__ */ new Date().toISOString(); + const stmt = db2.prepare( + "INSERT INTO memory (id, apiKeyId, sessionId, type, key, content, metadata, createdAt, updatedAt, expiresAt) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + ); + stmt.run( + id, + memory.apiKeyId, + memory.sessionId, + memory.type, + memory.key, + memory.content, + JSON.stringify(memory.metadata), + now, + now, + memory.expiresAt?.toISOString() ?? null + ); + const createdMemory = { + id, + apiKeyId: memory.apiKeyId, + sessionId: memory.sessionId, + type: memory.type, + key: memory.key, + content: memory.content, + metadata: memory.metadata, + createdAt: new Date(now), + updatedAt: new Date(now), + expiresAt: memory.expiresAt ?? null, + }; + invalidateMemoryCache(id); + evictIfNeeded(_memoryCache); + _memoryCache.set(id, { value: createdMemory, timestamp: Date.now() }); + return createdMemory; +} +async function deleteMemory(id) { + if (!id || typeof id !== "string") return false; + const db2 = getDbInstance(); + const stmt = db2.prepare("DELETE FROM memory WHERE id = ?"); + const result = stmt.run(id); + if (result.changes === 0) { + return false; + } + invalidateMemoryCache(id); + return true; +} +async function listMemories(filters) { + const db2 = getDbInstance(); + let query = "SELECT * FROM memory"; + const params = []; + const whereClauses = []; + if (filters.apiKeyId) { + whereClauses.push("apiKeyId = ?"); + params.push(filters.apiKeyId); + } + if (filters.type) { + whereClauses.push("type = ?"); + params.push(filters.type); + } + if (filters.sessionId) { + whereClauses.push("sessionId = ?"); + params.push(filters.sessionId); + } + if (whereClauses.length > 0) { + query += " WHERE " + whereClauses.join(" AND "); + } + query += " ORDER BY createdAt DESC"; + if (filters.limit !== void 0) { + query += " LIMIT ?"; + params.push(filters.limit); + } + if (filters.offset !== void 0) { + query += " OFFSET ?"; + params.push(filters.offset); + } + const stmt = db2.prepare(query); + const rows = stmt.all(...params); + return rows.map((row) => ({ + id: String(row.id), + apiKeyId: String(row.apiKeyId), + sessionId: String(row.sessionId), + type: row.type, + key: String(row.key), + content: String(row.content), + metadata: parseJSON(row.metadata), + createdAt: new Date(String(row.createdAt)), + updatedAt: new Date(String(row.updatedAt)), + expiresAt: row.expiresAt ? new Date(String(row.expiresAt)) : null, + })); +} + +// open-sse/mcp-server/tools/memoryTools.ts +var MemorySearchSchema = z3.object({ + apiKeyId: z3.string(), + query: z3.string().optional(), + type: z3.enum(["factual", "episodic", "procedural", "semantic"]).optional(), + maxTokens: z3.number().int().positive().max(8e3).optional(), + limit: z3.number().int().positive().max(100).optional(), +}); +var MemoryAddSchema = z3.object({ + apiKeyId: z3.string(), + sessionId: z3.string().optional(), + type: z3.enum(["factual", "episodic", "procedural", "semantic"]), + key: z3.string().min(1), + content: z3.string().min(1), + metadata: z3.record(z3.string(), z3.unknown()).optional(), +}); +var MemoryClearSchema = z3.object({ + apiKeyId: z3.string(), + type: z3.enum(["factual", "episodic", "procedural", "semantic"]).optional(), + olderThan: z3.string().optional(), +}); +var memoryTools = { + omniroute_memory_search: { + name: "omniroute_memory_search", + description: "Search memories by query, type, or API key with token budget enforcement", + inputSchema: MemorySearchSchema, + handler: async (args) => { + const config = { + enabled: true, + maxTokens: args.maxTokens || 2e3, + retrievalStrategy: "exact", + autoSummarize: false, + persistAcrossModels: false, + retentionDays: 30, + scope: "apiKey", + }; + const memories = await retrieveMemories(args.apiKeyId, config); + const filtered = args.type ? memories.filter((m) => m.type === args.type) : memories; + const limited = args.limit ? filtered.slice(0, args.limit) : filtered; + return { + success: true, + data: { + memories: limited, + count: limited.length, + totalTokens: limited.reduce((sum, m) => sum + Math.ceil(m.content.length / 4), 0), + }, + }; + }, + }, + omniroute_memory_add: { + name: "omniroute_memory_add", + description: "Add a new memory entry", + inputSchema: MemoryAddSchema, + handler: async (args) => { + const memory = await createMemory({ + apiKeyId: args.apiKeyId, + sessionId: args.sessionId || "", + type: args.type, + key: args.key, + content: args.content, + metadata: args.metadata || {}, + expiresAt: null, + }); + return { + success: true, + data: { + memory, + message: "Memory created successfully", + }, + }; + }, + }, + omniroute_memory_clear: { + name: "omniroute_memory_clear", + description: "Clear memories for an API key, optionally filtered by type or age", + inputSchema: MemoryClearSchema, + handler: async (args) => { + const memories = await listMemories({ + apiKeyId: args.apiKeyId, + type: args.type, + }); + let toDelete = memories; + if (args.olderThan) { + const cutoff = new Date(args.olderThan); + toDelete = memories.filter((m) => new Date(m.createdAt) < cutoff); + } + let deletedCount = 0; + for (const memory of toDelete) { + await deleteMemory(memory.id); + deletedCount++; + } + return { + success: true, + data: { + deletedCount, + message: `Cleared ${deletedCount} memories`, + }, + }; + }, + }, +}; + +// open-sse/mcp-server/tools/skillTools.ts +import { z as z5 } from "zod"; + +// src/lib/skills/schemas.ts +import { z as z4 } from "zod"; + +// src/lib/skills/types.ts +var SkillMode = /* @__PURE__ */ ((SkillMode2) => { + SkillMode2["AUTO"] = "auto"; + SkillMode2["MANUAL"] = "manual"; + SkillMode2["HYBRID"] = "hybrid"; + return SkillMode2; +})(SkillMode || {}); + +// src/lib/skills/schemas.ts +var SkillSchema = z4.object({ + input: z4.record(z4.string(), z4.unknown()), + output: z4.record(z4.string(), z4.unknown()), +}); +var SkillCreateInputSchema = z4 + .object({ + name: z4.string().min(1).max(100), + version: z4 + .string() + .regex(/^\d+\.\d+\.\d+$/) + .default("1.0.0"), + description: z4.string().max(500).optional(), + schema: SkillSchema, + handler: z4.string().min(1), + enabled: z4.boolean().default(true), + }) + .strict(); +var SkillUpdateInputSchema = z4 + .object({ + name: z4.string().min(1).max(100).optional(), + version: z4 + .string() + .regex(/^\d+\.\d+\.\d+$/) + .optional(), + description: z4.string().max(500).optional(), + schema: SkillSchema.optional(), + handler: z4.string().min(1).optional(), + enabled: z4.boolean().optional(), + }) + .strict(); +var SkillConfigSchema = z4.object({ + enabled: z4.boolean(), + mode: z4.nativeEnum(SkillMode), + allowedSkills: z4.array(z4.string()), + timeout: z4.number().int().positive().default(3e4), + maxRetries: z4.number().int().min(0).default(3), +}); + +// src/lib/skills/registry.ts +import { randomUUID } from "crypto"; +var SkillRegistry = class _SkillRegistry { + static instance; + registeredSkills = /* @__PURE__ */ new Map(); + versionCache = /* @__PURE__ */ new Map(); + constructor() {} + static getInstance() { + if (!_SkillRegistry.instance) { + _SkillRegistry.instance = new _SkillRegistry(); + } + return _SkillRegistry.instance; + } + async register(skillData) { + const parsed = SkillCreateInputSchema.parse(skillData); + const db2 = getDbInstance(); + const id = randomUUID(); + const now = /* @__PURE__ */ new Date(); + db2 + .prepare( + `INSERT INTO skills (id, api_key_id, name, version, description, schema, handler, enabled, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) + .run( + id, + skillData.apiKeyId, + parsed.name, + parsed.version, + parsed.description || null, + JSON.stringify(parsed.schema), + parsed.handler, + parsed.enabled ? 1 : 0, + now.toISOString(), + now.toISOString() + ); + const skill = { + id, + apiKeyId: skillData.apiKeyId, + name: parsed.name, + version: parsed.version, + description: parsed.description || "", + schema: parsed.schema, + handler: parsed.handler, + enabled: parsed.enabled, + createdAt: now, + updatedAt: now, + }; + this.registeredSkills.set(`${parsed.name}@${parsed.version}`, skill); + this.updateVersionCache(skill); + return skill; + } + async unregister(name, version, apiKeyId) { + const db2 = getDbInstance(); + if (version) { + const key = `${name}@${version}`; + const skill = this.registeredSkills.get(key); + if (skill && (!apiKeyId || skill.apiKeyId === apiKeyId)) { + db2.prepare("DELETE FROM skills WHERE id = ?").run(skill.id); + this.registeredSkills.delete(key); + this.clearVersionCache(name); + return true; + } + } else { + const deleted = db2 + .prepare("DELETE FROM skills WHERE name = ? AND (? IS NULL OR api_key_id = ?)") + .run(name, apiKeyId || null, apiKeyId || null); + if (deleted.changes > 0) { + const keysToDelete = Array.from(this.registeredSkills.keys()).filter((k) => + k.startsWith(`${name}@`) + ); + keysToDelete.forEach((k) => this.registeredSkills.delete(k)); + this.clearVersionCache(name); + return true; + } + } + return false; + } + list(apiKeyId) { + if (apiKeyId) { + return Array.from(this.registeredSkills.values()).filter((s) => s.apiKeyId === apiKeyId); + } + return Array.from(this.registeredSkills.values()); + } + getSkill(name, apiKeyId) { + return this.registeredSkills.get(name); + } + getSkillVersions(name) { + const cached = this.versionCache.get(name); + if (!cached) return []; + return Array.from(cached.values()).sort((a, b) => this.compareVersions(b.version, a.version)); + } + resolveVersion(name, constraint, apiKeyId) { + const versions = this.getSkillVersions(name); + if (versions.length === 0) return void 0; + const operator = constraint.charAt(0); + const version = constraint.slice(1); + switch (operator) { + case "^": + return versions.find((s) => this.satisfies(s.version, version, "^")); + case "~": + return versions.find((s) => this.satisfies(s.version, version, "~")); + case ">": + case ">=": + case "<": + case "<=": + case "==": + return versions.find((s) => this.satisfies(s.version, version, operator)); + default: + return versions.find((s) => s.version === constraint); + } + } + satisfies(version, base, operator) { + const [baseMajor, baseMinor, basePatch] = base.split(".").map(Number); + const [verMajor, verMinor, verPatch] = version.split(".").map(Number); + switch (operator) { + case "^": + return ( + verMajor === baseMajor && + (verMinor > baseMinor || (verMinor === baseMinor && verPatch >= basePatch)) + ); + case "~": + return verMajor === baseMajor && verMinor === baseMinor && verPatch >= basePatch; + case ">": + return this.compareVersions(version, base) > 0; + case ">=": + return this.compareVersions(version, base) >= 0; + case "<": + return this.compareVersions(version, base) < 0; + case "<=": + return this.compareVersions(version, base) <= 0; + case "==": + return version === base; + default: + return version === base; + } + } + compareVersions(a, b) { + const [aMajor, aMinor, aPatch] = a.split(".").map(Number); + const [bMajor, bMinor, bPatch] = b.split(".").map(Number); + if (aMajor !== bMajor) return aMajor - bMajor; + if (aMinor !== bMinor) return aMinor - bMinor; + return aPatch - bPatch; + } + updateVersionCache(skill) { + if (!this.versionCache.has(skill.name)) { + this.versionCache.set(skill.name, /* @__PURE__ */ new Map()); + } + this.versionCache.get(skill.name).set(skill.version, skill); + } + clearVersionCache(name) { + this.versionCache.delete(name); + } + async loadFromDatabase(apiKeyId) { + const db2 = getDbInstance(); + const rows = apiKeyId + ? db2.prepare("SELECT * FROM skills WHERE api_key_id = ?").all(apiKeyId) + : db2.prepare("SELECT * FROM skills").all(); + for (const row of rows) { + const skill = { + id: row.id, + apiKeyId: row.api_key_id, + name: row.name, + version: row.version, + description: row.description || "", + schema: JSON.parse(row.schema), + handler: row.handler, + enabled: row.enabled === 1, + createdAt: new Date(row.created_at), + updatedAt: new Date(row.updated_at), + }; + this.registeredSkills.set(`${skill.name}@${skill.version}`, skill); + this.updateVersionCache(skill); + } + } +}; +var skillRegistry = SkillRegistry.getInstance(); + +// src/lib/skills/executor.ts +import { randomUUID as randomUUID2 } from "crypto"; +var SkillExecutor = class _SkillExecutor { + static instance; + handlers = /* @__PURE__ */ new Map(); + timeout = 3e4; + maxRetries = 3; + constructor() {} + static getInstance() { + if (!_SkillExecutor.instance) { + _SkillExecutor.instance = new _SkillExecutor(); + } + return _SkillExecutor.instance; + } + registerHandler(name, handler) { + this.handlers.set(name, handler); + } + setTimeout(ms) { + this.timeout = ms; + } + setMaxRetries(count) { + this.maxRetries = count; + } + async execute(skillName, input, context) { + const skill = skillRegistry.getSkill(skillName, context.apiKeyId); + if (!skill) { + throw new Error(`Skill not found: ${skillName}`); + } + if (!skill.enabled) { + throw new Error(`Skill is disabled: ${skillName}`); + } + const db2 = getDbInstance(); + const executionId = randomUUID2(); + const startTime = Date.now(); + try { + db2 + .prepare( + `INSERT INTO skill_executions (id, skill_id, api_key_id, session_id, input, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ) + .run( + executionId, + skill.id, + context.apiKeyId, + context.sessionId || null, + JSON.stringify(input), + "running" /* RUNNING */, + /* @__PURE__ */ new Date().toISOString() + ); + const handler = this.handlers.get(skill.handler); + if (!handler) { + throw new Error(`Handler not found: ${skill.handler}`); + } + let output = null; + let errorMessage = null; + let status = "success"; /* SUCCESS */ + try { + const result = await this.executeWithTimeout( + handler(input, { apiKeyId: context.apiKeyId, sessionId: context.sessionId || "" }) + ); + output = result; + } catch (err) { + errorMessage = err instanceof Error ? err.message : String(err); + status = "error" /* ERROR */; + } + const durationMs = Date.now() - startTime; + db2 + .prepare( + `UPDATE skill_executions SET output = ?, status = ?, error_message = ?, duration_ms = ? WHERE id = ?` + ) + .run(output ? JSON.stringify(output) : null, status, errorMessage, durationMs, executionId); + return { + id: executionId, + skillId: skill.id, + apiKeyId: context.apiKeyId, + sessionId: context.sessionId || "", + input, + output, + status, + errorMessage, + durationMs, + createdAt: /* @__PURE__ */ new Date(), + }; + } catch (err) { + const durationMs = Date.now() - startTime; + const errorMessage = err instanceof Error ? err.message : String(err); + db2 + .prepare( + `UPDATE skill_executions SET status = ?, error_message = ?, duration_ms = ? WHERE id = ?` + ) + .run("error" /* ERROR */, errorMessage, durationMs, executionId); + throw err; + } + } + async executeWithTimeout(promise) { + return Promise.race([ + promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error("Skill execution timed out")), this.timeout) + ), + ]); + } + getExecution(executionId) { + const db2 = getDbInstance(); + const row = db2.prepare("SELECT * FROM skill_executions WHERE id = ?").get(executionId); + if (!row) return void 0; + return { + id: row.id, + skillId: row.skill_id, + apiKeyId: row.api_key_id, + sessionId: row.session_id || "", + input: JSON.parse(row.input), + output: row.output ? JSON.parse(row.output) : null, + status: row.status, + errorMessage: row.error_message, + durationMs: row.duration_ms, + createdAt: new Date(row.created_at), + }; + } + listExecutions(apiKeyId, limit = 50) { + const db2 = getDbInstance(); + const rows = apiKeyId + ? db2 + .prepare( + "SELECT * FROM skill_executions WHERE api_key_id = ? ORDER BY created_at DESC LIMIT ?" + ) + .all(apiKeyId, limit) + : db2.prepare("SELECT * FROM skill_executions ORDER BY created_at DESC LIMIT ?").all(limit); + return rows.map((row) => ({ + id: row.id, + skillId: row.skill_id, + apiKeyId: row.api_key_id, + sessionId: row.session_id || "", + input: JSON.parse(row.input), + output: row.output ? JSON.parse(row.output) : null, + status: row.status, + errorMessage: row.error_message, + durationMs: row.duration_ms, + createdAt: new Date(row.created_at), + })); + } +}; +var skillExecutor = SkillExecutor.getInstance(); + +// open-sse/mcp-server/tools/skillTools.ts +var SkillListSchema = z5.object({ + apiKeyId: z5.string().optional(), + name: z5.string().optional(), + enabled: z5.boolean().optional(), +}); +var SkillEnableSchema = z5.object({ + apiKeyId: z5.string(), + skillId: z5.string(), + enabled: z5.boolean(), +}); +var SkillExecuteSchema = z5.object({ + apiKeyId: z5.string(), + skillName: z5.string(), + input: z5.record(z5.string(), z5.unknown()), + sessionId: z5.string().optional(), +}); +var skillTools = { + omniroute_skills_list: { + name: "omniroute_skills_list", + description: "List all registered skills with optional filtering by API key or name", + inputSchema: SkillListSchema, + handler: async (args) => { + await skillRegistry.loadFromDatabase(args.apiKeyId); + const skills = skillRegistry.list(args.apiKeyId); + let filtered = skills; + if (args.name) { + filtered = filtered.filter((s) => s.name.includes(args.name)); + } + if (args.enabled !== void 0) { + filtered = filtered.filter((s) => s.enabled === args.enabled); + } + return { + skills: filtered.map((s) => ({ + id: s.id, + name: s.name, + version: s.version, + description: s.description, + enabled: s.enabled, + createdAt: s.createdAt.toISOString(), + })), + count: filtered.length, + }; + }, + }, + omniroute_skills_enable: { + name: "omniroute_skills_enable", + description: "Enable or disable a specific skill by ID", + inputSchema: SkillEnableSchema, + handler: async (args) => { + const skill = skillRegistry.getSkill(args.skillId, args.apiKeyId); + if (!skill) { + throw new Error(`Skill not found: ${args.skillId}`); + } + await skillRegistry.register({ + ...skill, + enabled: args.enabled, + apiKeyId: args.apiKeyId, + }); + return { success: true, skillId: args.skillId, enabled: args.enabled }; + }, + }, + omniroute_skills_execute: { + name: "omniroute_skills_execute", + description: "Execute a skill with provided input and return the result", + inputSchema: SkillExecuteSchema, + handler: async (args) => { + const execution = await skillExecutor.execute(args.skillName, args.input, { + apiKeyId: args.apiKeyId, + sessionId: args.sessionId, + }); + return { + id: execution.id, + skillId: execution.skillId, + status: execution.status, + output: execution.output, + error: execution.errorMessage, + duration: execution.durationMs, + createdAt: execution.createdAt.toISOString(), + }; + }, + }, + omniroute_skills_executions: { + name: "omniroute_skills_executions", + description: "List recent skill execution history", + inputSchema: z5.object({ + apiKeyId: z5.string().optional(), + limit: z5.number().int().positive().max(100).optional(), + }), + handler: async (args) => { + const executions = skillExecutor.listExecutions(args.apiKeyId, args.limit || 50); + return { + executions: executions.map((e) => ({ + id: e.id, + skillId: e.skillId, + status: e.status, + duration: e.durationMs, + error: e.errorMessage, + createdAt: e.createdAt.toISOString(), + })), + count: executions.length, + }; + }, + }, +}; + +// open-sse/mcp-server/server.ts +var OMNIROUTE_BASE_URL2 = process.env.OMNIROUTE_BASE_URL || "http://localhost:20128"; +var OMNIROUTE_API_KEY2 = process.env.OMNIROUTE_API_KEY || ""; +var MCP_ENFORCE_SCOPES = process.env.OMNIROUTE_MCP_ENFORCE_SCOPES === "true"; +var MCP_ALLOWED_SCOPES = new Set( + (process.env.OMNIROUTE_MCP_SCOPES || "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean) +); +function toRecord2(value) { + return value && typeof value === "object" && !Array.isArray(value) ? value : {}; +} +function toArray(value) { + return Array.isArray(value) ? value : []; +} +function toString2(value, fallback = "") { + return typeof value === "string" ? value : fallback; +} +function toNumber3(value, fallback = 0) { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} +function toStringArray(value, fallback = []) { + const values = toArray(value).filter((entry) => typeof entry === "string"); + return values.length > 0 ? values : fallback; +} +function normalizeComboModels(rawModels) { + return toArray(rawModels).map((rawModel, index) => { + const model = toRecord2(rawModel); + return { + provider: toString2(model.provider, "unknown"), + model: toString2(model.model, "unknown"), + priority: toNumber3(model.priority, index + 1), + }; + }); +} +async function omniRouteFetch(path4, options = {}) { + const url = `${OMNIROUTE_BASE_URL2}${path4}`; + const headers = { + "Content-Type": "application/json", + ...(OMNIROUTE_API_KEY2 ? { Authorization: `Bearer ${OMNIROUTE_API_KEY2}` } : {}), + ...(options.headers || {}), + }; + const response = await fetch(url, { ...options, headers, signal: AbortSignal.timeout(1e4) }); + if (!response.ok) { + const errorText = await response.text().catch(() => "Unknown error"); + throw new Error(`OmniRoute API error [${response.status}]: ${errorText}`); + } + return response.json(); +} +function withScopeEnforcement(toolName, handler) { + return async (args, extra) => { + const scopeContext = resolveCallerScopeContext(extra, Array.from(MCP_ALLOWED_SCOPES)); + const scopeCheck = evaluateToolScopes(toolName, scopeContext.scopes, MCP_ENFORCE_SCOPES); + if (!scopeCheck.allowed) { + const missingScopes = + scopeCheck.missing.length > 0 ? scopeCheck.missing.join(", ") : "unavailable"; + const reason = scopeCheck.reason || "scope_check_failed"; + const msg = `Insufficient MCP scopes for ${toolName}. Missing: ${missingScopes}. Caller=${scopeContext.callerId}, source=${scopeContext.source}.`; + const safeArgs = args && typeof args === "object" ? toRecord2(args) : { rawArgs: args }; + await logToolCall( + toolName, + { + ...safeArgs, + _scopeCheck: { + callerId: scopeContext.callerId, + source: scopeContext.source, + required: scopeCheck.required, + provided: scopeCheck.provided, + missing: scopeCheck.missing, + }, + }, + null, + 0, + false, + `scope_denied:${reason}` + ); + return { + content: [{ type: "text", text: `Error: ${msg}` }], + isError: true, + }; + } + return handler(args, extra); + }; +} +async function handleGetHealth() { + const start = Date.now(); + try { + const [healthRaw, resilienceRaw, rateLimitsRaw] = await Promise.allSettled([ + omniRouteFetch("/api/monitoring/health"), + omniRouteFetch("/api/resilience"), + omniRouteFetch("/api/rate-limits"), + ]); + const health = healthRaw.status === "fulfilled" ? toRecord2(healthRaw.value) : {}; + const resilience = resilienceRaw.status === "fulfilled" ? toRecord2(resilienceRaw.value) : {}; + const rateLimits = rateLimitsRaw.status === "fulfilled" ? toRecord2(rateLimitsRaw.value) : {}; + const memoryUsageRaw = toRecord2(health.memoryUsage); + const cacheStatsRaw = toRecord2(health.cacheStats); + const resilienceCircuitBreakers = toArray(resilience.circuitBreakers); + const rateLimitEntries = toArray(rateLimits.limits); + const result = { + uptime: toString2(health.uptime, "unknown"), + version: toString2(health.version, "unknown"), + memoryUsage: { + heapUsed: toNumber3(memoryUsageRaw.heapUsed, 0), + heapTotal: toNumber3(memoryUsageRaw.heapTotal, 0), + }, + circuitBreakers: resilienceCircuitBreakers, + rateLimits: rateLimitEntries, + cacheStats: + Object.keys(cacheStatsRaw).length > 0 + ? { + hits: toNumber3(cacheStatsRaw.hits, 0), + misses: toNumber3(cacheStatsRaw.misses, 0), + hitRate: toNumber3(cacheStatsRaw.hitRate, 0), + } + : void 0, + }; + await logToolCall("omniroute_get_health", {}, result, Date.now() - start, true); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + await logToolCall("omniroute_get_health", {}, null, Date.now() - start, false, msg); + return { content: [{ type: "text", text: `Error: ${msg}` }], isError: true }; + } +} +async function handleListCombos(args) { + const start = Date.now(); + try { + const combosRaw = await omniRouteFetch("/api/combos"); + const combosRecord = toRecord2(combosRaw); + const combos = Array.isArray(combosRecord.combos) + ? combosRecord.combos + : Array.isArray(combosRaw) + ? combosRaw + : []; + let metrics = {}; + if (args.includeMetrics) { + metrics = toRecord2(await omniRouteFetch("/api/combos/metrics").catch(() => ({}))); + } + const result = { + combos: toArray(combos).map((rawCombo) => { + const combo = toRecord2(rawCombo); + const comboData = toRecord2(combo.data); + const comboId = toString2(combo.id, ""); + const modelsSource = + Array.isArray(combo.models) && combo.models.length > 0 ? combo.models : comboData.models; + return { + id: comboId, + name: toString2(combo.name, comboId || "unnamed"), + models: normalizeComboModels(modelsSource), + strategy: toString2(combo.strategy, toString2(comboData.strategy, "priority")), + enabled: combo.enabled !== false, + ...(args.includeMetrics ? { metrics: metrics[comboId] ?? null } : {}), + }; + }), + }; + await logToolCall("omniroute_list_combos", args, result, Date.now() - start, true); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + await logToolCall("omniroute_list_combos", args, null, Date.now() - start, false, msg); + return { content: [{ type: "text", text: `Error: ${msg}` }], isError: true }; + } +} +async function handleGetComboMetrics(args) { + const start = Date.now(); + try { + const result = await omniRouteFetch( + `/api/combos/metrics?comboId=${encodeURIComponent(args.comboId)}` + ); + await logToolCall("omniroute_get_combo_metrics", args, result, Date.now() - start, true); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + await logToolCall("omniroute_get_combo_metrics", args, null, Date.now() - start, false, msg); + return { content: [{ type: "text", text: `Error: ${msg}` }], isError: true }; + } +} +async function handleSwitchCombo(args) { + const start = Date.now(); + try { + const result = await omniRouteFetch(`/api/combos/${encodeURIComponent(args.comboId)}`, { + method: "PUT", + body: JSON.stringify({ isActive: args.active }), + }); + await logToolCall("omniroute_switch_combo", args, result, Date.now() - start, true); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + await logToolCall("omniroute_switch_combo", args, null, Date.now() - start, false, msg); + return { content: [{ type: "text", text: `Error: ${msg}` }], isError: true }; + } +} +async function handleCheckQuota(args) { + const start = Date.now(); + try { + let path4 = "/api/usage/quota"; + if (args.connectionId) path4 += `?connectionId=${encodeURIComponent(args.connectionId)}`; + else if (args.provider) path4 += `?provider=${encodeURIComponent(args.provider)}`; + const result = normalizeQuotaResponse(await omniRouteFetch(path4), { + provider: args.provider || null, + connectionId: args.connectionId || null, + }); + await logToolCall("omniroute_check_quota", args, result, Date.now() - start, true); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + await logToolCall("omniroute_check_quota", args, null, Date.now() - start, false, msg); + return { content: [{ type: "text", text: `Error: ${msg}` }], isError: true }; + } +} +async function handleRouteRequest(args) { + const start = Date.now(); + try { + const body = { + model: args.model, + messages: args.messages, + stream: false, + // MCP tool always returns non-streaming + }; + if (args.combo) { + body["x-combo"] = args.combo; + } + const raw = await omniRouteFetch("/v1/chat/completions", { + method: "POST", + body: JSON.stringify(body), + }); + const choices = toArray(raw.choices); + const firstChoice = toRecord2(choices[0]); + const firstMessage = toRecord2(firstChoice.message); + const usage = toRecord2(raw.usage); + const result = { + response: { + content: toString2(firstMessage.content, ""), + model: toString2(raw.model, args.model), + tokens: { + prompt: toNumber3(usage.prompt_tokens, 0), + completion: toNumber3(usage.completion_tokens, 0), + }, + }, + routing: { + provider: toString2(raw.provider, "unknown"), + combo: raw.combo ?? null, + fallbacksTriggered: toNumber3(raw.fallbacksTriggered, 0), + cost: toNumber3(raw.cost, 0), + latencyMs: Date.now() - start, + routingExplanation: toString2( + raw.routingExplanation, + "Request routed through primary provider" + ), + }, + }; + await logToolCall( + "omniroute_route_request", + { model: args.model, messageCount: args.messages.length }, + result.routing, + Date.now() - start, + true + ); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + await logToolCall( + "omniroute_route_request", + { model: args.model }, + null, + Date.now() - start, + false, + msg + ); + return { content: [{ type: "text", text: `Error: ${msg}` }], isError: true }; + } +} +async function handleCostReport(args) { + const start = Date.now(); + try { + const period = args.period || "session"; + const rangeMap = { + session: "1d", + day: "1d", + week: "7d", + month: "30d", + }; + const range = rangeMap[period] || "30d"; + const raw = toRecord2( + await omniRouteFetch(`/api/usage/analytics?range=${encodeURIComponent(range)}`) + ); + const tokenCount = toRecord2(raw.tokenCount); + const budget = toRecord2(raw.budget); + const result = { + period, + totalCost: toNumber3(raw.totalCost, 0), + requestCount: toNumber3(raw.requestCount, 0), + tokenCount: { + prompt: toNumber3(tokenCount.prompt, 0), + completion: toNumber3(tokenCount.completion, 0), + }, + byProvider: toArray(raw.byProvider), + byModel: toArray(raw.byModel), + budget: { + limit: budget.limit ?? null, + remaining: budget.remaining ?? null, + }, + }; + await logToolCall("omniroute_cost_report", args, result, Date.now() - start, true); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + await logToolCall("omniroute_cost_report", args, null, Date.now() - start, false, msg); + return { content: [{ type: "text", text: `Error: ${msg}` }], isError: true }; + } +} +async function handleListModelsCatalog(args) { + const start = Date.now(); + try { + let path4 = "/v1/models"; + let isProviderSpecific = false; + let source = "local_catalog"; + let warning; + if (args.provider && !args.capability) { + path4 = `/api/providers/${encodeURIComponent(args.provider)}/models?excludeHidden=true`; + isProviderSpecific = true; + } else { + const params = new URLSearchParams(); + if (args.provider) params.set("provider", args.provider); + if (args.capability) params.set("capability", args.capability); + if (params.toString()) path4 += `?${params.toString()}`; + } + const raw = toRecord2(await omniRouteFetch(path4)); + let rawModels = []; + if (isProviderSpecific) { + rawModels = Array.isArray(raw.models) ? raw.models : []; + source = typeof raw.source === "string" ? raw.source : "api"; + if (raw.warning) warning = String(raw.warning); + } else { + rawModels = Array.isArray(raw.data) ? raw.data : []; + source = "local_catalog"; + } + const result = { + models: rawModels.map((rawModel) => { + const model = toRecord2(rawModel); + return { + id: toString2(model.id, ""), + provider: toString2( + model.owned_by, + toString2(model.provider, args.provider || "unknown") + ), + capabilities: toStringArray(model.capabilities, ["chat"]), + status: toString2(model.status, "available"), + pricing: model.pricing, + }; + }), + source, + ...(warning ? { warning } : {}), + }; + await logToolCall( + "omniroute_list_models_catalog", + args, + { modelCount: result.models.length }, + Date.now() - start, + true + ); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + await logToolCall("omniroute_list_models_catalog", args, null, Date.now() - start, false, msg); + return { content: [{ type: "text", text: `Error: ${msg}` }], isError: true }; + } +} +function createMcpServer() { + const server = new McpServer({ + name: "omniroute", + version: process.env.npm_package_version || "1.8.1", + }); + server.registerTool( + "omniroute_get_health", + { + description: + "Returns OmniRoute health status including uptime, memory, circuit breakers, rate limits, and cache stats", + inputSchema: getHealthInput, + }, + withScopeEnforcement("omniroute_get_health", async (args) => { + getHealthInput.parse(args ?? {}); + return handleGetHealth(); + }) + ); + server.registerTool( + "omniroute_list_combos", + { + description: + "Lists all configured combos (model chains) with strategies and optional metrics", + inputSchema: listCombosInput, + }, + withScopeEnforcement("omniroute_list_combos", (args) => + handleListCombos(listCombosInput.parse(args)) + ) + ); + server.registerTool( + "omniroute_get_combo_metrics", + { + description: "Returns detailed performance metrics for a specific combo", + inputSchema: getComboMetricsInput, + }, + withScopeEnforcement("omniroute_get_combo_metrics", (args) => + handleGetComboMetrics(getComboMetricsInput.parse(args)) + ) + ); + server.registerTool( + "omniroute_switch_combo", + { + description: "Activates or deactivates a combo for routing", + inputSchema: switchComboInput, + }, + withScopeEnforcement("omniroute_switch_combo", (args) => + handleSwitchCombo(switchComboInput.parse(args)) + ) + ); + server.registerTool( + "omniroute_check_quota", + { + description: "Checks remaining API quota for one or all providers", + inputSchema: checkQuotaInput, + }, + withScopeEnforcement("omniroute_check_quota", (args) => + handleCheckQuota(checkQuotaInput.parse(args)) + ) + ); + server.registerTool( + "omniroute_route_request", + { + description: "Sends a chat completion request through OmniRoute intelligent routing", + inputSchema: routeRequestInput, + }, + withScopeEnforcement("omniroute_route_request", (args) => + handleRouteRequest(routeRequestInput.parse(args)) + ) + ); + server.registerTool( + "omniroute_cost_report", + { + description: "Generates a cost report for the specified period", + inputSchema: costReportInput, + }, + withScopeEnforcement("omniroute_cost_report", (args) => + handleCostReport(costReportInput.parse(args)) + ) + ); + server.registerTool( + "omniroute_list_models_catalog", + { + description: "Lists all available AI models across providers with capabilities and pricing", + inputSchema: listModelsCatalogInput, + }, + withScopeEnforcement("omniroute_list_models_catalog", (args) => + handleListModelsCatalog(listModelsCatalogInput.parse(args)) + ) + ); + server.registerTool( + "omniroute_simulate_route", + { + description: "Simulates the routing path a request would take without executing it (dry-run)", + inputSchema: simulateRouteInput, + }, + withScopeEnforcement("omniroute_simulate_route", (args) => + handleSimulateRoute(simulateRouteInput.parse(args)) + ) + ); + server.registerTool( + "omniroute_set_budget_guard", + { + description: + "Sets a session budget limit with configurable action when exceeded (degrade/block/alert)", + inputSchema: setBudgetGuardInput, + }, + withScopeEnforcement("omniroute_set_budget_guard", (args) => + handleSetBudgetGuard(setBudgetGuardInput.parse(args)) + ) + ); + server.registerTool( + "omniroute_set_routing_strategy", + { + description: + "Updates combo routing strategy at runtime (priority/weighted/round-robin/auto/etc.)", + inputSchema: setRoutingStrategyInput, + }, + withScopeEnforcement("omniroute_set_routing_strategy", (args) => + handleSetRoutingStrategy(setRoutingStrategyInput.parse(args)) + ) + ); + server.registerTool( + "omniroute_set_resilience_profile", + { + description: + "Applies a resilience profile controlling circuit breakers, retries, timeouts, and fallback depth", + inputSchema: setResilienceProfileInput, + }, + withScopeEnforcement("omniroute_set_resilience_profile", (args) => + handleSetResilienceProfile(setResilienceProfileInput.parse(args)) + ) + ); + server.registerTool( + "omniroute_test_combo", + { + description: + "Tests each provider in a combo with a real prompt, reporting latency, cost, and success per provider", + inputSchema: testComboInput, + }, + withScopeEnforcement("omniroute_test_combo", (args) => + handleTestCombo(testComboInput.parse(args)) + ) + ); + server.registerTool( + "omniroute_get_provider_metrics", + { + description: + "Returns detailed metrics for a specific provider including latency percentiles and circuit breaker state", + inputSchema: getProviderMetricsInput, + }, + withScopeEnforcement("omniroute_get_provider_metrics", (args) => + handleGetProviderMetrics(getProviderMetricsInput.parse(args)) + ) + ); + server.registerTool( + "omniroute_best_combo_for_task", + { + description: + "Recommends the best combo for a task type based on provider fitness and constraints", + inputSchema: bestComboForTaskInput, + }, + withScopeEnforcement("omniroute_best_combo_for_task", (args) => + handleBestComboForTask(bestComboForTaskInput.parse(args)) + ) + ); + server.registerTool( + "omniroute_explain_route", + { + description: + "Explains why a request was routed to a specific provider, showing scoring factors and fallbacks", + inputSchema: explainRouteInput, + }, + withScopeEnforcement("omniroute_explain_route", (args) => + handleExplainRoute(explainRouteInput.parse(args)) + ) + ); + server.registerTool( + "omniroute_get_session_snapshot", + { + description: + "Returns a full snapshot of the current working session: cost, tokens, top models, errors, budget status", + inputSchema: getSessionSnapshotInput, + }, + withScopeEnforcement("omniroute_get_session_snapshot", async (args) => { + getSessionSnapshotInput.parse(args ?? {}); + return handleGetSessionSnapshot(); + }) + ); + server.registerTool( + "omniroute_sync_pricing", + { + description: + "Syncs pricing data from external sources (LiteLLM) into OmniRoute without overwriting user-set prices", + inputSchema: syncPricingInput, + }, + withScopeEnforcement("omniroute_sync_pricing", (args) => + handleSyncPricing(syncPricingInput.parse(args)) + ) + ); + Object.values(memoryTools).forEach((toolDef) => { + server.registerTool( + toolDef.name, + { + description: toolDef.description, + // @ts-ignore: dynamic zod access + inputSchema: toolDef.inputSchema, + }, + withScopeEnforcement(toolDef.name, async (args) => { + try { + const parsedArgs = toolDef.inputSchema.parse(args ?? {}); + const result = await toolDef.handler(parsedArgs); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { content: [{ type: "text", text: `Error: ${msg}` }], isError: true }; + } + }) + ); + }); + Object.values(skillTools).forEach((toolDef) => { + server.registerTool( + toolDef.name, + { + description: toolDef.description, + // @ts-ignore: dynamic zod access + inputSchema: toolDef.inputSchema, + }, + withScopeEnforcement(toolDef.name, async (args) => { + try { + const parsedArgs = toolDef.inputSchema.parse(args ?? {}); + const result = await toolDef.handler(parsedArgs); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { content: [{ type: "text", text: `Error: ${msg}` }], isError: true }; + } + }) + ); + }); + return server; +} +async function startMcpStdio() { + const server = createMcpServer(); + const transport = new StdioServerTransport(); + const version = process.env.npm_package_version || "1.8.1"; + const stopHeartbeat = startMcpHeartbeat({ + version, + scopesEnforced: MCP_ENFORCE_SCOPES, + allowedScopes: Array.from(MCP_ALLOWED_SCOPES), + toolCount: MCP_TOOLS.length, + }); + const stopHeartbeatOnce = () => { + stopHeartbeat(); + }; + process.once("exit", stopHeartbeatOnce); + process.once("SIGINT", stopHeartbeatOnce); + process.once("SIGTERM", stopHeartbeatOnce); + console.error("[MCP] OmniRoute MCP Server starting (stdio transport)..."); + try { + await server.connect(transport); + console.error("[MCP] OmniRoute MCP Server connected and ready."); + } finally { + stopHeartbeatOnce(); + process.off("exit", stopHeartbeatOnce); + process.off("SIGINT", stopHeartbeatOnce); + process.off("SIGTERM", stopHeartbeatOnce); + } +} +if (process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/\\/g, "/"))) { + startMcpStdio().catch((err) => { + console.error("[MCP] Fatal error:", err); + process.exit(1); + }); +} +export { createMcpServer, startMcpStdio }; diff --git a/tests/unit/cc-compatible-model-catalog.test.mjs b/tests/unit/cc-compatible-model-catalog.test.mjs new file mode 100644 index 0000000000..2664bb5b39 --- /dev/null +++ b/tests/unit/cc-compatible-model-catalog.test.mjs @@ -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 + ); +}); diff --git a/tests/unit/cc-compatible-provider.test.mjs b/tests/unit/cc-compatible-provider.test.mjs index ddae9a8640..022668a1e9 100644 --- a/tests/unit/cc-compatible-provider.test.mjs +++ b/tests/unit/cc-compatible-provider.test.mjs @@ -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; diff --git a/tests/unit/claude-oauth-provider.test.mjs b/tests/unit/claude-oauth-provider.test.mjs new file mode 100644 index 0000000000..479a14429c --- /dev/null +++ b/tests/unit/claude-oauth-provider.test.mjs @@ -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"); +}); diff --git a/tests/unit/t27-github-copilot-response-format.test.mjs b/tests/unit/t27-github-copilot-response-format.test.mjs index 618413584f..83945d9be8 100644 --- a/tests/unit/t27-github-copilot-response-format.test.mjs +++ b/tests/unit/t27-github-copilot-response-format.test.mjs @@ -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; + } +}); diff --git a/tests/unit/t28-model-catalog-updates.test.mjs b/tests/unit/t28-model-catalog-updates.test.mjs index a1d3440bdb..4b0c796e55 100644 --- a/tests/unit/t28-model-catalog-updates.test.mjs +++ b/tests/unit/t28-model-catalog-updates.test.mjs @@ -5,12 +5,14 @@ import { getModelInfoCore } from "../../open-sse/services/model.ts"; import { REGISTRY } from "../../open-sse/config/providerRegistry.ts"; import { getStaticModelsForProvider } from "../../src/app/api/providers/[id]/models/route.ts"; -test("T28: gemini catalog includes preview models from 9router", () => { +test("T28: gemini-cli catalog includes preview models, gemini uses API sync", () => { + // Gemini (AI Studio) no longer has a hardcoded registry — models come from + // API sync via /api/providers/:id/models with pageSize=1000. const geminiIds = REGISTRY.gemini.models.map((m) => m.id); - const geminiCliIds = REGISTRY["gemini-cli"].models.map((m) => m.id); + assert.equal(geminiIds.length, 0, "gemini models should be empty (populated by API sync)"); - assert.ok(geminiIds.includes("gemini-3.1-flash-lite-preview")); - assert.ok(geminiIds.includes("gemini-3-flash-preview")); + // gemini-cli still has hardcoded models (Cloud Code doesn't have a models API) + const geminiCliIds = REGISTRY["gemini-cli"].models.map((m) => m.id); assert.ok(geminiCliIds.includes("gemini-3.1-flash-lite-preview")); assert.ok(geminiCliIds.includes("gemini-3-flash-preview")); }); diff --git a/tests/unit/t31-t33-t34-t38-model-specs.test.mjs b/tests/unit/t31-t33-t34-t38-model-specs.test.mjs index 23c5a50680..9a067c50c0 100644 --- a/tests/unit/t31-t33-t34-t38-model-specs.test.mjs +++ b/tests/unit/t31-t33-t34-t38-model-specs.test.mjs @@ -2,6 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; const { REGISTRY } = await import("../../open-sse/config/providerRegistry.ts"); +const { getStaticModelsForProvider } = await import("../../src/app/api/providers/[id]/models/route.ts"); const { resolveModelAlias: resolveDeprecatedAlias } = await import("../../open-sse/services/modelDeprecation.ts"); const { normalizeThinkingLevel } = await import("../../open-sse/services/thinkingBudget.ts"); @@ -14,10 +15,12 @@ const { capThinkingBudget, } = await import("../../src/shared/constants/modelSpecs.ts"); -test("T31: registry exposes Gemini 3.1 Pro High/Low model IDs", () => { - const geminiIds = REGISTRY.gemini.models.map((m) => m.id); - assert.ok(geminiIds.includes("gemini-3.1-pro-high")); - assert.ok(geminiIds.includes("gemini-3.1-pro-low")); +test("T31: antigravity static catalog exposes Gemini 3.1 Pro High/Low model IDs", () => { + // gemini-3.1-pro-high/low are Antigravity (Cloud Code sandbox) models, + // not Gemini AI Studio models. They live in the static catalog, not the registry. + const staticIds = (getStaticModelsForProvider("antigravity") || []).map((m) => m.id); + assert.ok(staticIds.includes("gemini-3.1-pro-high")); + assert.ok(staticIds.includes("gemini-3.1-pro-low")); }); test("T31: legacy Gemini aliases resolve to Gemini 3.1 IDs", () => {