freellmapi / server /src /routes /proxy.ts
Nryn215's picture
Deploy: Enable and enforce pure ELO-based Intelligence routing strategy
ed57015
Raw
History Blame Contribute Delete
67.8 kB
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 <key>` 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<string, { modelDbId: number; lastUsed: number }>();
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, unknown>): 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<string>();
const skipModels = new Set<number>();
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<number, { id?: string; name: string; args: string }>();
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<string, unknown>, 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<string, any>;
// 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 };