| import crypto from 'crypto'; |
| import { Router } from 'express'; |
| import type { Request, Response } from 'express'; |
| import { z } from 'zod'; |
| import type { ChatMessage, ModelListRow } from '@freellmapi/shared/types.js'; |
| import { routeRequest, recordRateLimitHit, recordSuccess, hasEnabledVisionModel, hasEnabledToolsModel, type RouteResult } from '../services/router.js'; |
| import { recordRequest, recordTokens, setCooldown, getCooldownDurationForLimit, PAYMENT_REQUIRED_COOLDOWN_MS } 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(); |
|
|
| |
| |
| |
| |
| const AUTO_MODEL_ID = 'auto'; |
|
|
| function isAutoModel(modelId: string | undefined): boolean { |
| return modelId === AUTO_MODEL_ID; |
| } |
|
|
| |
| |
| |
| export function timingSafeStringEqual(provided: string, expected: string): boolean { |
| const a = Buffer.from(provided); |
| const b = Buffer.from(expected); |
| |
| |
| |
| const compareA = a.length === b.length ? a : Buffer.alloc(b.length); |
| return crypto.timingSafeEqual(compareA, b) && a.length === b.length; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| export function extractApiToken(req: Request): string | undefined { |
| const bearer = req.headers.authorization?.replace(/^Bearer\s+/i, '').trim(); |
| if (bearer) return bearer; |
|
|
| const apiKeyHeader = req.headers['x-api-key']; |
| const xApiKey = Array.isArray(apiKeyHeader) ? apiKeyHeader[0] : apiKeyHeader; |
| const trimmed = xApiKey?.trim(); |
| return trimmed || undefined; |
| } |
|
|
| |
| |
| |
| const stickySessionMap = new Map<string, { modelDbId: number; lastUsed: number }>(); |
| const STICKY_TTL_MS = 30 * 60 * 1000; |
|
|
| function getSessionKey(messages: ChatMessage[], sessionIdHeader?: string): string { |
| |
| |
| |
| if (sessionIdHeader) return `hdr:${sessionIdHeader}`; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const firstUser = messages.find(m => m.role === 'user'); |
| if (!firstUser) return ''; |
| const text = contentToString(firstUser.content ?? ''); |
| if (!text) return ''; |
| return crypto.createHash('sha1').update(text).digest('hex'); |
| } |
|
|
| export function getStickyModel(messages: ChatMessage[], sessionIdHeader?: string): number | undefined { |
| |
| const hasAssistant = messages.some(m => m.role === 'assistant'); |
| if (!hasAssistant) return undefined; |
|
|
| const key = getSessionKey(messages, sessionIdHeader); |
| if (!key) return undefined; |
|
|
| const entry = stickySessionMap.get(key); |
| if (!entry) return undefined; |
|
|
| if (Date.now() - entry.lastUsed > STICKY_TTL_MS) { |
| stickySessionMap.delete(key); |
| return undefined; |
| } |
| return entry.modelDbId; |
| } |
|
|
| export function setStickyModel(messages: ChatMessage[], modelDbId: number, sessionIdHeader?: string) { |
| const key = getSessionKey(messages, sessionIdHeader); |
| if (!key) return; |
| stickySessionMap.set(key, { modelDbId, lastUsed: Date.now() }); |
|
|
| |
| if (stickySessionMap.size > 500) { |
| const now = Date.now(); |
| for (const [k, v] of stickySessionMap) { |
| if (now - v.lastUsed > STICKY_TTL_MS) stickySessionMap.delete(k); |
| } |
| } |
| } |
|
|
| |
| proxyRouter.get('/models', async (req: Request, res: Response) => { |
| const token = extractApiToken(req); |
| const unifiedKey = await getUnifiedApiKey(); |
| if (!token || !timingSafeStringEqual(token, unifiedKey)) { |
| res.status(401).json({ error: { message: 'Invalid API key', type: 'authentication_error' } }); |
| return; |
| } |
|
|
| const db = getDb(); |
| const models = await db.all<ModelListRow>(` |
| 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 |
| WHERE enabled = 1 |
| ) |
| WHERE rn = 1 |
| ORDER BY intelligence_rank ASC, id ASC |
| `); |
|
|
| 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; |
|
|
| |
| |
| |
| |
| |
| |
| |
| 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); |
|
|
| |
| |
| |
| |
| |
| |
| |
| 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(), |
| }); |
|
|
| |
| |
| |
| 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(), |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| const assistantMessageSchema = z.object({ |
| role: z.literal('assistant'), |
| content: z.union([contentSchema, z.null()]).optional(), |
| name: z.string().optional(), |
| |
| |
| |
| tool_calls: z.array(toolCallSchema).nullable().optional(), |
| }); |
|
|
| |
| |
| |
| 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(), |
| }); |
|
|
| |
| |
| const functionMessageSchema = z.object({ |
| role: z.literal('function'), |
| name: z.string().min(1), |
| content: z.union([contentSchema, z.null()]).optional(), |
| }); |
|
|
| const toolDefinitionSchema = z.object({ |
| |
| |
| 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([ |
| |
| |
| 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(), |
| |
| |
| max_tokens: z.number().int().optional(), |
| top_p: z.number().min(0).max(1).optional(), |
| stream: z.boolean().optional(), |
| |
| |
| |
| tools: z.array(toolDefinitionSchema).nullable().optional(), |
| tool_choice: toolChoiceSchema.nullable().optional(), |
| parallel_tool_calls: z.boolean().nullable().optional(), |
| }); |
|
|
| export function isRetryableError(err: any): boolean { |
| const msg = (err.message ?? '').toLowerCase(); |
| return msg.includes('429') || msg.includes('rate limit') || msg.includes('too many requests') |
| || msg.includes('quota') || msg.includes('resource_exhausted') |
| || msg.includes('aborted') || msg.includes('timeout') || msg.includes('etimedout') |
| || msg.includes('econnrefused') || msg.includes('econnreset') |
| || msg.includes('503') || msg.includes('unavailable') |
| || msg.includes('500') || msg.includes('internal server error') |
| |
| |
| || 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') |
| |
| |
| |
| || msg.includes('404') || msg.includes('not found') || msg.includes('no endpoints found') |
| |
| |
| |
| |
| || msg.includes('api error 400') |
| |
| |
| |
| |
| |
| || isPaymentRequiredError(err) |
| |
| |
| |
| || 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'); |
| } |
|
|
| |
| |
| |
| export function isPaymentRequiredError(err: any): boolean { |
| const msg = (err.message ?? '').toLowerCase(); |
| return msg.includes('402') || msg.includes('payment required') |
| || msg.includes('insufficient_quota') || msg.includes('insufficient credit') |
| || msg.includes('insufficient balance'); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| export function streamChunkText(chunk: any): string { |
| return chunk?.choices?.[0]?.delta?.content ?? ''; |
| } |
|
|
| |
| |
| |
| |
| |
| 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 = await getUnifiedApiKey(); |
| if (!token || !timingSafeStringEqual(token, unifiedKey)) { |
| res.status(401).json({ error: { message: 'Invalid API key', type: 'authentication_error' } }); |
| return; |
| } |
| const parsed = EmbeddingsBody.safeParse(req.body); |
| if (!parsed.success) { |
| res.status(400).json({ error: { message: 'Invalid request: `input` is required', type: 'invalid_request_error' } }); |
| return; |
| } |
| const inputs = Array.isArray(parsed.data.input) ? parsed.data.input : [parsed.data.input]; |
| try { |
| const result = await runEmbeddings(parsed.data.model, inputs); |
| res.json({ |
| object: 'list', |
| data: result.vectors.map((values, i) => ({ object: 'embedding', index: i, embedding: values })), |
| model: result.family, |
| provider: result.platform, |
| usage: { prompt_tokens: result.inputTokens, total_tokens: result.inputTokens }, |
| }); |
| } catch (err: any) { |
| const status = err instanceof EmbeddingsError ? err.status : 502; |
| const type = status === 400 ? 'invalid_request_error' : status === 429 ? 'rate_limit_error' : 'server_error'; |
| res.status(status).json({ error: { message: `embedding error: ${err?.message ?? 'unknown'}`, type } }); |
| } |
| }); |
|
|
| proxyRouter.post('/chat/completions', async (req: Request, res: Response) => { |
| const start = Date.now(); |
|
|
| |
| |
| |
| const token = extractApiToken(req); |
| const unifiedKey = await getUnifiedApiKey(); |
| if (!token || !timingSafeStringEqual(token, unifiedKey)) { |
| res.status(401).json({ |
| error: { message: 'Invalid API key', type: 'authentication_error' }, |
| }); |
| return; |
| } |
|
|
| |
| const parsed = chatCompletionSchema.safeParse(req.body); |
| if (!parsed.success) { |
| |
| |
| |
| 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 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; |
|
|
| |
| |
| |
| |
| 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; |
| |
| |
| |
| 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 } : {}), |
| |
| |
| |
| ...(hasToolCalls ? { tool_calls: m.tool_calls!.map(tc => { |
| |
| |
| |
| 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', |
| |
| content: m.content ?? '', |
| tool_call_id: takeToolCallId(m.tool_call_id), |
| ...(m.name ? { name: m.name } : {}), |
| }; |
| } |
|
|
| |
| |
| if (m.role === 'function') { |
| return { |
| role: 'tool', |
| content: m.content ?? '', |
| tool_call_id: takeToolCallId(undefined), |
| name: m.name, |
| }; |
| } |
|
|
| return { |
| |
| |
| role: m.role === 'developer' ? 'system' : m.role, |
| content: m.content, |
| ...(m.name ? { name: m.name } : {}), |
| }; |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| const estimatedInputTokens = messages.reduce((sum, m) => { |
| const text = contentToString(m.content); |
| return sum + Math.ceil(text.length / 4); |
| }, 0); |
|
|
| |
| |
| |
| |
| |
| const hasImage = messageHasImage(messages); |
| if (hasImage && !(await 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); |
|
|
| |
| |
| |
| |
| |
| const wantsTools = (tools?.length ?? 0) > 0; |
| if (wantsTools && !(await 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; |
| } |
|
|
| |
| |
| |
| const rawSessionId = req.headers['x-session-id']; |
| const sessionIdHeader = Array.isArray(rawSessionId) ? rawSessionId[0] : rawSessionId; |
|
|
| |
| |
| |
| |
| let preferredModel: number | undefined; |
| if (isAutoModel(requestedModel)) { |
| |
| preferredModel = getStickyModel(messages, sessionIdHeader); |
| } else if (requestedModel) { |
| const db = getDb(); |
| const enabled = await db.get<{ id: number }>('SELECT id FROM models WHERE model_id = ? AND enabled = 1', [requestedModel]); |
| if (enabled) { |
| preferredModel = enabled.id; |
| } else { |
| const disabled = await db.get<{ id: number }>('SELECT id FROM models WHERE model_id = ?', [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); |
| } |
|
|
| |
| |
| |
| const pinnedModelId = requestedModel && !isAutoModel(requestedModel) ? requestedModel : null; |
|
|
| |
| const skipKeys = new Set<string>(); |
| let lastError: any = null; |
|
|
| for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { |
| let route: RouteResult; |
| try { |
| route = await routeRequest(estimatedTotal, skipKeys.size > 0 ? skipKeys : undefined, preferredModel, hasImage, wantsTools); |
| } catch (err: any) { |
| |
| 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; |
| } |
|
|
| await recordRequest(route.platform, route.modelId, route.keyId); |
|
|
| try { |
| if (stream) { |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| let totalOutputTokens = 0; |
| let headerSent = false; |
| let ttfbMs: number | null = null; |
|
|
| |
| |
| |
| let mode: 'undecided' | 'passthrough' | 'dialect' = 'undecided'; |
| let heldText = ''; |
| const preamble: unknown[] = []; |
| 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, messages, route.modelId, |
| { temperature, max_tokens, top_p, tools, tool_choice, parallel_tool_calls }, |
| ); |
|
|
| for await (const chunk of gen) { |
| const anyChunk = chunk as Record<string, any>; |
|
|
| |
| |
| |
| |
| |
| 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 { } |
| await 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) { |
| |
| |
| if (anyChunk.usage) usageChunk = anyChunk; |
| continue; |
| } |
|
|
| if (choice.finish_reason) upstreamFinish = choice.finish_reason; |
|
|
| |
| 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) { |
| |
| |
| |
| |
| |
| 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) || heldText.length > 256) { |
| mode = 'passthrough'; |
| flushHeaders(); |
| writeChunk(mkChunk({ content: heldText }, null)); |
| heldText = ''; |
| } |
| |
| } |
|
|
| |
|
|
| |
| |
| |
| 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; } }); |
|
|
| |
| |
| |
| |
| 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) { |
| |
| |
| |
| 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)); |
| totalOutputTokens += Math.ceil(heldText.length / 4); |
| } |
| 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); |
| } |
| |
| |
| |
| 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(); |
|
|
| await recordTokens(route.platform, route.modelId, route.keyId, estimatedInputTokens + totalOutputTokens); |
| recordSuccess(route.modelDbId); |
| setStickyModel(messages, route.modelDbId, sessionIdHeader); |
| await logRequest(route.platform, route.modelId, route.keyId, 'success', estimatedInputTokens, totalOutputTokens, Date.now() - start, null, ttfbMs, pinnedModelId); |
| return; |
| } catch (streamErr: any) { |
| if (headerSent) { |
| |
| |
| 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 { } |
| try { res.write('data: [DONE]\n\n'); res.end(); } catch { } |
| await logRequest(route.platform, route.modelId, route.keyId, 'error', estimatedInputTokens, totalOutputTokens, Date.now() - start, sanitizeProviderErrorMessage(streamErr.message), null, pinnedModelId); |
| return; |
| } |
| |
| |
| |
| |
| 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 }, |
| ); |
|
|
| |
| |
| |
| const respMsg = result.choices?.[0]?.message; |
| const respText = contentToString(respMsg?.content ?? ''); |
| if (!respText && (respMsg?.tool_calls?.length ?? 0) === 0) { |
| await 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}`); |
| await setCooldown(route.platform, route.modelId, route.keyId, await 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; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| 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; |
| await 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)); |
| |
| |
| |
| |
| 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)); |
| } |
| } |
| } |
| |
| res.json(normalizeOutboundContent(result)); |
|
|
| await 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); |
| await logRequest(route.platform, route.modelId, route.keyId, 'error', estimatedInputTokens, 0, latency, safeError, null, pinnedModelId); |
|
|
| if (isRetryableError(err)) { |
| |
| const skipId = `${route.platform}:${route.modelId}:${route.keyId}`; |
| skipKeys.add(skipId); |
| await setCooldown( |
| route.platform, |
| route.modelId, |
| route.keyId, |
| isPaymentRequiredError(err) |
| ? PAYMENT_REQUIRED_COOLDOWN_MS |
| : await getCooldownDurationForLimit(route.platform, route.modelId, route.keyId, { |
| rpd: route.rpdLimit, |
| tpd: route.tpdLimit, |
| }), |
| ); |
| recordRateLimitHit(route.modelDbId); |
| lastError = err; |
| console.log(`[Proxy] ${safeError.slice(0, 60)} from ${route.displayName}, falling back (attempt ${attempt + 1}/${MAX_RETRIES})`); |
| continue; |
| } |
|
|
| |
| res.status(502).json({ |
| error: { |
| message: `Provider error (${route.displayName}): ${safeError}`, |
| type: 'provider_error', |
| }, |
| }); |
| return; |
| } |
| } |
|
|
| |
| res.status(429).json({ |
| error: { |
| message: `All models rate-limited after ${MAX_RETRIES} attempts. Last: ${sanitizeProviderErrorMessage(lastError?.message)}`, |
| type: 'rate_limit_error', |
| }, |
| }); |
| }); |
|
|
| export async function logRequest( |
| platform: string, |
| modelId: string, |
| keyId: number, |
| status: string, |
| inputTokens: number, |
| outputTokens: number, |
| latencyMs: number, |
| error: string | null, |
| ttfbMs: number | null = null, |
| |
| |
| |
| requestedModel: string | null = null, |
| ) { |
| try { |
| const db = getDb(); |
| await db.run(` |
| INSERT INTO requests (platform, model_id, key_id, status, input_tokens, output_tokens, latency_ms, error, ttfb_ms, requested_model) |
| VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) |
| `, [platform, modelId, keyId, status, inputTokens, outputTokens, latencyMs, error, ttfbMs, requestedModel]); |
| await pruneRequestAnalytics({ db }); |
| } catch (e) { |
| console.error('Failed to log request:', e); |
| } |
| } |
|
|