import crypto from 'crypto'; import { Router } from 'express'; import type { Request, Response } from 'express'; import { z } from 'zod'; import type { ChatMessage, ModelListRow } from '@freellmapi/shared/types.js'; import { routeRequest, recordRateLimitHit, recordSuccess, hasEnabledVisionModel, hasEnabledToolsModel, type RouteResult } from '../services/router.js'; import { recordRequest, recordTokens, setCooldown, getCooldownDurationForLimit, PAYMENT_REQUIRED_COOLDOWN_MS, MODEL_FORBIDDEN_COOLDOWN_MS } from '../services/ratelimit.js'; import { pruneRequestAnalytics } from '../services/request-retention.js'; import { runEmbeddings, EmbeddingsError } from '../services/embeddings.js'; import { getDb, getUnifiedApiKey } from '../db/index.js'; import { contentToString, messageHasImage, normalizeOutboundContent } from '../lib/content.js'; import { repairToolArguments, toolSchemaMap } from '../lib/tool-args.js'; import { sanitizeProviderErrorMessage } from '../lib/error-redaction.js'; import { rescueInlineToolCalls, startsWithDialectMarker, couldBecomeDialectMarker, containsDialectMarker } from '../lib/tool-call-rescue.js'; export const proxyRouter = Router(); // Virtual "auto" model. Clients like Hermes require a non-empty `model` field // on every request, but freellmapi's whole point is to pick the model itself. // Requesting this id means "let the router decide" — identical to omitting // `model` entirely. const AUTO_MODEL_ID = 'auto'; function isAutoModel(modelId: string | undefined): boolean { return modelId === AUTO_MODEL_ID; } // Constant-time string comparison for the unified API key. Plain `===` leaks // length and per-character timing, which a network attacker could in principle // use to recover the key one byte at a time. export function timingSafeStringEqual(provided: string, expected: string): boolean { // Use HMAC to produce fixed-length digests so timingSafeEqual always // receives same-length buffers regardless of input length. This eliminates // both the per-character timing leak and the length-branch timing leak that // the Buffer.alloc-on-mismatch approach had. const key = Buffer.alloc(32); const a = crypto.createHmac('sha256', key).update(provided).digest(); const b = crypto.createHmac('sha256', key).update(expected).digest(); return crypto.timingSafeEqual(a, b); } // Extract the unified API key from an incoming request. Accepts both the // OpenAI-style `Authorization: Bearer ` header and the Anthropic-style // `x-api-key` header. Clients that speak the Anthropic wire format — notably // Claude Code routed through CC Switch (#103) — send the key in `x-api-key` // rather than a bearer token, and were getting a spurious "Invalid API key" // 401 before this fallback existed. export function extractApiToken(req: Request): string | undefined { const bearer = req.headers.authorization?.replace(/^Bearer\s+/i, '').trim(); if (bearer) return bearer; const apiKeyHeader = req.headers['x-api-key']; const xApiKey = Array.isArray(apiKeyHeader) ? apiKeyHeader[0] : apiKeyHeader; const trimmed = xApiKey?.trim(); return trimmed || undefined; } // Sticky sessions: track which model served each "session" // Key: hash of first user message → model_db_id // This prevents model switching mid-conversation which causes hallucination const stickySessionMap = new Map(); const STICKY_TTL_MS = 30 * 60 * 1000; // 30 min session TTL function getSessionKey(messages: ChatMessage[], sessionIdHeader?: string): string { // Explicit session pinning: clients that manage their own conversation ids // (agent harnesses especially) can send X-Session-Id and get exact // affinity regardless of how their message history mutates. (#231) if (sessionIdHeader) return `hdr:${sessionIdHeader}`; // Otherwise the first user message identifies the session — clients re-send // the full conversation each turn, so it is stable across turns. Flatten // array-of-blocks content before hashing: opencode-style agents send // [{type:'text',...}] even for plain text, and the old string-only check // silently disabled stickiness for them, re-routing every turn (#231 audit: // observed a rank-2 → rank-11 mid-conversation flip). No turn-count suffix: // the old ':single'/':multi' split guaranteed a sticky MISS on turn 2, // exactly where agents replay the assistant's tool-call dialect and a model // switch causes cross-dialect contamination. const firstUser = messages.find(m => m.role === 'user'); if (!firstUser) return ''; const text = contentToString(firstUser.content ?? ''); if (!text) return ''; return crypto.createHash('sha1').update(text).digest('hex'); } export function getStickyModel(messages: ChatMessage[], sessionIdHeader?: string): number | undefined { // Only apply sticky for multi-turn (has assistant messages = continuation) const hasAssistant = messages.some(m => m.role === 'assistant'); if (!hasAssistant) return undefined; const key = getSessionKey(messages, sessionIdHeader); if (!key) return undefined; const entry = stickySessionMap.get(key); if (!entry) return undefined; if (Date.now() - entry.lastUsed > STICKY_TTL_MS) { stickySessionMap.delete(key); return undefined; } return entry.modelDbId; } export function setStickyModel(messages: ChatMessage[], modelDbId: number, sessionIdHeader?: string) { const key = getSessionKey(messages, sessionIdHeader); if (!key) return; stickySessionMap.set(key, { modelDbId, lastUsed: Date.now() }); // Cleanup old entries if (stickySessionMap.size > 500) { const now = Date.now(); for (const [k, v] of stickySessionMap) { if (now - v.lastUsed > STICKY_TTL_MS) stickySessionMap.delete(k); } } } // OpenAI-compatible /models endpoint (used by Hermes for metadata) // shows API models which is linked by the user proxyRouter.get('/models', (req: Request, res: Response) => { const token = extractApiToken(req); const unifiedKey = getUnifiedApiKey(); if (!token || !timingSafeStringEqual(token, unifiedKey)) { res.status(401).json({ error: { message: 'Invalid API key', type: 'authentication_error' } }); return; } const db = getDb(); const models = db.prepare(` SELECT platform, model_id, display_name, context_window FROM ( SELECT platform, model_id, display_name, context_window, intelligence_rank, id, ROW_NUMBER() OVER ( PARTITION BY model_id ORDER BY intelligence_rank ASC, id ASC ) AS rn FROM models m WHERE m.enabled = 1 AND EXISTS ( SELECT 1 FROM api_keys k WHERE k.platform = m.platform AND k.enabled = 1 AND (m.key_id IS NULL OR k.id = m.key_id) ) ) WHERE rn = 1 ORDER BY intelligence_rank ASC, id ASC `).all() as ModelListRow[]; res.json({ object: 'list', data: [ { id: AUTO_MODEL_ID, object: 'model', created: 0, owned_by: 'freellmapi', name: 'Auto (router picks the best available model)', context_window: null, }, ...models.map(m => ({ id: m.model_id, object: 'model', created: 0, owned_by: m.platform, name: m.display_name, context_window: m.context_window, })), ], }); }); const MAX_RETRIES = 20; // Echo-tolerant tool calls: agents replay OUR responses back as history, and // not all of them preserve the strict OpenAI shape. `type` may be dropped // (re-added on forward), Gemini-lineage agents (Qwen Code, AionUI) often // send `arguments` as a parsed object instead of a JSON string, and `id` may // be missing or empty (ids aren't a Gemini concept) — all get normalized // below rather than 400-ing the whole session. Missing ids are synthesized // and paired with their tool-result messages by order. (#200) const toolCallSchema = z.object({ id: z.string().optional(), type: z.literal('function').optional(), function: z.object({ name: z.string().min(1), arguments: z.union([z.string(), z.record(z.string(), z.unknown())]), }), thought_signature: z.string().optional(), }); const toolCallArgsToString = (args: string | Record): string => typeof args === 'string' ? args : JSON.stringify(args); // OpenAI multimodal envelope. Clients like opencode / continue.dev send // content as an array of typed blocks even when only text is present, and // Gemini-lineage agents send part-style blocks like `{ "text": "..." }` with // no `type` at all. Accept any object (or bare string) as a block; flatten to // string for providers that don't support arrays (Cohere, Cloudflare). // Non-text blocks pass z validation but get dropped by contentToString — // vision/audio still isn't supported. (#200) const contentBlockSchema = z.union([z.string(), z.record(z.string(), z.unknown())]); const contentSchema = z.union([z.string(), z.array(contentBlockSchema)]); const systemMessageSchema = z.object({ role: z.literal('system'), content: contentSchema, name: z.string().optional(), }); // OpenAI's newer SDKs send the system prompt as role:"developer"; accept it // and forward as "system" — none of the routed providers know the developer // role. (#200) const developerMessageSchema = z.object({ role: z.literal('developer'), content: contentSchema, name: z.string().optional(), }); const userMessageSchema = z.object({ role: z.literal('user'), content: contentSchema, name: z.string().optional(), }); // Assistant turns may carry empty/null content and no tool_calls — OpenAI // accepts these in conversation history (a turn that produced no visible text, // a placeholder, a tool turn whose content was emptied), and clients replay // them verbatim. We accept them too and coerce empty/null content to "" before // forwarding (see message build below) rather than 400-ing a payload OpenAI // would take. (#165) const assistantMessageSchema = z.object({ role: z.literal('assistant'), content: z.union([contentSchema, z.null()]).optional(), name: z.string().optional(), // tool_calls: null (not just missing) is what several agents replay for // no-tool assistant turns — aionrs (AionUI's engine) writes it into every // session-resumed assistant echo. Treated as absent. (#200) tool_calls: z.array(toolCallSchema).nullable().optional(), // Thinking trace echoed back by a client. DeepSeek thinking models on // OpenCode Zen 400 ("reasoning_content in thinking mode must be passed back") // unless the prior turn's reasoning_content is replayed, so keep it through // validation instead of stripping it. See issue #255. reasoning_content: z.string().nullable().optional(), }); // Tool results may arrive with null/missing content (a tool that returned // nothing) and a missing/empty tool_call_id (Gemini-lineage agents) — coerced // to "" and paired by order with the preceding tool_calls respectively. (#200) const toolMessageSchema = z.object({ role: z.literal('tool'), content: z.union([contentSchema, z.null()]).optional(), tool_call_id: z.string().optional(), name: z.string().optional(), }); // Legacy function-calling shape (pre-tools OpenAI API). Old clients still // replay these in history; forwarded as a tool message. (#200) const functionMessageSchema = z.object({ role: z.literal('function'), name: z.string().min(1), content: z.union([contentSchema, z.null()]).optional(), }); const toolDefinitionSchema = z.object({ // Some agents omit `type` on tool definitions; re-defaulted to 'function' // on forward. (#200) type: z.literal('function').optional(), function: z.object({ name: z.string().min(1), description: z.string().optional(), parameters: z.record(z.string(), z.unknown()).optional(), strict: z.boolean().optional(), }), }); const toolChoiceSchema = z.union([ // 'any' is the Mistral/Gemini wording for OpenAI's 'required'; mapped on // forward. (#200) z.enum(['none', 'auto', 'required', 'any']), z.object({ type: z.literal('function'), function: z.object({ name: z.string().min(1), }), }), ]); const chatCompletionSchema = z.object({ messages: z.array(z.union([ systemMessageSchema, developerMessageSchema, userMessageSchema, assistantMessageSchema, toolMessageSchema, functionMessageSchema, ])).min(1), model: z.string().optional(), temperature: z.number().min(0).max(2).optional(), // Some clients send max_tokens <= 0 (or -1) to mean "no limit"; accepted and // treated as unset on forward. (#200) max_tokens: z.number().int().optional(), top_p: z.number().min(0).max(1).optional(), stream: z.boolean().optional(), // Top-level tool knobs may arrive as explicit nulls from clients that // serialize every field of their request struct; all treated as absent // and never forwarded as null. (#200) tools: z.array(toolDefinitionSchema).nullable().optional(), tool_choice: toolChoiceSchema.nullable().optional(), parallel_tool_calls: z.boolean().nullable().optional(), }); export function isRetryableError(err: any): boolean { const msg = (err.message ?? '').toLowerCase(); return msg.includes('429') || msg.includes('rate limit') || msg.includes('too many requests') || msg.includes('quota') || msg.includes('resource_exhausted') || msg.includes('aborted') || msg.includes('timeout') || msg.includes('etimedout') || msg.includes('econnrefused') || msg.includes('econnreset') || msg.includes('503') || msg.includes('unavailable') || msg.includes('500') || msg.includes('internal server error') // 413: this model's payload limit is too small for the request, but another // provider in the fallback chain may have a larger limit. Same reasoning as 503. || msg.includes('413') || msg.includes('payload too large') || msg.includes('request body too large') || msg.includes('request entity too large') || msg.includes('content too large') // 404: model deprecated/removed upstream (e.g. OpenRouter's "no endpoints found" // for a model that's been pulled). Rotate to the next model in the chain — // setCooldown + the health checker will avoid this model on subsequent requests. || msg.includes('404') || msg.includes('not found') || msg.includes('no endpoints found') // 403: the key is valid (it passed validateKey, and the health checker // disables truly-forbidden keys) but this specific model is off-limits to // the key's tier — e.g. gpt-4o on GitHub Models' free tier, subscription-only // models on Cloudflare. Another model in the chain is reachable, so fail over // instead of 502-ing the whole request. Paired with isModelAccessForbiddenError // to rule the model out for this request and a day-long bench. See issue #256. || isModelAccessForbiddenError(err) // 400: one provider may reject parameters another accepts (e.g. max_tokens // limits, unsupported params). The matching pattern is "api error 400" // which comes from the OpenAI-compat provider's error formatting, not // a bare "400" which is deliberately non-retryable for validation errors. || msg.includes('api error 400') // 402: this provider/key is out of credits (e.g. HuggingFace Router // "API error 402: Payment required"). The SAME model often lives on another // provider (Kimi K2.6 is on HF + Cloudflare + NVIDIA), so fail over instead // of killing the workflow. Paired with a long cooldown (isPaymentRequiredError) // so we don't re-hammer the broke key every retry. || isPaymentRequiredError(err) // Dead-turn classes from the stream turn-integrity layer (#231 audit): // all thrown before any byte reached the client, so another model can // serve the request invisibly. || msg.includes('empty completion') || msg.includes('in-band provider error') || msg.includes('stream ended unexpectedly') || msg.includes('stream stalled') || msg.includes('unparseable inline tool-call dialect'); } // A 402 Payment Required / out-of-credits error. Distinct from a transient 429: // it won't recover on the next window, so the caller benches the model+key with // PAYMENT_REQUIRED_COOLDOWN_MS (a full day) rather than the 90s transient cooldown. export function isPaymentRequiredError(err: any): boolean { const msg = (err.message ?? '').toLowerCase(); return msg.includes('402') || msg.includes('payment required') || msg.includes('insufficient_quota') || msg.includes('insufficient credit') || msg.includes('insufficient balance'); } // A 404 "model removed/deprecated upstream" error. It's a MODEL-level failure, // not a key-level one: every key for the platform will 404 the same way, so the // retry loop skips the entire model for the rest of the request instead of // burning one fallback attempt per key on the same dead route. // (PR #111, credits @barbotkonv.) export function isModelNotFoundError(err: any): boolean { const msg = (err.message ?? '').toLowerCase(); return msg.includes('404') || msg.includes('not found') || msg.includes('no endpoints found'); } // A 403 Forbidden returned for a specific model behind an otherwise-valid key. // Drives the same whole-model skip as a 404: every key on this platform's tier // would be forbidden the same model, so rule it out for the rest of the request // rather than trying it again with a sibling key. Distinct from a dead key — // validateKey returns false on 401/403, so the health checker disables genuinely // forbidden keys; a 403 reaching here is model-not-on-this-tier. See issue #256. export function isModelAccessForbiddenError(err: any): boolean { if (err?.status === 403) return true; const msg = (err?.message ?? '').toLowerCase(); return msg.includes('403') || msg.includes('forbidden'); } // Pull the incremental text out of a streaming chunk for token counting. // Must tolerate chunks that carry no `choices` array at all: some providers // (e.g. Groq) emit usage/keepalive frames shaped like `{usage:{...}}` with no // `choices`. Indexing `chunk.choices[0]` on those throws "Cannot read // properties of undefined (reading '0')", which — once the SSE stream has // started — aborts the response mid-flight with no chance to fall back. export function streamChunkText(chunk: any): string { return chunk?.choices?.[0]?.delta?.content ?? ''; } // OpenAI-compatible embeddings endpoint, routed through the embeddings family // catalog: `model: "auto"` (or omitted) → the configured default family; a // family name or provider model id → that family's provider chain. Failover // only happens WITHIN a family (same model on another provider) — never across // models, since vectors from different models are incompatible. const EmbeddingsBody = z.object({ model: z.string().optional(), input: z.union([z.string(), z.array(z.string())]), }); proxyRouter.post('/embeddings', async (req: Request, res: Response) => { const token = extractApiToken(req); const unifiedKey = getUnifiedApiKey(); if (!token || !timingSafeStringEqual(token, unifiedKey)) { res.status(401).json({ error: { message: 'Invalid API key', type: 'authentication_error' } }); return; } const parsed = EmbeddingsBody.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: { message: 'Invalid request: `input` is required', type: 'invalid_request_error' } }); return; } const inputs = Array.isArray(parsed.data.input) ? parsed.data.input : [parsed.data.input]; try { const result = await runEmbeddings(parsed.data.model, inputs); res.json({ object: 'list', data: result.vectors.map((values, i) => ({ object: 'embedding', index: i, embedding: values })), model: result.family, provider: result.platform, usage: { prompt_tokens: result.inputTokens, total_tokens: result.inputTokens }, }); } catch (err: any) { const status = err instanceof EmbeddingsError ? err.status : 502; const type = status === 400 ? 'invalid_request_error' : status === 429 ? 'rate_limit_error' : 'server_error'; res.status(status).json({ error: { message: `embedding error: ${err?.message ?? 'unknown'}`, type } }); } }); proxyRouter.post('/chat/completions', async (req: Request, res: Response) => { const start = Date.now(); // Authenticate with the unified API key for every proxy request, including // loopback callers. Browser pages can reach localhost, so socket locality is // not a reliable authorization boundary. const token = extractApiToken(req); const unifiedKey = getUnifiedApiKey(); if (!token || !timingSafeStringEqual(token, unifiedKey)) { res.status(401).json({ error: { message: 'Invalid API key', type: 'authentication_error' }, }); return; } // Validate request const parsed = chatCompletionSchema.safeParse(req.body); if (!parsed.success) { // Path-qualified issues ("messages.1.content: Invalid input" beats a bare // "Invalid input") and a server-side breadcrumb — these rejections never // reach the request log, which made #200 nearly undebuggable. const detail = parsed.error.errors .map(e => (e.path.length ? `${e.path.join('.')}: ${e.message}` : e.message)) .slice(0, 5) .join(', '); console.warn(`[proxy] 400 invalid /chat/completions request: ${detail}`); res.status(400).json({ error: { message: `Invalid request: ${detail}`, type: 'invalid_request_error', }, }); return; } const { model: requestedModel, temperature, top_p, stream } = parsed.data; // Agent-tolerant knob normalization (#200): max_tokens <= 0 means "no // limit" in several clients → unset; tool_choice 'any' is OpenAI's // 'required'; tool definitions get their 'function' type re-defaulted. const max_tokens = parsed.data.max_tokens != null && parsed.data.max_tokens > 0 ? parsed.data.max_tokens : undefined; const tool_choice = parsed.data.tool_choice === 'any' ? 'required' as const : parsed.data.tool_choice ?? undefined; const tools = parsed.data.tools?.map(t => ({ ...t, type: 'function' as const })); const parallel_tool_calls = parsed.data.parallel_tool_calls ?? undefined; // Pairing state for id-less tool calls (#200): every tool_call id (given or // synthesized) queues up here; a tool message without a tool_call_id takes // the oldest unanswered one, which matches the single-call-per-turn flow // Gemini-lineage agents produce. const pendingToolCallIds: string[] = []; let syntheticIdCounter = 0; const takeToolCallId = (given: string | undefined): string => { if (given && given.length > 0) { const qi = pendingToolCallIds.indexOf(given); if (qi !== -1) pendingToolCallIds.splice(qi, 1); return given; } return pendingToolCallIds.shift() ?? `call_auto_${++syntheticIdCounter}`; }; const messages: ChatMessage[] = parsed.data.messages.map((m): ChatMessage => { if (m.role === 'assistant') { const hasToolCalls = (m.tool_calls?.length ?? 0) > 0; // With tool_calls, content: null is the correct OpenAI shape — keep it. // Without tool_calls, coerce empty/null content to "" so strict upstreams // don't choke on a null-content assistant turn we just accepted. (#165) const isEmptyContent = m.content == null || (typeof m.content === 'string' && m.content.length === 0) || (Array.isArray(m.content) && m.content.length === 0); const assistantContent: ChatMessage['content'] = hasToolCalls ? (m.content ?? null) : (isEmptyContent ? '' : m.content!); return { role: 'assistant', content: assistantContent, ...(m.name ? { name: m.name } : {}), // Replay the thinking trace verbatim. DeepSeek thinking models on // OpenCode Zen reject a follow-up turn that drops it; other providers // ignore the unknown field. Same round-trip rationale as // thought_signature below. (#255) ...(typeof m.reasoning_content === 'string' && m.reasoning_content.length > 0 ? { reasoning_content: m.reasoning_content } : {}), // hasToolCalls (not a bare truthiness check) so null AND empty-array // tool_calls are dropped rather than forwarded — strict upstreams // reject both shapes. (#200) ...(hasToolCalls ? { tool_calls: m.tool_calls!.map(tc => { // Normalize echo-tolerant inputs back to the strict OpenAI shape // before forwarding (see toolCallSchema); synthesize missing ids // and queue every id for order-based tool-result pairing. (#200) const id = tc.id && tc.id.length > 0 ? tc.id : `call_auto_${++syntheticIdCounter}`; pendingToolCallIds.push(id); return { id, type: 'function' as const, function: { name: tc.function.name, arguments: toolCallArgsToString(tc.function.arguments) }, thought_signature: tc.thought_signature, }; }) } : {}), }; } if (m.role === 'tool') { return { role: 'tool', // Null/missing content (a tool that returned nothing) → "". (#200) content: m.content ?? '', tool_call_id: takeToolCallId(m.tool_call_id), ...(m.name ? { name: m.name } : {}), }; } // Legacy function-calling result → forward as a tool message, paired by // order like an id-less tool message. (#200) if (m.role === 'function') { return { role: 'tool', content: m.content ?? '', tool_call_id: takeToolCallId(undefined), name: m.name, }; } return { // 'developer' is OpenAI's newer name for the system role — providers // downstream only know 'system'. (#200) role: m.role === 'developer' ? 'system' : m.role, content: m.content, ...(m.name ? { name: m.name } : {}), }; }); // Token estimation is intentionally a heuristic (~4 chars per token). Used // for routing decisions (skip a model whose budget is too small) and for // streaming bookkeeping where the provider doesn't echo a final usage count. // Non-streaming requests reconcile against the provider's real `usage` block // (see line ~340). Streaming will drift from real consumption — accepted // tradeoff because per-request usage isn't always returned mid-stream. const estimatedInputTokens = messages.reduce((sum, m) => { const text = contentToString(m.content); return sum + Math.ceil(text.length / 4); }, 0); // Image requests must route to a vision-capable model. Reject up front with a // clear message when none is enabled, rather than silently dropping the image // or surfacing the generic "all models exhausted" error (#118, #125). Add a // rough per-image token cost so budget routing isn't skewed by content the // heuristic above (text-only) can't see. const hasImage = messageHasImage(messages); if (hasImage && !hasEnabledVisionModel()) { res.status(422).json({ error: { message: 'This request includes an image, but no vision-capable model is enabled. Enable a vision model (e.g. Gemini 2.5 Flash, Llama 4 Scout) in the Fallback Chain.', type: 'invalid_request_error', code: 'no_vision_model', }, }); return; } const IMAGE_TOKEN_ESTIMATE = 1000; const imageCount = messages.reduce((n, m) => n + (Array.isArray(m.content) ? m.content.filter(b => (b as { type?: string })?.type === 'image_url' || (b as { type?: string })?.type === 'image').length : 0), 0); const estimatedTotal = estimatedInputTokens + imageCount * IMAGE_TOKEN_ESTIMATE + (max_tokens ?? 1000); // Tool-bearing requests must route to a model that emits STRUCTURED // tool_calls. A model without real function-calling support serializes the // call into its text answer — the request "succeeds" but the client's tool // loop sees nothing, which is strictly worse than an error. Same up-front // gate pattern as vision above. const wantsTools = (tools?.length ?? 0) > 0; if (wantsTools && !hasEnabledToolsModel()) { res.status(422).json({ error: { message: 'This request includes tools, but no tool-capable model is enabled. Enable a tool-calling model (e.g. GPT-OSS 120B, Gemini 3.5 Flash, GLM-4.7) in the Fallback Chain.', type: 'invalid_request_error', code: 'no_tools_model', }, }); return; } // Optional client-managed session affinity (see getSessionKey). Express // lower-cases header names; a repeated header arrives as an array — take // the first value. const rawSessionId = req.headers['x-session-id']; const sessionIdHeader = Array.isArray(rawSessionId) ? rawSessionId[0] : rawSessionId; // Explicit `model` field pins routing. If the catalog has no enabled row // matching the requested id, return 400 — silently auto-routing to a // different model would be surprising to OpenAI-compatible clients. // Sticky-session is the fallback when no `model` field was sent at all. let preferredModel: number | undefined; if (isAutoModel(requestedModel)) { // Explicit "auto" → behave exactly like an omitted model field. preferredModel = getStickyModel(messages, sessionIdHeader); } else if (requestedModel) { const db = getDb(); const enabled = db.prepare('SELECT id FROM models WHERE model_id = ? AND enabled = 1').get(requestedModel) as { id: number } | undefined; if (enabled) { preferredModel = enabled.id; } else { const disabled = db.prepare('SELECT id FROM models WHERE model_id = ?').get(requestedModel) as { id: number } | undefined; const reason = disabled ? 'is disabled' : 'is not in the catalog'; res.status(400).json({ error: { message: `Model '${requestedModel}' ${reason}. Use 'auto' (or omit the 'model' field) to auto-route, or call /v1/models for the available list.`, type: 'invalid_request_error', code: 'model_not_found', }, }); return; } } else { preferredModel = getStickyModel(messages, sessionIdHeader); } // For analytics: the model id the client pinned, null when auto-routed // ('auto' or omitted). Logged with every request row so pinned vs auto // traffic and failover overrides are visible. const pinnedModelId = requestedModel && !isAutoModel(requestedModel) ? requestedModel : null; // Retry loop: on 429/rate limit, skip that model+key and try the next one const skipKeys = new Set(); const skipModels = new Set(); let lastError: any = null; for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { let route: RouteResult; try { route = routeRequest(estimatedTotal, skipKeys.size > 0 ? skipKeys : undefined, preferredModel, hasImage, wantsTools, skipModels.size > 0 ? skipModels : undefined); } catch (err: any) { // No more models available if (lastError) { const safeLastError = sanitizeProviderErrorMessage(lastError.message); res.status(429).json({ error: { message: `All models rate-limited. Last error: ${safeLastError}`, type: 'rate_limit_error', }, }); } else { res.status(err.status ?? 503).json({ error: { message: err.message, type: 'routing_error' }, }); } return; } try { if (stream) { // — Stream turn-integrity (#231 audit) — // The old loop forwarded upstream chunks verbatim and called any // stream that produced bytes a success. Live failure modes that // slipped through: in-band `{"error":...}` frames delivered as dead // turns, tool calls with no terminal finish_reason, inline tool-call // dialect emitted as text, truncations logged as success. This loop // validates the TURN, not the transport: // - headers are held until the first real payload, so anything that // dies before producing one fails over invisibly; // - text that starts with an inline tool-call dialect marker is held // and rescued into structured tool_calls (or failed over); // - tool_call deltas are buffered, argument-repaired, and emitted as // one complete chunk, always followed by finish_reason // "tool_calls" — agents never see calls without a terminal reason; // - a stream that ends with neither content nor calls is an empty // completion and fails over like the non-stream path. let totalOutputTokens = 0; let headerSent = false; let ttfbMs: number | null = null; // Hold-window state: 'undecided' until the first text either matches // a dialect marker (→ 'dialect': buffer everything, rescue at end) or // provably cannot (→ 'passthrough': flush and stream normally). let mode: 'undecided' | 'passthrough' | 'dialect' = 'undecided'; let heldText = ''; const preamble: unknown[] = []; // role-only chunks held until flush const toolCallAcc = new Map(); let upstreamFinish: string | null = null; let usageChunk: unknown = null; let lastMeta: { id?: string; model?: string; created?: number } = {}; const flushHeaders = () => { if (headerSent) return; ttfbMs = Date.now() - start; res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.setHeader('X-Routed-Via', `${route.platform}/${route.modelId}`); if (attempt > 0) res.setHeader('X-Fallback-Attempts', String(attempt)); headerSent = true; for (const p of preamble) res.write(`data: ${JSON.stringify(p)}\n\n`); preamble.length = 0; }; const mkChunk = (delta: Record, finish: string | null) => ({ id: lastMeta.id ?? `chatcmpl-${Date.now()}`, object: 'chat.completion.chunk', created: lastMeta.created ?? Math.floor(Date.now() / 1000), model: lastMeta.model ?? route.modelId, choices: [{ index: 0, delta, finish_reason: finish }], }); const writeChunk = (c: unknown) => res.write(`data: ${JSON.stringify(c)}\n\n`); try { const gen = route.provider.streamChatCompletion( route.apiKey, messages, route.modelId, { temperature, max_tokens, top_p, tools, tool_choice, parallel_tool_calls }, ); for await (const chunk of gen) { const anyChunk = chunk as Record; // In-band upstream error frame (observed live: Groq emits // {"error":{...,"code":"tool_use_failed"}} inside a 200 SSE // stream). Before headers: retryable, the next model gets the // request. After: surface an error frame instead of pretending // the turn succeeded. if (anyChunk.error && !anyChunk.choices) { const msg = anyChunk.error.message ?? JSON.stringify(anyChunk.error).slice(0, 200); if (!headerSent) throw new Error(`in-band provider error from ${route.displayName}: ${msg}`); console.error(`[Proxy] In-band error frame from ${route.displayName} mid-stream:`, msg); writeChunk({ error: { message: `Provider error (${route.displayName}): ${sanitizeProviderErrorMessage(String(msg))}`, type: 'stream_error' } }); try { res.write('data: [DONE]\n\n'); res.end(); } catch { /* socket gone */ } logRequest(route.platform, route.modelId, route.keyId, 'error', estimatedInputTokens, totalOutputTokens, Date.now() - start, `in-band error frame: ${sanitizeProviderErrorMessage(String(msg))}`, ttfbMs, pinnedModelId); return; } if (anyChunk.id) lastMeta = { id: anyChunk.id, model: anyChunk.model, created: anyChunk.created }; const choice = anyChunk.choices?.[0]; if (!choice) { // Usage-only frame (stream_options.include_usage) — held and // re-emitted after our finish chunk to preserve OpenAI ordering. if (anyChunk.usage) usageChunk = anyChunk; continue; } if (choice.finish_reason) upstreamFinish = choice.finish_reason; // Buffer tool_call deltas — emitted complete + repaired at end. for (const tc of choice.delta?.tool_calls ?? []) { const idx = tc.index ?? 0; if (!toolCallAcc.has(idx)) toolCallAcc.set(idx, { id: undefined, name: '', args: '' }); const acc = toolCallAcc.get(idx)!; if (tc.id && !acc.id) acc.id = tc.id; if (tc.function?.name) acc.name += tc.function.name; if (tc.function?.arguments) acc.args += tc.function.arguments; } normalizeOutboundContent(chunk); const text = typeof choice.delta?.content === 'string' ? choice.delta.content : ''; if (text.length === 0) { // Role preamble / keep-alive: hold until first payload decides // the mode, forward afterwards. tool_calls and finish_reason are // stripped — both are re-emitted complete at the end (OpenRouter // attaches tool_call deltas to chunks that also carry role/ // reasoning keys; forwarding them raw would duplicate the call). if (choice.delta && Object.keys(choice.delta).some(k => k !== 'content' && k !== 'tool_calls' && choice.delta[k] != null)) { const cleaned = { ...anyChunk, choices: [{ ...choice, delta: { ...choice.delta, tool_calls: undefined }, finish_reason: null }] }; if (headerSent) writeChunk(cleaned); else preamble.push(cleaned); } continue; } totalOutputTokens += Math.ceil(text.length / 4); if (mode === 'passthrough') { writeChunk({ ...anyChunk, choices: [{ ...choice, delta: { ...choice.delta, tool_calls: undefined }, finish_reason: null }] }); continue; } heldText += text; if (mode === 'dialect') continue; const probe = heldText.trimStart(); if (startsWithDialectMarker(probe)) { mode = 'dialect'; } else if (!couldBecomeDialectMarker(probe) || probe.length > 256) { mode = 'passthrough'; flushHeaders(); writeChunk(mkChunk({ content: heldText }, null)); heldText = ''; } // else: still a strict prefix of a marker — keep holding. } // — Stream ended cleanly (provider saw [DONE] or a finish_reason) — // Assemble buffered tool calls: synthesize missing ids, repair // double-encoded arguments against the request's schemas, drop // calls whose args still aren't valid JSON. const schemas = toolSchemaMap(tools); let syntheticStreamIds = 0; const completedCalls = [...toolCallAcc.entries()] .sort((a, b) => a[0] - b[0]) .map(([, acc]) => ({ id: acc.id && acc.id.length > 0 ? acc.id : `call_stream_${++syntheticStreamIds}`, type: 'function' as const, function: { name: acc.name, arguments: repairToolArguments(acc.args || '{}', schemas.get(acc.name)) }, })) .filter(c => { try { JSON.parse(c.function.arguments); return c.function.name.length > 0; } catch { return false; } }); // Dialect rescue: the held text is an inline tool call in some // model's private syntax. Parse it into structured calls or treat // the turn as dead (headers were never sent in dialect mode, so // failing over is free). if (mode === 'dialect' || (mode === 'undecided' && heldText.length > 0 && containsDialectMarker(heldText))) { const rescue = rescueInlineToolCalls(heldText, new Set((tools ?? []).map(t => t.function.name))); if (rescue.detected) { if (!rescue.calls) throw new Error(`unparseable inline tool-call dialect from ${route.displayName}: ${heldText.slice(0, 120)}`); let rescuedIds = 0; for (const c of rescue.calls) { completedCalls.push({ id: `call_rescued_${++rescuedIds}`, type: 'function', function: { name: c.name, arguments: repairToolArguments(c.arguments, schemas.get(c.name)) } }); } heldText = rescue.cleanText; console.log(`[Proxy] Rescued ${rescuedIds} inline tool call(s) from ${route.displayName} into structured tool_calls`); } } const hasText = headerSent || heldText.trim().length > 0; if (!hasText && completedCalls.length === 0) { // Nothing usable came out — same failover semantics as the // non-stream empty-completion path. Headers can't have been sent // (header flush requires payload), so the client never notices. throw new Error(`empty completion from ${route.displayName} (stream produced no content and no tool calls)`); } flushHeaders(); if (heldText.length > 0) { writeChunk(mkChunk({ content: heldText }, null)); } if (completedCalls.length > 0) { writeChunk(mkChunk({ tool_calls: completedCalls.map((c, i) => ({ index: i, ...c })) }, null)); totalOutputTokens += Math.ceil(completedCalls.reduce((n, c) => n + c.function.arguments.length, 0) / 4); } // Terminal finish_reason, ALWAYS present: calls win over a sloppy // upstream 'stop'; 'length'/'content_filter' survive for pure-text // turns; missing upstream reason is synthesized. const finish = completedCalls.length > 0 ? 'tool_calls' : (upstreamFinish && upstreamFinish !== 'tool_calls' ? upstreamFinish : 'stop'); writeChunk(mkChunk({}, finish)); if (usageChunk) writeChunk(usageChunk); res.write('data: [DONE]\n\n'); res.end(); recordRequest(route.platform, route.modelId, route.keyId); recordTokens(route.platform, route.modelId, route.keyId, estimatedInputTokens + totalOutputTokens); recordSuccess(route.modelDbId); setStickyModel(messages, route.modelDbId, sessionIdHeader); logRequest(route.platform, route.modelId, route.keyId, 'success', estimatedInputTokens, totalOutputTokens, Date.now() - start, null, ttfbMs, pinnedModelId); return; } catch (streamErr: any) { if (headerSent) { // Mid-stream error after real payload reached the client — finish // the SSE response honestly instead of leaving the client hanging. console.error(`[Proxy] Mid-stream error from ${route.displayName}:`, streamErr.message); const payload = { error: { message: `Provider error (${route.displayName}): stream interrupted`, type: 'stream_error' } }; try { res.write(`data: ${JSON.stringify(payload)}\n\n`); } catch { /* socket gone */ } try { res.write('data: [DONE]\n\n'); res.end(); } catch { /* socket gone */ } logRequest(route.platform, route.modelId, route.keyId, 'error', estimatedInputTokens, totalOutputTokens, Date.now() - start, sanitizeProviderErrorMessage(streamErr.message), null, pinnedModelId); return; } // Headers never sent — bubble to the outer retry handler, which // cooldowns this model+key and tries the next one. Covers upstream // HTTP errors, in-band error frames, abrupt EOF, stalls, empty // completions, and unparseable dialect turns alike. throw streamErr; } } else { const result = await route.provider.chatCompletion( route.apiKey, messages, route.modelId, { temperature, max_tokens, top_p, tools, tool_choice, parallel_tool_calls }, ); // Empty completion (no text, no tool calls) → fail over rather than // return a transport-level "success" the caller can't act on. Mirrors // the zero-chunk streaming case above. const respMsg = result.choices?.[0]?.message; const respText = contentToString(respMsg?.content ?? ''); if (!respText && (respMsg?.tool_calls?.length ?? 0) === 0) { logRequest(route.platform, route.modelId, route.keyId, 'error', estimatedInputTokens, 0, Date.now() - start, 'empty completion (no content, no tool_calls)', null, pinnedModelId); skipKeys.add(`${route.platform}:${route.modelId}:${route.keyId}`); setCooldown(route.platform, route.modelId, route.keyId, getCooldownDurationForLimit(route.platform, route.modelId, route.keyId, { rpd: route.rpdLimit, tpd: route.tpdLimit })); recordRateLimitHit(route.modelDbId); lastError = new Error(`empty completion from ${route.displayName}`); continue; } // Inline tool-call dialect rescue (#231 audit): a tool-bearing // request answered with the call serialized as TEXT (a mid- // conversation model switch makes the new model imitate the previous // model's private syntax). Re-parse it into structured tool_calls so // the client's agent loop keeps working; a detected-but-unparseable // dialect is a dead turn and fails over like an empty completion. if (wantsTools && respMsg && (respMsg.tool_calls?.length ?? 0) === 0 && respText) { const rescue = rescueInlineToolCalls(respText, new Set((tools ?? []).map(t => t.function.name))); if (rescue.detected) { if (!rescue.calls) { throw new Error(`unparseable inline tool-call dialect from ${route.displayName}: ${respText.slice(0, 120)}`); } const schemas = toolSchemaMap(tools); respMsg.tool_calls = rescue.calls.map((c, i) => ({ id: `call_rescued_${i + 1}`, type: 'function' as const, function: { name: c.name, arguments: repairToolArguments(c.arguments, schemas.get(c.name)) }, })); respMsg.content = rescue.cleanText.length > 0 ? rescue.cleanText : null; if (result.choices?.[0]) result.choices[0].finish_reason = 'tool_calls'; console.log(`[Proxy] Rescued ${rescue.calls.length} inline tool call(s) from ${route.displayName} into structured tool_calls`); } } const totalTokens = result.usage?.total_tokens ?? 0; recordRequest(route.platform, route.modelId, route.keyId); recordTokens(route.platform, route.modelId, route.keyId, totalTokens); recordSuccess(route.modelDbId); setStickyModel(messages, route.modelDbId, sessionIdHeader); res.setHeader('X-Routed-Via', `${route.platform}/${route.modelId}`); if (attempt > 0) res.setHeader('X-Fallback-Attempts', String(attempt)); // Repair double-encoded tool arguments against the request's tool // schemas (e.g. GLM emitting an array parameter as a JSON string), // so strict clients don't reject the call. Schema-gated — a true // string parameter is never touched. See lib/tool-args.ts. if (respMsg?.tool_calls?.length) { const schemas = toolSchemaMap(tools); for (const tc of respMsg.tool_calls) { if (tc?.function?.arguments != null) { tc.function.arguments = repairToolArguments(tc.function.arguments, schemas.get(tc.function.name)); } } } // Normalize array-shaped message.content to a string on the way out (#166). res.json(normalizeOutboundContent(result)); logRequest( route.platform, route.modelId, route.keyId, 'success', result.usage?.prompt_tokens ?? 0, result.usage?.completion_tokens ?? 0, Date.now() - start, null, null, pinnedModelId, ); return; } } catch (err: any) { const latency = Date.now() - start; const safeError = sanitizeProviderErrorMessage(err.message); logRequest(route.platform, route.modelId, route.keyId, 'error', estimatedInputTokens, 0, latency, safeError, null, pinnedModelId); if (isRetryableError(err)) { // Model-level 404 (removed/deprecated upstream): rule the whole model // out for the rest of this request — its other keys would 404 the same // way. The per-key cooldown below still applies, so cross-request // behavior (#66/#76) is unchanged. (PR #111, credits @barbotkonv.) // 404 (removed upstream) and 403 (model off-limits to this key's tier) // both rule the model out: a sibling key on the same platform would // fail it identically, so skip it for the rest of this request. if (isModelNotFoundError(err) || isModelAccessForbiddenError(err)) skipModels.add(route.modelDbId); // Put this model+key on cooldown and try the next one const skipId = `${route.platform}:${route.modelId}:${route.keyId}`; skipKeys.add(skipId); setCooldown( route.platform, route.modelId, route.keyId, isPaymentRequiredError(err) ? PAYMENT_REQUIRED_COOLDOWN_MS // A 403 won't clear on the next window (it's a tier/subscription gate, // not a transient limit), so bench this model+key for a day like a 402 // instead of re-trying it every request. See issue #256. : isModelAccessForbiddenError(err) ? MODEL_FORBIDDEN_COOLDOWN_MS : getCooldownDurationForLimit(route.platform, route.modelId, route.keyId, { rpd: route.rpdLimit, tpd: route.tpdLimit, }, err.retryAfterMs), ); recordRateLimitHit(route.modelDbId); lastError = err; console.log(`[Proxy] ${safeError.slice(0, 60)} from ${route.displayName}, falling back (attempt ${attempt + 1}/${MAX_RETRIES})`); continue; } // Non-retryable error (auth, 4xx, etc.): don't retry res.status(502).json({ error: { message: `Provider error (${route.displayName}): ${safeError}`, type: 'provider_error', }, }); return; } } // Exhausted all retries res.status(429).json({ error: { message: `All models rate-limited after ${MAX_RETRIES} attempts. Last: ${sanitizeProviderErrorMessage(lastError?.message)}`, type: 'rate_limit_error', }, }); }); export function logRequest( platform: string, modelId: string, keyId: number, status: string, inputTokens: number, outputTokens: number, latencyMs: number, error: string | null, ttfbMs: number | null = null, // The model id the client pinned; null for auto-routed requests. Lets // analytics split pinned vs auto traffic and detect failover overrides // (requested_model set but != model_id). requestedModel: string | null = null, ) { try { const db = getDb(); db.prepare(` INSERT INTO requests (platform, model_id, key_id, status, input_tokens, output_tokens, latency_ms, error, ttfb_ms, requested_model) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run(platform, modelId, keyId, status, inputTokens, outputTokens, latencyMs, error, ttfbMs, requestedModel); pruneRequestAnalytics({ db }); } catch (e) { console.error('Failed to log request:', e); } }