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, resolveRoutingChain, resolveModelGroupCandidates, recordRateLimitHit, recordSuccess, hasEnabledVisionModel, hasEnabledToolsModel, type RouteResult, type ResolvedChain, type ChainRow } from '../services/router.js'; import { recordRequest, recordTokens, setCooldown, getCooldownDurationForLimit, PAYMENT_REQUIRED_COOLDOWN_MS, MODEL_FORBIDDEN_COOLDOWN_MS, learnLimitFromError } from '../services/ratelimit.js'; import { runEmbeddings, EmbeddingsError } from '../services/embeddings.js'; import { runImageGeneration, runSpeech, MediaError } from '../services/media.js'; import { getDb, getUnifiedApiKey } from '../db/index.js'; import { contentToString, messageHasImage, normalizeOutboundContent, sanitizeResponse } 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'; import { getContextHandoffMode, recordIncomingMessages, maybeInjectContextHandoff, recordSuccessfulModel, hasPriorModel, HANDOFF_MAX_TOKENS } from '../services/context-handoff.js'; import { isFusionModel, runFusion, fusionConfigSchema, FusionError, FUSION_MODEL_ID } from '../services/fusion.js'; import { isRetryableError, isPaymentRequiredError, isModelNotFoundError, isModelAccessForbiddenError } from '../lib/error-classify.js'; import { logRequest } from '../lib/request-log.js'; import type { Platform } from '@freellmapi/shared/types.js'; import { inferQuotaPoolKey, type QuotaObservationContext } from '../services/provider-quota.js'; import { isUnifyEnabled, getModelGroups, resolveRequestedIdToMembers } from '../services/model-groups.js'; import { buildModelListing } from '../services/model-listing.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 { if (!modelId) return true; const lower = modelId.toLowerCase(); return lower === AUTO_MODEL_ID || lower.startsWith(`${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 apiKeyHeader = req.headers['x-api-key']; const xApiKey = Array.isArray(apiKeyHeader) ? apiKeyHeader[0] : apiKeyHeader; const trimmed = xApiKey?.trim(); if (trimmed) return trimmed; const bearer = req.headers.authorization?.replace(/^Bearer\s+/i, '').trim(); return bearer || undefined; } function quotaContextForRoute(route: RouteResult, endpoint: string): QuotaObservationContext { return { platform: route.platform as Platform, keyId: route.keyId, modelId: route.modelId, quotaPoolKey: inferQuotaPoolKey(route.platform as Platform, route.modelId), endpoint, origin: 'proxy', }; } export function getRequestGroupId(req: Request): string { const raw = req.headers['x-request-id']; const value = Array.isArray(raw) ? raw[0] : raw; const trimmed = value?.trim(); return trimmed || crypto.randomUUID(); } function shortRequestId(requestId: string): string { return requestId.replace(/-/g, '').slice(0, 6); } type TraceEvent = 'start' | 'next' | 'ok' | 'fail'; export function traceRouteEvent( scope: 'Proxy' | 'Responses', opts: { event: TraceEvent; requestId: string; attempt: number; platform: string; model: string; requestedModel?: string; latencyMs?: number; inputTokens?: number; outputTokens?: number; error?: string; }, ) { const parts = [ `[${scope}]`, new Date().toISOString().slice(11, 19), opts.event, shortRequestId(opts.requestId), `a${opts.attempt}`, opts.platform, '-', opts.model, ]; if (opts.requestedModel) parts.push(`req=${opts.requestedModel}`); if (opts.latencyMs != null) parts.push(`lat=${opts.latencyMs}ms`); if (opts.inputTokens != null) parts.push(`in=${opts.inputTokens}`); if (opts.outputTokens != null) parts.push(`out=${opts.outputTokens}`); if (opts.error) parts.push(`err=${JSON.stringify(opts.error)}`); console.log(parts.join(' ')); } // 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, strategyKey?: string): string { if (sessionIdHeader) { return strategyKey ? `hdr:${sessionIdHeader}::${strategyKey}` : `hdr:${sessionIdHeader}`; } const firstUser = messages.find(m => m.role === 'user'); if (!firstUser) return ''; const text = contentToString(firstUser.content ?? ''); if (!text) return ''; const payload = strategyKey ? `${text}::${strategyKey}` : text; return crypto.createHash('sha1').update(payload).digest('hex'); } export function getStickyModel(messages: ChatMessage[], sessionIdHeader?: string, strategyKey?: string): number | undefined { const hasAssistant = messages.some(m => m.role === 'assistant'); if (!hasAssistant) return undefined; const key = getSessionKey(messages, sessionIdHeader, strategyKey); 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, strategyKey?: string) { const key = getSessionKey(messages, sessionIdHeader, strategyKey); 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; } // By default we return the WHOLE catalog (one row per model id), each tagged // with whether it is currently usable, so a client can see everything and know // what's connected vs. disabled/keyless (#242). `?available=true` (alias // `?connected=true`) narrows the list to only models that can serve a request // right now — the previous default behavior. `available` is computed as // "enabled AND an enabled key can serve it"; dedup prefers an available // instance of a model id over a disabled/keyless one. // Shared catalog listing (one source of truth for the OpenAI and Anthropic // /v1/models endpoints — see services/model-listing.ts). `autoContextWindow` // is the honest ceiling for the virtual "auto" model: the largest context // window among models that can serve a request right now. Advertising null // makes OpenAI-compatible clients (opencode, Continue) fall back to their own // conservative default and truncate long inputs before they reach us (#282). const { models: allListed, autoContextWindow } = buildModelListing(); const q = String(req.query.available ?? req.query.connected ?? '').toLowerCase(); // By default (when no parameter is specified), only return available models. // This filters out the huge list of non-working models for client pickers. const onlyAvailable = q === '' ? true : (q === '1' || q === 'true' || q === 'yes'); const listed = onlyAvailable ? allListed.filter(m => m.available === 1) : allListed; 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: autoContextWindow, // `context_length` is OpenRouter's field name and the one most // OpenAI-compatible clients read; emit both so whichever a client // looks for is populated. Additive — clients ignore unknown fields. context_length: autoContextWindow, available: true, unavailable_reason: null, }, { id: FUSION_MODEL_ID, object: 'model', created: 0, owned_by: 'freellmapi', name: 'Fusion (panel of models answer in parallel, a judge synthesizes one answer)', context_window: autoContextWindow, context_length: autoContextWindow, // Available whenever auto is — fusion needs at least one routable model. available: autoContextWindow != null, unavailable_reason: autoContextWindow != null ? null : 'no_models', }, ...listed.map(m => ({ id: m.id, object: 'model', created: 0, owned_by: m.ownedBy, name: m.name, context_window: m.contextWindow, context_length: m.contextWindow, // Non-standard but additive: OpenAI clients ignore unknown fields. available: m.available === 1, unavailable_reason: m.available === 1 ? null : (m.enabled === 1 ? 'no_key' : 'disabled'), })), ], }); }); 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(), // Fusion config — only meaningful when `model` is the virtual "fusion" id. // Ignored for every other model. See services/fusion.ts. fusion: fusionConfigSchema.optional(), }); // Upstream-error classifiers live in lib/error-classify.ts so the fusion // service can share them without an import cycle; imported above for internal // use and re-exported here for existing importers (routes/responses.ts, // proxy-retry.test.ts) that pull them from this module. export { isRetryableError, isPaymentRequiredError, isModelNotFoundError, isModelAccessForbiddenError }; // 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 } }); } }); // OpenAI-compatible image generation. Routed through the media catalog (its own // table, never the chat router): `model: "auto"` (or omitted) tries every enabled // image provider in order; a provider model id pins to that one. Failover is // across providers, never across modalities. See services/media.ts. const ImageBody = z.object({ model: z.string().optional(), prompt: z.string().min(1), n: z.number().int().positive().max(4).optional(), size: z.string().optional(), response_format: z.enum(['url', 'b64_json']).optional(), }); function mediaErrorType(status: number): string { if (status === 400) return 'invalid_request_error'; if (status === 401) return 'authentication_error'; if (status === 429) return 'rate_limit_error'; return 'server_error'; } proxyRouter.post('/images/generations', 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 = ImageBody.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: { message: 'Invalid request: `prompt` is required', type: 'invalid_request_error' } }); return; } try { const result = await runImageGeneration(parsed.data.model, { prompt: parsed.data.prompt, n: parsed.data.n, size: parsed.data.size, }); res.json({ created: Math.floor(Date.now() / 1000), data: result.images, model: result.modelId, provider: result.platform, }); } catch (err: any) { const status = err instanceof MediaError ? err.status : 502; const httpStatus = status >= 400 && status < 600 ? status : 502; res.status(httpStatus).json({ error: { message: `image generation error: ${err?.message ?? 'unknown'}`, type: mediaErrorType(status) } }); } }); // OpenAI-compatible text-to-speech. Returns raw audio bytes (OpenAI's /audio/speech // shape). Same media-catalog routing as images. const SpeechBody = z.object({ model: z.string().optional(), input: z.string().min(1), voice: z.string().optional(), response_format: z.string().optional(), }); proxyRouter.post('/audio/speech', 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 = SpeechBody.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: { message: 'Invalid request: `input` is required', type: 'invalid_request_error' } }); return; } try { const result = await runSpeech(parsed.data.model, { input: parsed.data.input, voice: parsed.data.voice, format: parsed.data.response_format, }); res.setHeader('Content-Type', result.contentType); res.setHeader('X-Provider', result.platform); res.send(result.audio); } catch (err: any) { const status = err instanceof MediaError ? err.status : 502; const httpStatus = status >= 400 && status < 600 ? status : 502; res.status(httpStatus).json({ error: { message: `speech error: ${err?.message ?? 'unknown'}`, type: mediaErrorType(status) } }); } }); proxyRouter.post('/chat/completions', async (req: Request, res: Response) => { const start = Date.now(); const requestGroupId = getRequestGroupId(req); res.setHeader('X-Request-ID', requestGroupId); // 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; const requestedModelLabel = requestedModel ?? 'auto'; // 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; } // ── Fusion: multi-model synthesis ────────────────────────────────────────── // The virtual "fusion" model fans the prompt out to a panel of diverse models // in parallel, then a judge synthesizes one answer. It routes each panel/judge // sub-call through the normal path (cooldowns, quotas, analytics), so it // behaves like a normal model from the client's side — just K+1x the tokens. // v1 has no tools/vision/streaming-panel; reject the first two up front and // replay the synthesized answer as a single SSE turn when stream is set. if (isFusionModel(requestedModel)) { if (hasImage) { res.status(422).json({ error: { message: 'Fusion does not support image input yet. Use a vision model directly.', type: 'invalid_request_error', code: 'fusion_no_vision' } }); return; } if (wantsTools) { res.status(422).json({ error: { message: 'Fusion does not support tool calling yet. Use a tool-capable model directly.', type: 'invalid_request_error', code: 'fusion_no_tools' } }); return; } const fusionOptions = { temperature, max_tokens, top_p }; const fusionConfig = parsed.data.fusion ?? {}; if (stream) { // Streaming fusion: open the SSE response immediately and emit additive // `_fusion` frames (no `choices`, so standard OpenAI clients skip them) as // each panel model settles and when the judge runs — the Playground shows // these arriving in a collapsible trace. The final synthesized answer is // then streamed as normal content deltas, so plain clients still get it. res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); const writeFrame = (o: unknown) => { try { res.write(`data: ${JSON.stringify(o)}\n\n`); } catch { /* socket gone */ } }; const streamId = `fusion-${Date.now()}-${crypto.randomBytes(3).toString('hex')}`; const base = { id: streamId, object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), model: FUSION_MODEL_ID }; // Track whether the judge already streamed content so we don't re-emit it. let answerStarted = false; try { const { response } = await runFusion({ messages, config: fusionConfig, options: fusionOptions, estimatedTokens: estimatedTotal, hooks: { // `a` already carries a sanitized error for failed slots; content is // the model's own answer and is forwarded as-is. onPanel: (a) => writeFrame({ _fusion: { event: 'panel', ...a } }), onJudge: (j) => writeFrame({ _fusion: { event: 'judge', ...j } }), // Stream the judge's synthesis live as standard content deltas, so // the final answer appears as it's written instead of after the wait. onJudgeDelta: (delta) => { if (!answerStarted) { writeFrame({ ...base, choices: [{ index: 0, delta: { role: 'assistant' }, finish_reason: null }] }); answerStarted = true; } writeFrame({ ...base, choices: [{ index: 0, delta: { content: delta }, finish_reason: null }] }); }, }, }); // best_of / single-survivor / judge-fell-back-to-best-of never streamed // a delta — emit the final answer as one chunk in that case. if (!answerStarted) { const finalText = contentToString(response.choices[0]?.message?.content ?? ''); writeFrame({ ...base, choices: [{ index: 0, delta: { role: 'assistant' }, finish_reason: null }] }); writeFrame({ ...base, choices: [{ index: 0, delta: { content: finalText }, finish_reason: null }] }); } writeFrame({ ...base, choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], usage: response.usage }); } catch (err: any) { const message = err instanceof FusionError ? err.message : `fusion error: ${sanitizeProviderErrorMessage(err?.message)}`; const type = err instanceof FusionError && err.status === 429 ? 'rate_limit_error' : 'server_error'; writeFrame({ error: { message, type } }); } try { res.write('data: [DONE]\n\n'); res.end(); } catch { /* socket gone */ } return; } try { const { response, routedVia } = await runFusion({ messages, config: fusionConfig, options: fusionOptions, estimatedTokens: estimatedTotal, }); res.setHeader('X-Routed-Via', routedVia); res.json(response); } catch (err: any) { if (err instanceof FusionError) { res.status(err.status).json({ error: { message: err.message, type: err.status === 429 ? 'rate_limit_error' : 'invalid_request_error' } }); } else { res.status(502).json({ error: { message: `fusion error: ${sanitizeProviderErrorMessage(err?.message)}`, type: 'server_error' } }); } } 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; let resolvedChain: ResolvedChain | undefined; let strategyKey: string | undefined; if (isAutoModel(requestedModel)) { resolvedChain = resolveRoutingChain(requestedModel); strategyKey = resolvedChain.strategyKey; } // Context handoff only applies to auto-routed requests. Pinned-model requests // are deliberate client choices; injecting "you are taking over" there would // be semantically wrong. const isAutoRouted = !requestedModel || isAutoModel(requestedModel); const handoffMode = isAutoRouted ? getContextHandoffMode() : ('off' as const); const sessionKey = handoffMode !== 'off' ? getSessionKey(messages, sessionIdHeader, strategyKey) : ''; if (handoffMode !== 'off' && sessionKey) { recordIncomingMessages(sessionKey, messages); } // A handoff can only fire when a prior model is on record for this session. // Check after recordIncomingMessages, which clears the prior model on a // fresh conversation. Stable across the retry loop (the prior model only // changes on a success, which returns), so compute it once here. const handoffPossible = handoffMode !== 'off' && !!sessionKey && hasPriorModel(sessionKey); // 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; // When the pinned model is a unified group, this holds the group's ordered // members and is passed to routeRequest as the STRICT chain (no other model // is ever reached). Undefined for auto and legacy single-row pins. let groupChain: ChainRow[] | undefined; // Sticky scope: auto requests bucket by routing strategy; a unified group pin // buckets by the canonical id the client sent, so the group prefers its last // successful provider without leaking stickiness across groups. let stickyStrategyKey: string | undefined = strategyKey; if (isAutoModel(requestedModel)) { preferredModel = getStickyModel(messages, sessionIdHeader, strategyKey); } else if (requestedModel) { const db = getDb(); // Unify ON: a requested id (canonical slug OR any provider's model_id) maps // to the whole logical-model group, and we route STRICTLY across only its // providers — failing over between them, never to a different model (#335). const members = isUnifyEnabled() ? resolveRequestedIdToMembers(requestedModel, getModelGroups()) : null; if (members && members.length > 0) { groupChain = resolveModelGroupCandidates(members); if (groupChain.length === 0) { // Distinguish a catalog-disabled model from one whose providers are // present but unusable (chain-disabled / no key), so the 400 stays // actionable and matches the legacy single-row "is disabled" wording. const placeholders = members.map(() => '?').join(','); const anyEnabled = db.prepare(`SELECT 1 FROM models WHERE id IN (${placeholders}) AND enabled = 1 LIMIT 1`).get(...members); const reason = anyEnabled ? 'has no providers with an enabled key' : 'is disabled'; 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; } stickyStrategyKey = requestedModel; const sticky = getStickyModel(messages, sessionIdHeader, stickyStrategyKey); // Only prefer the sticky member if it's actually IN this group — passing a // non-member as preferredModelDbId would make routeRequest inject an // off-group model and break strict pinning. preferredModel = (sticky != null && groupChain.some(r => r.model_db_id === sticky)) ? sticky : undefined; } else { // Unify OFF, or an id that isn't in the catalog: legacy single-row pin. 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, strategyKey); } // 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 { // When a handoff could fire this turn, pad the token estimate so the router's // context-window and TPM checks account for the extra system message overhead. // We don't know the selected model key until after routeRequest() returns, so // the padding is conservative on turns where injection is *possible* (a prior // model is on record). Turns where injection can't happen — every turn 1, and // sessions that never switched — pay no headroom tax. const routingEstimate = handoffPossible ? estimatedTotal + HANDOFF_MAX_TOKENS : estimatedTotal; route = routeRequest(routingEstimate, skipKeys.size > 0 ? skipKeys : undefined, preferredModel, hasImage, wantsTools, skipModels.size > 0 ? skipModels : undefined, groupChain ?? resolvedChain?.chain); } 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; } const modelKey = `${route.platform}:${route.modelId}`; traceRouteEvent('Proxy', { event: attempt === 0 ? 'start' : 'next', requestId: requestGroupId, attempt, platform: route.platform, model: route.modelId, requestedModel: attempt === 0 ? requestedModelLabel : undefined, }); let outboundMessages = messages; // Extra input tokens the injected handoff adds on this turn (0 when not // injected). Folded into the streaming success accounting, where token // counts are estimated; the non-stream path uses the provider's usage, // which already counts the injected message. let injectedHandoffTokens = 0; if (handoffMode !== 'off' && sessionKey) { const handoff = maybeInjectContextHandoff({ mode: handoffMode, sessionKey, messages, selectedModelKey: modelKey }); if (handoff.injected) console.log(`[Proxy] Context handoff injected (session ${sessionKey.slice(0, 8)}…, model switch detected)`); outboundMessages = handoff.messages; injectedHandoffTokens = handoff.injectedTokens; } 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, outboundMessages, route.modelId, { temperature, max_tokens, top_p, tools, tool_choice, parallel_tool_calls }, quotaContextForRoute(route, 'chat/completions'), ); 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 */ } traceRouteEvent('Proxy', { event: 'fail', requestId: requestGroupId, attempt, platform: route.platform, model: route.modelId, latencyMs: Date.now() - start, error: sanitizeProviderErrorMessage(String(msg)), }); 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); sanitizeResponse(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 + injectedHandoffTokens + totalOutputTokens); recordSuccess(route.modelDbId); setStickyModel(messages, route.modelDbId, sessionIdHeader, stickyStrategyKey); if (handoffMode !== 'off' && sessionKey) recordSuccessfulModel({ sessionKey, modelKey }); traceRouteEvent('Proxy', { event: 'ok', requestId: requestGroupId, attempt, platform: route.platform, model: route.modelId, latencyMs: Date.now() - start, inputTokens: estimatedInputTokens + injectedHandoffTokens, outputTokens: totalOutputTokens, }); logRequest(route.platform, route.modelId, route.keyId, 'success', estimatedInputTokens + injectedHandoffTokens, 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 */ } traceRouteEvent('Proxy', { event: 'fail', requestId: requestGroupId, attempt, platform: route.platform, model: route.modelId, latencyMs: Date.now() - start, error: sanitizeProviderErrorMessage(streamErr.message), }); logRequest(route.platform, route.modelId, route.keyId, 'error', estimatedInputTokens, totalOutputTokens, Date.now() - start, sanitizeProviderErrorMessage(streamErr.message), ttfbMs, 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, outboundMessages, route.modelId, { temperature, max_tokens, top_p, tools, tool_choice, parallel_tool_calls }, quotaContextForRoute(route, 'chat/completions'), ); // 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) { traceRouteEvent('Proxy', { event: 'fail', requestId: requestGroupId, attempt, platform: route.platform, model: route.modelId, latencyMs: Date.now() - start, error: 'empty completion', }); 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); // Use stickyStrategyKey (not the global strategyKey) so a group-pinned // request writes its sticky entry under the SAME key the next turn reads // from (set to the requested model id at the top of the loop). Matches the // streaming success path; without it, "prefer last successful provider" // is lost for non-streaming group-pinned sessions. (#341 review) setStickyModel(messages, route.modelDbId, sessionIdHeader, stickyStrategyKey); if (handoffMode !== 'off' && sessionKey) recordSuccessfulModel({ sessionKey, modelKey }); 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(sanitizeResponse(normalizeOutboundContent(result))); traceRouteEvent('Proxy', { event: 'ok', requestId: requestGroupId, attempt, platform: route.platform, model: route.modelId, latencyMs: Date.now() - start, inputTokens: result.usage?.prompt_tokens ?? 0, outputTokens: result.usage?.completion_tokens ?? 0, }); 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); traceRouteEvent('Proxy', { event: 'fail', requestId: requestGroupId, attempt, platform: route.platform, model: route.modelId, latencyMs: latency, error: safeError, }); 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); // Self-correct the catalog: if the provider reported its real ceiling in // the error body (e.g. a Groq 413 "tokens per minute (TPM): Limit 30000"), // persist it so the next request's pre-check fails over BEFORE the 413 // instead of re-discovering it. No-op for errors with no parseable limit. learnLimitFromError(route.modelDbId, err); lastError = err; 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', }, }); }); // logRequest moved to lib/request-log.ts (shared with the fusion service to // avoid an import cycle); imported above for internal use and re-exported here // for routes/responses.ts which imports it from this module. export { logRequest };