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