| 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', (req: Request, res: Response) => {
|
| const token = extractApiToken(req);
|
| const unifiedKey = getUnifiedApiKey();
|
| if (!token || !timingSafeStringEqual(token, unifiedKey)) {
|
| res.status(401).json({ error: { message: 'Invalid API key', type: 'authentication_error' } });
|
| return;
|
| }
|
|
|
| const db = getDb();
|
| const models = db.prepare(`
|
| SELECT platform, model_id, display_name, context_window
|
| FROM (
|
| SELECT platform, model_id, display_name, context_window, intelligence_rank, id,
|
| ROW_NUMBER() OVER (
|
| PARTITION BY model_id
|
| ORDER BY intelligence_rank ASC, id ASC
|
| ) AS rn
|
| FROM models
|
| WHERE enabled = 1
|
| )
|
| WHERE rn = 1
|
| ORDER BY intelligence_rank ASC, id ASC
|
| `).all() as ModelListRow[];
|
|
|
| res.json({
|
| object: 'list',
|
| data: [
|
| {
|
| id: AUTO_MODEL_ID,
|
| object: 'model',
|
| created: 0,
|
| owned_by: 'freellmapi',
|
| name: 'Auto (router picks the best available model)',
|
| context_window: null,
|
| },
|
| ...models.map(m => ({
|
| id: m.model_id,
|
| object: 'model',
|
| created: 0,
|
| owned_by: m.platform,
|
| name: m.display_name,
|
| context_window: m.context_window,
|
| })),
|
| ],
|
| });
|
| });
|
|
|
| const MAX_RETRIES = 20;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 = 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 = 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 && !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 && !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 = 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);
|
| }
|
|
|
|
|
|
|
|
|
| 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 = 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;
|
| }
|
|
|
| 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 { }
|
| 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();
|
|
|
| recordTokens(route.platform, route.modelId, route.keyId, estimatedInputTokens + totalOutputTokens);
|
| recordSuccess(route.modelDbId);
|
| setStickyModel(messages, route.modelDbId, sessionIdHeader);
|
| logRequest(route.platform, route.modelId, route.keyId, 'success', estimatedInputTokens, totalOutputTokens, Date.now() - start, null, ttfbMs, pinnedModelId);
|
| return;
|
| } catch (streamErr: any) {
|
| if (headerSent) {
|
|
|
|
|
| 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 { }
|
| 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) {
|
| 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;
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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;
|
| 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));
|
|
|
| logRequest(
|
| route.platform, route.modelId, route.keyId, 'success',
|
| result.usage?.prompt_tokens ?? 0,
|
| result.usage?.completion_tokens ?? 0,
|
| Date.now() - start, null, null, pinnedModelId,
|
| );
|
| return;
|
| }
|
| } catch (err: any) {
|
| const latency = Date.now() - start;
|
| const safeError = sanitizeProviderErrorMessage(err.message);
|
| logRequest(route.platform, route.modelId, route.keyId, 'error', estimatedInputTokens, 0, latency, safeError, null, pinnedModelId);
|
|
|
| if (isRetryableError(err)) {
|
|
|
| const skipId = `${route.platform}:${route.modelId}:${route.keyId}`;
|
| skipKeys.add(skipId);
|
| setCooldown(
|
| route.platform,
|
| route.modelId,
|
| route.keyId,
|
| isPaymentRequiredError(err)
|
| ? PAYMENT_REQUIRED_COOLDOWN_MS
|
| : 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 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();
|
| 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);
|
| }
|
| }
|
|
|