@nvae/llmswitch 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1053 @@
1
+ /**
2
+ * Outward-facing AI gateway HTTP server.
3
+ *
4
+ * Third-party clients authenticate with a gateway-issued API key, address any
5
+ * configured provider by model id, and may speak OpenAI Chat, OpenAI Responses
6
+ * or Anthropic Messages. Requests are routed with provider fallback and
7
+ * translated between formats as needed.
8
+ */
9
+ import { createServer, } from "node:http";
10
+ import { parseBridgeRuntimeLimits } from "../bridge/runtime.js";
11
+ import { randomBytes } from "node:crypto";
12
+ import { requestWithNodeTransport, } from "../bridge/transport.js";
13
+ import { authenticateGatewayKey, keyAllowsTarget, listGatewayKeys, touchGatewayKey, } from "./keys.js";
14
+ import { createInboundEncoder, createUpstreamDecoder, chatCompletionToInbound, chatCompletionToLegacyCompletion, chatRequestToUpstream, formatErrorBody, inboundToChatRequest, legacyPromptToChatBody, parseInboundRequest, translateUpstreamError, upstreamPath, upstreamToChatCompletion, withRequestedModel, } from "./pipeline.js";
15
+ import { ModelNotRoutableError, listRoutableModels, resolveModelRoute, } from "./router.js";
16
+ import { listGatewayProviders, listGatewayRoutes, readGatewayConfig, } from "./store.js";
17
+ import { constantTimeTokenEqual, readGatewayState } from "./state.js";
18
+ import { ProviderBreaker } from "./health.js";
19
+ import { countAnthropicInputTokens } from "./tokens.js";
20
+ import { recordUsage } from "./usage.js";
21
+ import { providerFormat, } from "./types.js";
22
+ const SSE_HEADERS = {
23
+ "Content-Type": "text/event-stream; charset=utf-8",
24
+ "Cache-Control": "no-cache, no-transform",
25
+ Connection: "keep-alive",
26
+ "X-Accel-Buffering": "no",
27
+ };
28
+ const SSE_HEARTBEAT_MS = 15_000;
29
+ class RequestBodyTooLargeError extends Error {
30
+ maxBytes;
31
+ constructor(maxBytes) {
32
+ super(`请求体超过 ${maxBytes} 字节上限`);
33
+ this.maxBytes = maxBytes;
34
+ this.name = "RequestBodyTooLargeError";
35
+ }
36
+ }
37
+ function headerValue(value) {
38
+ return Array.isArray(value) ? value[0] : value;
39
+ }
40
+ const REQUEST_ID_SOURCE = Symbol("llm-switch-request-id");
41
+ const REQUEST_ID_RE = /^[A-Za-z0-9._-]{1,64}$/;
42
+ /** Echo a client-supplied id or mint a fresh one for correlation. */
43
+ function requestIdOf(req) {
44
+ const bag = req;
45
+ const existing = bag[REQUEST_ID_SOURCE];
46
+ if (typeof existing === "string")
47
+ return existing;
48
+ const supplied = headerValue(req.headers["x-request-id"]);
49
+ const id = supplied && REQUEST_ID_RE.test(supplied)
50
+ ? supplied
51
+ : randomBytes(8).toString("hex");
52
+ bag[REQUEST_ID_SOURCE] = id;
53
+ return id;
54
+ }
55
+ /** Accept both OpenAI (`Authorization: Bearer`) and Anthropic (`x-api-key`). */
56
+ function presentedKey(req) {
57
+ const authorization = headerValue(req.headers.authorization);
58
+ const bearer = authorization?.match(/^Bearer\s+(\S+)$/i)?.[1];
59
+ if (bearer)
60
+ return bearer;
61
+ const apiKey = headerValue(req.headers["x-api-key"]);
62
+ if (apiKey)
63
+ return apiKey;
64
+ return undefined;
65
+ }
66
+ function controlToken(req) {
67
+ return headerValue(req.headers["x-llm-switch-control"]);
68
+ }
69
+ function readBody(req, maxBytes) {
70
+ return new Promise((resolve, reject) => {
71
+ const chunks = [];
72
+ let bytes = 0;
73
+ let settled = false;
74
+ const cleanup = () => {
75
+ req.off("data", onData);
76
+ req.off("end", onEnd);
77
+ req.off("error", onError);
78
+ };
79
+ const onData = (value) => {
80
+ if (settled)
81
+ return;
82
+ const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value);
83
+ bytes += chunk.length;
84
+ if (bytes > maxBytes) {
85
+ settled = true;
86
+ cleanup();
87
+ req.resume();
88
+ reject(new RequestBodyTooLargeError(maxBytes));
89
+ return;
90
+ }
91
+ chunks.push(chunk);
92
+ };
93
+ const onEnd = () => {
94
+ if (settled)
95
+ return;
96
+ settled = true;
97
+ cleanup();
98
+ resolve(Buffer.concat(chunks));
99
+ };
100
+ const onError = (error) => {
101
+ if (settled)
102
+ return;
103
+ settled = true;
104
+ cleanup();
105
+ reject(error);
106
+ };
107
+ req.on("data", onData);
108
+ req.on("end", onEnd);
109
+ req.on("error", onError);
110
+ });
111
+ }
112
+ function sendJson(res, status, body, extraHeaders = {}) {
113
+ if (res.headersSent) {
114
+ res.end();
115
+ return;
116
+ }
117
+ const raw = JSON.stringify(body);
118
+ res.writeHead(status, {
119
+ "Content-Type": "application/json; charset=utf-8",
120
+ "Content-Length": Buffer.byteLength(raw),
121
+ ...extraHeaders,
122
+ });
123
+ res.end(raw);
124
+ }
125
+ /** Join a provider base URL with an API path, honoring its path prefix. */
126
+ export function upstreamUrl(provider, path) {
127
+ const base = provider.baseUrl.replace(/\/+$/, "");
128
+ const suffix = path.startsWith("/") ? path : `/${path}`;
129
+ const prefix = (provider.pathPrefix ?? "v1").replace(/^\/+|\/+$/g, "");
130
+ if (!prefix)
131
+ return `${base}${suffix}`;
132
+ if (base.endsWith(`/${prefix}`))
133
+ return `${base}${suffix}`;
134
+ return `${base}/${prefix}${suffix}`;
135
+ }
136
+ const HOP_BY_HOP = /^(connection|keep-alive|proxy-authenticate|proxy-authorization|te|trailer|transfer-encoding|upgrade|host|content-length)$/i;
137
+ /**
138
+ * Client headers worth relaying on a format-preserving pass-through, so beta
139
+ * programs (`anthropic-beta`, `openai-beta`) keep working through the gateway.
140
+ * Provider-configured headers always win.
141
+ */
142
+ const FORWARDED_CLIENT_HEADERS = [
143
+ "anthropic-version",
144
+ "anthropic-beta",
145
+ "openai-beta",
146
+ ];
147
+ export function buildUpstreamHeaders(provider, req) {
148
+ const headers = {
149
+ Accept: "application/json",
150
+ "Content-Type": "application/json",
151
+ };
152
+ const format = providerFormat(provider);
153
+ if (format === "anthropic") {
154
+ headers["anthropic-version"] = "2023-06-01";
155
+ if (provider.apiKey)
156
+ headers["x-api-key"] = provider.apiKey;
157
+ }
158
+ else if (provider.apiKey) {
159
+ headers.Authorization = `Bearer ${provider.apiKey}`;
160
+ }
161
+ let hasAuthorization = Boolean(headers.Authorization);
162
+ const providerHeaderNames = new Set(Object.keys(provider.headers || {}).map((name) => name.toLowerCase()));
163
+ for (const [name, value] of Object.entries(provider.headers || {})) {
164
+ if (HOP_BY_HOP.test(name))
165
+ continue;
166
+ headers[name] = value;
167
+ if (name.toLowerCase() === "authorization")
168
+ hasAuthorization = true;
169
+ }
170
+ // An explicit authorization header replaces the derived bearer token.
171
+ if (hasAuthorization && provider.apiKey && format !== "anthropic") {
172
+ const explicit = Object.keys(provider.headers || {}).find((name) => name.toLowerCase() === "authorization");
173
+ if (explicit)
174
+ headers.Authorization = provider.headers[explicit];
175
+ }
176
+ if (req) {
177
+ for (const name of FORWARDED_CLIENT_HEADERS) {
178
+ if (providerHeaderNames.has(name))
179
+ continue;
180
+ const value = headerValue(req.headers[name]);
181
+ if (value !== undefined)
182
+ headers[name] = value;
183
+ }
184
+ // Correlate upstream calls with the gateway request id.
185
+ headers["x-request-id"] = requestIdOf(req);
186
+ }
187
+ return headers;
188
+ }
189
+ /**
190
+ * Tolerant token-usage extraction: upstream payloads and hub chunks speak
191
+ * either the OpenAI or the Anthropic usage vocabulary.
192
+ */
193
+ export function extractTokenUsage(payload) {
194
+ const usage = payload?.usage;
195
+ if (!usage || typeof usage !== "object" || Array.isArray(usage)) {
196
+ return {};
197
+ }
198
+ const row = usage;
199
+ const num = (...names) => {
200
+ for (const name of names) {
201
+ const value = row[name];
202
+ if (typeof value === "number" && Number.isFinite(value))
203
+ return value;
204
+ }
205
+ return undefined;
206
+ };
207
+ const inputTokens = num("prompt_tokens", "input_tokens");
208
+ const outputTokens = num("completion_tokens", "output_tokens");
209
+ return {
210
+ ...(inputTokens !== undefined ? { inputTokens } : {}),
211
+ ...(outputTokens !== undefined ? { outputTokens } : {}),
212
+ };
213
+ }
214
+ function isRetryableStatus(config, status) {
215
+ return config.fallback.retryStatuses.includes(status);
216
+ }
217
+ function authFailureResponse(format, reason, retryAfterSeconds, rate) {
218
+ const messages = {
219
+ missing: [
220
+ 401,
221
+ "缺少 API Key。请在 Authorization: Bearer <key> 或 x-api-key 中提供。",
222
+ "missing_api_key",
223
+ ],
224
+ malformed: [401, "API Key 格式无效。", "invalid_api_key"],
225
+ unknown: [401, "API Key 无效。", "invalid_api_key"],
226
+ revoked: [401, "API Key 已被吊销。", "revoked_api_key"],
227
+ expired: [401, "API Key 已过期。", "expired_api_key"],
228
+ format_denied: [
229
+ 403,
230
+ "该 API Key 无权访问此接口格式。",
231
+ "format_not_allowed",
232
+ ],
233
+ rate_limited: [429, "请求频率超过限制。", "rate_limit_exceeded"],
234
+ };
235
+ const [status, message, code] = messages[reason];
236
+ const body = formatErrorBody(format, status, message, code);
237
+ const headers = rateLimitHeaders(rate);
238
+ if (status === 401) {
239
+ headers["WWW-Authenticate"] = 'Bearer realm="llm-switch-gateway"';
240
+ }
241
+ if (reason === "rate_limited" && retryAfterSeconds) {
242
+ headers["Retry-After"] = String(retryAfterSeconds);
243
+ }
244
+ return { status: body.status, payload: body.payload, headers };
245
+ }
246
+ /** Standard rate-limit hints so clients can self-throttle. */
247
+ function rateLimitHeaders(rate) {
248
+ if (!rate || rate.limit <= 0)
249
+ return {};
250
+ return {
251
+ "X-RateLimit-Limit": String(rate.limit),
252
+ "X-RateLimit-Remaining": String(Math.max(0, rate.remaining)),
253
+ "X-RateLimit-Reset": String(rate.resetAt),
254
+ };
255
+ }
256
+ function logRequest(enabled, fields) {
257
+ if (!enabled)
258
+ return;
259
+ const parts = [
260
+ new Date().toISOString(),
261
+ `${fields.method} ${fields.path}`,
262
+ `status=${fields.status}`,
263
+ `dur=${fields.durationMs}ms`,
264
+ ];
265
+ if (fields.requestId)
266
+ parts.push(`req=${fields.requestId}`);
267
+ if (fields.model)
268
+ parts.push(`model=${fields.model}`);
269
+ if (fields.provider)
270
+ parts.push(`provider=${fields.provider}`);
271
+ if (fields.attempts && fields.attempts > 1) {
272
+ parts.push(`attempts=${fields.attempts}`);
273
+ }
274
+ // Only the key id is logged; the secret never appears in logs.
275
+ if (fields.keyId)
276
+ parts.push(`key=${fields.keyId}`);
277
+ console.error(parts.join(" "));
278
+ }
279
+ function applyCors(req, res, config) {
280
+ if (!config.corsOrigins.length)
281
+ return;
282
+ const origin = headerValue(req.headers.origin);
283
+ if (!origin)
284
+ return;
285
+ const allowAll = config.corsOrigins.includes("*");
286
+ if (!allowAll && !config.corsOrigins.includes(origin))
287
+ return;
288
+ res.setHeader("Access-Control-Allow-Origin", allowAll ? "*" : origin);
289
+ res.setHeader("Vary", "Origin");
290
+ res.setHeader("Access-Control-Allow-Headers", "authorization, x-api-key, content-type, anthropic-version, anthropic-beta, openai-beta, x-request-id");
291
+ res.setHeader("Access-Control-Expose-Headers", "x-request-id, retry-after, x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset");
292
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
293
+ res.setHeader("Access-Control-Max-Age", "600");
294
+ }
295
+ /** Bounded in-flight request counter shared by all data-plane endpoints. */
296
+ class ConcurrencyGate {
297
+ max;
298
+ active = 0;
299
+ constructor(max) {
300
+ this.max = max;
301
+ }
302
+ get inFlight() {
303
+ return this.active;
304
+ }
305
+ tryAcquire() {
306
+ if (this.active >= this.max)
307
+ return false;
308
+ this.active += 1;
309
+ return true;
310
+ }
311
+ release() {
312
+ if (this.active > 0)
313
+ this.active -= 1;
314
+ }
315
+ }
316
+ export function createGatewayServer(options = {}) {
317
+ const limits = parseBridgeRuntimeLimits();
318
+ const gate = new ConcurrencyGate(limits.maxConcurrency);
319
+ const breaker = new ProviderBreaker();
320
+ const logEnabled = options.log !== false;
321
+ const startedAtIso = new Date().toISOString();
322
+ const startedMs = Date.now();
323
+ const stats = { requests: 0, errors4xx: 0, errors5xx: 0 };
324
+ return createServer(async (req, res) => {
325
+ const startedAt = Date.now();
326
+ stats.requests += 1;
327
+ const config = readGatewayConfig();
328
+ applyCors(req, res, config);
329
+ const requestId = requestIdOf(req);
330
+ res.setHeader("x-request-id", requestId);
331
+ const url = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);
332
+ const path = url.pathname.replace(/\/+$/, "") || "/";
333
+ const method = req.method || "GET";
334
+ const finish = (status, extra = {}) => {
335
+ if (status >= 500)
336
+ stats.errors5xx += 1;
337
+ else if (status >= 400)
338
+ stats.errors4xx += 1;
339
+ logRequest(logEnabled, {
340
+ method,
341
+ path,
342
+ status,
343
+ requestId,
344
+ durationMs: Date.now() - startedAt,
345
+ ...extra,
346
+ });
347
+ };
348
+ try {
349
+ if (method === "OPTIONS") {
350
+ res.writeHead(204);
351
+ res.end();
352
+ finish(204);
353
+ return;
354
+ }
355
+ // --- control plane ----------------------------------------------------
356
+ if (method === "GET" && (path === "/health" || path === "/v1/health")) {
357
+ const supplied = controlToken(req);
358
+ const state = readGatewayState();
359
+ const expectedToken = options.controlToken ?? state.instance?.controlToken;
360
+ const expectedId = options.instanceId ?? state.instance?.id;
361
+ if (!supplied) {
362
+ sendJson(res, 200, { ok: true, service: "llm-switch-gateway" });
363
+ finish(200);
364
+ return;
365
+ }
366
+ if (!expectedToken || !constantTimeTokenEqual(expectedToken, supplied)) {
367
+ sendJson(res, 401, {
368
+ ok: false,
369
+ error: { code: "invalid_control_token", message: "Unauthorized" },
370
+ });
371
+ finish(401);
372
+ return;
373
+ }
374
+ const providers = listGatewayProviders();
375
+ sendJson(res, 200, {
376
+ ok: true,
377
+ service: "llm-switch-gateway",
378
+ instanceId: expectedId,
379
+ startedAt: state.instance?.startedAt ?? startedAtIso,
380
+ uptimeSeconds: Math.floor((Date.now() - startedMs) / 1000),
381
+ stats: {
382
+ requests: stats.requests,
383
+ errors4xx: stats.errors4xx,
384
+ errors5xx: stats.errors5xx,
385
+ activeConnections: gate.inFlight,
386
+ maxConcurrency: limits.maxConcurrency,
387
+ },
388
+ providers: providers.map((provider) => ({
389
+ name: provider.name,
390
+ apiFormat: provider.apiFormat,
391
+ enabled: provider.enabled,
392
+ models: provider.models.length,
393
+ })),
394
+ routes: listGatewayRoutes().length,
395
+ activeKeys: listGatewayKeys().filter((key) => !key.revokedAt).length,
396
+ breakers: breaker.snapshot(),
397
+ });
398
+ finish(200);
399
+ return;
400
+ }
401
+ if (method === "POST" && path === "/_control/shutdown") {
402
+ const state = readGatewayState();
403
+ const expectedToken = options.controlToken ?? state.instance?.controlToken;
404
+ const expectedId = options.instanceId ?? state.instance?.id;
405
+ const supplied = controlToken(req);
406
+ if (!expectedToken || !constantTimeTokenEqual(expectedToken, supplied)) {
407
+ sendJson(res, 401, {
408
+ ok: false,
409
+ error: { code: "invalid_control_token", message: "Unauthorized" },
410
+ });
411
+ finish(401);
412
+ return;
413
+ }
414
+ let instanceId = "";
415
+ try {
416
+ const parsed = JSON.parse((await readBody(req, 4_096)).toString("utf8"));
417
+ instanceId =
418
+ typeof parsed.instanceId === "string" ? parsed.instanceId : "";
419
+ }
420
+ catch {
421
+ sendJson(res, 400, {
422
+ error: { message: "Invalid JSON body" },
423
+ });
424
+ finish(400);
425
+ return;
426
+ }
427
+ if (!expectedId || instanceId !== expectedId) {
428
+ sendJson(res, 409, {
429
+ error: {
430
+ code: "instance_mismatch",
431
+ message: "Gateway instance mismatch",
432
+ },
433
+ });
434
+ finish(409);
435
+ return;
436
+ }
437
+ sendJson(res, 202, { ok: true, instanceId });
438
+ finish(202);
439
+ queueMicrotask(() => {
440
+ void options.onShutdown?.(instanceId);
441
+ });
442
+ return;
443
+ }
444
+ if (method === "GET" && path === "/") {
445
+ sendJson(res, 200, {
446
+ service: "llm-switch-gateway",
447
+ endpoints: [
448
+ "GET /v1/models",
449
+ "GET /v1/models/{id}",
450
+ "POST /v1/chat/completions",
451
+ "POST /v1/completions",
452
+ "POST /v1/messages",
453
+ "POST /v1/messages/count_tokens",
454
+ "POST /v1/responses",
455
+ "POST /v1/embeddings",
456
+ ],
457
+ });
458
+ finish(200);
459
+ return;
460
+ }
461
+ // --- data plane -------------------------------------------------------
462
+ const endpoint = matchEndpoint(method, path);
463
+ if (!endpoint) {
464
+ sendJson(res, 404, {
465
+ error: {
466
+ message: `未知端点:${method} ${path}`,
467
+ type: "invalid_request_error",
468
+ },
469
+ });
470
+ finish(404);
471
+ return;
472
+ }
473
+ const auth = authenticateGatewayKey(presentedKey(req), {
474
+ format: endpoint.format,
475
+ defaultRateLimitPerMinute: config.rateLimitPerMinute,
476
+ });
477
+ if (!auth.ok) {
478
+ const failure = authFailureResponse(endpoint.format, auth.reason, auth.retryAfterSeconds, auth.rate);
479
+ sendJson(res, failure.status, failure.payload, failure.headers);
480
+ finish(failure.status);
481
+ return;
482
+ }
483
+ // Set once so both JSON and SSE responses carry the hints.
484
+ for (const [name, value] of Object.entries(rateLimitHeaders(auth.rate))) {
485
+ res.setHeader(name, value);
486
+ }
487
+ if (!gate.tryAcquire()) {
488
+ const body = formatErrorBody(endpoint.format, 503, "网关并发已达上限,请稍后重试。", "gateway_busy");
489
+ sendJson(res, body.status, body.payload, { "Retry-After": "1" });
490
+ finish(503, { keyId: auth.key.id });
491
+ return;
492
+ }
493
+ try {
494
+ if (endpoint.kind === "models") {
495
+ const models = listRoutableModels().filter((model) => keyAllowsTarget(auth.key, model.provider, model.upstreamModel, model.id));
496
+ sendJson(res, 200, {
497
+ object: "list",
498
+ data: models.map((model) => ({
499
+ id: model.id,
500
+ object: "model",
501
+ created: 0,
502
+ owned_by: model.provider,
503
+ /** Non-standard hints, useful for gateway clients. */
504
+ llm_switch: {
505
+ provider: model.provider,
506
+ upstream_model: model.upstreamModel,
507
+ format: model.format,
508
+ },
509
+ })),
510
+ });
511
+ finish(200, { keyId: auth.key.id });
512
+ return;
513
+ }
514
+ if (endpoint.kind === "model-detail") {
515
+ const found = listRoutableModels().find((model) => model.id.toLowerCase() === (endpoint.modelId || "").toLowerCase());
516
+ if (!found ||
517
+ !keyAllowsTarget(auth.key, found.provider, found.upstreamModel, found.id)) {
518
+ const body = formatErrorBody(endpoint.format, 404, `模型「${endpoint.modelId}」不存在或不可访问。`, "model_not_found");
519
+ sendJson(res, body.status, body.payload);
520
+ finish(404, { keyId: auth.key.id });
521
+ return;
522
+ }
523
+ sendJson(res, 200, {
524
+ id: found.id,
525
+ object: "model",
526
+ created: 0,
527
+ owned_by: found.provider,
528
+ llm_switch: {
529
+ provider: found.provider,
530
+ upstream_model: found.upstreamModel,
531
+ format: found.format,
532
+ },
533
+ });
534
+ finish(200, { keyId: auth.key.id });
535
+ return;
536
+ }
537
+ const bodyBuf = await readBody(req, limits.maxBodyBytes);
538
+ let parsedBody;
539
+ try {
540
+ parsedBody = JSON.parse(bodyBuf.toString("utf8"));
541
+ }
542
+ catch {
543
+ const body = formatErrorBody(endpoint.format, 400, "请求体不是合法 JSON。", "invalid_json");
544
+ sendJson(res, body.status, body.payload);
545
+ finish(400, { keyId: auth.key.id });
546
+ return;
547
+ }
548
+ const outcome = await handleDataRequest({
549
+ req,
550
+ res,
551
+ endpoint,
552
+ body: parsedBody,
553
+ key: auth.key,
554
+ config,
555
+ limits,
556
+ breaker,
557
+ });
558
+ touchGatewayKey(auth.key.id);
559
+ finish(outcome.status, {
560
+ keyId: auth.key.id,
561
+ model: outcome.model,
562
+ provider: outcome.provider,
563
+ attempts: outcome.attempts,
564
+ });
565
+ }
566
+ finally {
567
+ gate.release();
568
+ }
569
+ }
570
+ catch (err) {
571
+ const format = matchEndpoint(method, path)?.format ?? "openai-chat";
572
+ if (err instanceof RequestBodyTooLargeError) {
573
+ const body = formatErrorBody(format, 413, err.message, "request_too_large");
574
+ sendJson(res, body.status, body.payload);
575
+ finish(413);
576
+ return;
577
+ }
578
+ const message = err instanceof Error ? err.message : String(err);
579
+ const body = formatErrorBody(format, 500, message, "internal_error");
580
+ sendJson(res, body.status, body.payload);
581
+ finish(500);
582
+ }
583
+ });
584
+ }
585
+ function matchEndpoint(method, path) {
586
+ const normalized = path.replace(/^\/v1/, "") || "/";
587
+ if (method === "GET" && normalized === "/models") {
588
+ return { kind: "models", format: "openai-chat" };
589
+ }
590
+ if (method === "GET" && normalized.startsWith("/models/")) {
591
+ const id = normalized.slice("/models/".length);
592
+ if (!id.includes("/")) {
593
+ return {
594
+ kind: "model-detail",
595
+ format: "openai-chat",
596
+ ...(id ? { modelId: decodeURIComponent(id) } : {}),
597
+ };
598
+ }
599
+ }
600
+ if (method !== "POST")
601
+ return null;
602
+ switch (normalized) {
603
+ case "/chat/completions":
604
+ return { kind: "completion", format: "openai-chat" };
605
+ case "/completions":
606
+ return { kind: "completion-legacy", format: "openai-chat" };
607
+ case "/messages":
608
+ return { kind: "completion", format: "anthropic" };
609
+ case "/messages/count_tokens":
610
+ return { kind: "count_tokens", format: "anthropic" };
611
+ case "/responses":
612
+ return { kind: "completion", format: "openai-responses" };
613
+ case "/embeddings":
614
+ return { kind: "embeddings", format: "openai-chat" };
615
+ default:
616
+ return null;
617
+ }
618
+ }
619
+ async function handleDataRequest(ctx) {
620
+ const { endpoint, res } = ctx;
621
+ const body = endpoint.kind === "completion-legacy"
622
+ ? legacyPromptToChatBody(ctx.body)
623
+ : ctx.body;
624
+ const inbound = parseInboundRequest(endpoint.format, body);
625
+ if (endpoint.kind === "completion-legacy")
626
+ inbound.legacyCompletion = true;
627
+ let candidates;
628
+ try {
629
+ candidates = resolveModelRoute(inbound.requestedModel).candidates;
630
+ }
631
+ catch (err) {
632
+ const status = err instanceof ModelNotRoutableError ? 404 : 500;
633
+ const error = formatErrorBody(endpoint.format, status, err instanceof Error ? err.message : String(err), "model_not_found");
634
+ sendJson(res, error.status, error.payload);
635
+ return { status: error.status, model: inbound.requestedModel };
636
+ }
637
+ const allowed = candidates.filter((candidate) => keyAllowsTarget(ctx.key, candidate.provider.name, candidate.model, inbound.requestedModel));
638
+ if (!allowed.length) {
639
+ const error = formatErrorBody(endpoint.format, 403, `该 API Key 无权访问模型「${inbound.requestedModel}」。`, "model_not_allowed");
640
+ sendJson(res, error.status, error.payload);
641
+ return { status: error.status, model: inbound.requestedModel };
642
+ }
643
+ // Skip providers in failure cooldown; if all of them are cooling, prefer a
644
+ // delayed attempt over an immediate hard failure.
645
+ const coolingFree = allowed.filter((candidate) => ctx.breaker.allows(candidate.provider.name));
646
+ const routable = coolingFree.length ? coolingFree : allowed;
647
+ if (endpoint.kind === "embeddings") {
648
+ return forwardEmbeddings(ctx, inbound, routable);
649
+ }
650
+ if (endpoint.kind === "count_tokens") {
651
+ return forwardCountTokens(ctx, inbound, routable);
652
+ }
653
+ return forwardCompletion(ctx, inbound, routable);
654
+ }
655
+ /** Abort the upstream request as soon as the client goes away. */
656
+ function clientAbortSignal(req, res) {
657
+ const controller = new AbortController();
658
+ const onClose = () => {
659
+ if (!res.writableEnded)
660
+ controller.abort();
661
+ };
662
+ req.once("close", onClose);
663
+ return {
664
+ signal: controller.signal,
665
+ dispose: () => req.off("close", onClose),
666
+ };
667
+ }
668
+ function transportOptionsFor(provider, limits, signal) {
669
+ return {
670
+ proxy: provider.proxy,
671
+ signal,
672
+ connectTimeoutMs: limits.connectTimeoutMs,
673
+ idleTimeoutMs: limits.idleTimeoutMs,
674
+ totalTimeoutMs: limits.totalTimeoutMs,
675
+ maxResponseBytes: limits.maxResponseBytes,
676
+ };
677
+ }
678
+ async function forwardCompletion(ctx, inbound, candidates) {
679
+ const { res, config, limits } = ctx;
680
+ const hubRequest = inboundToChatRequest(inbound);
681
+ const failures = [];
682
+ const abort = clientAbortSignal(ctx.req, res);
683
+ try {
684
+ for (let index = 0; index < candidates.length; index += 1) {
685
+ const candidate = candidates[index];
686
+ const hasMore = index < candidates.length - 1;
687
+ const provider = candidate.provider;
688
+ const targetFormat = providerFormat(provider);
689
+ // Legacy text completions always reshape the payload (chat → completion),
690
+ // so the raw passthrough path never applies to them.
691
+ const passthrough = targetFormat === inbound.format && !inbound.legacyCompletion;
692
+ const upstreamBody = passthrough
693
+ ? { ...inbound.body, model: candidate.model }
694
+ : chatRequestToUpstream(targetFormat, {
695
+ ...hubRequest,
696
+ model: candidate.model,
697
+ });
698
+ let response;
699
+ try {
700
+ response = await requestWithNodeTransport({
701
+ url: upstreamUrl(provider, upstreamPath(targetFormat)),
702
+ method: "POST",
703
+ // Relay beta headers only when the wire format is preserved; a
704
+ // translated request has no guarantee the beta flag still applies.
705
+ headers: buildUpstreamHeaders(provider, passthrough ? ctx.req : undefined),
706
+ body: JSON.stringify(upstreamBody),
707
+ ...transportOptionsFor(provider, limits, abort.signal),
708
+ });
709
+ }
710
+ catch (err) {
711
+ if (abort.signal.aborted)
712
+ return { status: 499, attempts: index + 1 };
713
+ const message = err instanceof Error ? err.message : String(err);
714
+ ctx.breaker.failure(provider.name, message);
715
+ failures.push({ candidate, status: 502, message });
716
+ if (hasMore && config.fallback.enabled)
717
+ continue;
718
+ return respondWithFailures(res, inbound, failures, index + 1);
719
+ }
720
+ if (!response.ok) {
721
+ const text = await response.text().catch(() => "");
722
+ const retryable = config.fallback.enabled && isRetryableStatus(config, response.status);
723
+ if (hasMore && retryable) {
724
+ ctx.breaker.failure(provider.name, `HTTP ${response.status}`);
725
+ failures.push({
726
+ candidate,
727
+ status: response.status,
728
+ message: text.slice(0, 300),
729
+ });
730
+ continue;
731
+ }
732
+ if (response.status >= 500) {
733
+ ctx.breaker.failure(provider.name, `HTTP ${response.status}`);
734
+ }
735
+ const error = translateUpstreamError(inbound.format, response.status, text);
736
+ sendJson(res, error.status, error.payload);
737
+ return {
738
+ status: error.status,
739
+ model: inbound.requestedModel,
740
+ provider: provider.name,
741
+ attempts: index + 1,
742
+ };
743
+ }
744
+ // Committed to this candidate: no fallback once bytes are written.
745
+ ctx.breaker.success(provider.name);
746
+ if (!inbound.stream) {
747
+ let payload;
748
+ try {
749
+ payload = (await response.json());
750
+ }
751
+ catch (err) {
752
+ const message = err instanceof Error ? err.message : String(err);
753
+ const error = formatErrorBody(inbound.format, 502, `无法解析上游响应:${message}`, "upstream_error");
754
+ sendJson(res, error.status, error.payload);
755
+ return {
756
+ status: error.status,
757
+ model: inbound.requestedModel,
758
+ provider: provider.name,
759
+ attempts: index + 1,
760
+ };
761
+ }
762
+ const finalBody = passthrough
763
+ ? withRequestedModel(payload, inbound.requestedModel)
764
+ : withRequestedModel(chatCompletionToInbound(inbound, upstreamToChatCompletion(targetFormat, payload, inbound.requestedModel)), inbound.requestedModel);
765
+ sendJson(res, 200, inbound.legacyCompletion
766
+ ? chatCompletionToLegacyCompletion(finalBody, inbound.requestedModel)
767
+ : finalBody);
768
+ recordUsage({
769
+ keyId: ctx.key.id,
770
+ provider: provider.name,
771
+ model: inbound.requestedModel,
772
+ ...extractTokenUsage(payload),
773
+ });
774
+ return {
775
+ status: 200,
776
+ model: inbound.requestedModel,
777
+ provider: provider.name,
778
+ attempts: index + 1,
779
+ };
780
+ }
781
+ const streamUsage = await pipeStream(response, res, inbound, targetFormat, passthrough);
782
+ recordUsage({
783
+ keyId: ctx.key.id,
784
+ provider: provider.name,
785
+ model: inbound.requestedModel,
786
+ ...streamUsage,
787
+ });
788
+ return {
789
+ status: 200,
790
+ model: inbound.requestedModel,
791
+ provider: provider.name,
792
+ attempts: index + 1,
793
+ };
794
+ }
795
+ return respondWithFailures(res, inbound, failures, candidates.length);
796
+ }
797
+ finally {
798
+ abort.dispose();
799
+ }
800
+ }
801
+ function respondWithFailures(res, inbound, failures, attempts) {
802
+ const last = failures[failures.length - 1];
803
+ const detail = failures
804
+ .map((failure) => `${failure.candidate.provider.name}(${failure.status}): ${failure.message || "无响应"}`)
805
+ .join(" | ");
806
+ const status = last && last.status >= 400 && last.status < 600 ? last.status : 502;
807
+ const error = formatErrorBody(inbound.format, status === 429 ? 429 : 502, `所有上游尝试均失败。${detail}`, "all_upstreams_failed");
808
+ sendJson(res, error.status, error.payload);
809
+ return { status: error.status, model: inbound.requestedModel, attempts };
810
+ }
811
+ /**
812
+ * Relay a streaming upstream response. Passthrough copies bytes verbatim;
813
+ * otherwise upstream frames are decoded to hub chunks and re-encoded into the
814
+ * inbound protocol. Usage lines spotted in decoded chunks are returned so the
815
+ * caller can account tokens.
816
+ */
817
+ async function pipeStream(upstream, res, inbound, targetFormat, passthrough) {
818
+ res.writeHead(200, { ...SSE_HEADERS });
819
+ const usage = {};
820
+ const noteUsage = (payload) => {
821
+ const found = extractTokenUsage(payload);
822
+ if (found.inputTokens !== undefined)
823
+ usage.inputTokens = found.inputTokens;
824
+ if (found.outputTokens !== undefined) {
825
+ usage.outputTokens = found.outputTokens;
826
+ }
827
+ };
828
+ let lastWrite = Date.now();
829
+ const heartbeat = setInterval(() => {
830
+ if (res.writableEnded)
831
+ return;
832
+ if (Date.now() - lastWrite < SSE_HEARTBEAT_MS)
833
+ return;
834
+ // SSE comment frame: ignored by every conforming client, keeps proxies warm.
835
+ res.write(": keep-alive\n\n");
836
+ lastWrite = Date.now();
837
+ }, SSE_HEARTBEAT_MS);
838
+ const write = (frame) => {
839
+ res.write(frame);
840
+ lastWrite = Date.now();
841
+ };
842
+ const reader = upstream.body?.getReader();
843
+ if (!reader) {
844
+ clearInterval(heartbeat);
845
+ res.end();
846
+ return usage;
847
+ }
848
+ const decoder = createUpstreamDecoder(targetFormat, inbound.requestedModel);
849
+ const encoder = createInboundEncoder(inbound);
850
+ const textDecoder = new TextDecoder();
851
+ let buffer = "";
852
+ const emit = (chunks) => {
853
+ for (const chunk of chunks) {
854
+ noteUsage(chunk);
855
+ const normalized = withRequestedModel(chunk, inbound.requestedModel);
856
+ for (const frame of encoder.encode(normalized))
857
+ write(frame);
858
+ }
859
+ };
860
+ try {
861
+ for (;;) {
862
+ const { done, value } = await reader.read();
863
+ if (done)
864
+ break;
865
+ if (passthrough) {
866
+ write(value);
867
+ continue;
868
+ }
869
+ buffer += textDecoder.decode(value, { stream: true });
870
+ const lines = buffer.split(/\r?\n/);
871
+ buffer = lines.pop() || "";
872
+ for (const line of lines)
873
+ emit(decoder.push(line));
874
+ }
875
+ if (!passthrough) {
876
+ if (buffer.trim())
877
+ emit(decoder.push(buffer));
878
+ emit(decoder.finish());
879
+ for (const frame of encoder.finish())
880
+ write(frame);
881
+ }
882
+ }
883
+ catch (err) {
884
+ if (!res.writableEnded) {
885
+ const message = err instanceof Error ? err.message : String(err);
886
+ const payload = inbound.format === "anthropic"
887
+ ? { type: "error", error: { type: "api_error", message } }
888
+ : { error: { message, type: "api_error" } };
889
+ write(`event: error\ndata: ${JSON.stringify(payload)}\n\n`);
890
+ }
891
+ }
892
+ finally {
893
+ clearInterval(heartbeat);
894
+ res.end();
895
+ }
896
+ return usage;
897
+ }
898
+ /**
899
+ * Embeddings only exist on OpenAI-compatible upstreams; Anthropic providers are
900
+ * skipped rather than silently mistranslated.
901
+ */
902
+ async function forwardEmbeddings(ctx, inbound, candidates) {
903
+ const { res, config, limits } = ctx;
904
+ const usable = candidates.filter((candidate) => providerFormat(candidate.provider) !== "anthropic");
905
+ if (!usable.length) {
906
+ const error = formatErrorBody(inbound.format, 404, `模型「${inbound.requestedModel}」没有支持 embeddings 的上游(Anthropic 格式不提供该接口)。`, "embeddings_unsupported");
907
+ sendJson(res, error.status, error.payload);
908
+ return { status: error.status, model: inbound.requestedModel };
909
+ }
910
+ const abort = clientAbortSignal(ctx.req, res);
911
+ const failures = [];
912
+ try {
913
+ for (let index = 0; index < usable.length; index += 1) {
914
+ const candidate = usable[index];
915
+ const hasMore = index < usable.length - 1;
916
+ let response;
917
+ try {
918
+ response = await requestWithNodeTransport({
919
+ url: upstreamUrl(candidate.provider, "/embeddings"),
920
+ method: "POST",
921
+ headers: buildUpstreamHeaders(candidate.provider, ctx.req),
922
+ body: JSON.stringify({ ...inbound.body, model: candidate.model }),
923
+ ...transportOptionsFor(candidate.provider, limits, abort.signal),
924
+ });
925
+ }
926
+ catch (err) {
927
+ if (abort.signal.aborted)
928
+ return { status: 499, attempts: index + 1 };
929
+ const message = err instanceof Error ? err.message : String(err);
930
+ ctx.breaker.failure(candidate.provider.name, message);
931
+ failures.push({
932
+ candidate,
933
+ status: 502,
934
+ message,
935
+ });
936
+ if (hasMore && config.fallback.enabled)
937
+ continue;
938
+ return respondWithFailures(res, inbound, failures, index + 1);
939
+ }
940
+ const text = await response.text().catch(() => "");
941
+ if (!response.ok) {
942
+ if (hasMore &&
943
+ config.fallback.enabled &&
944
+ isRetryableStatus(config, response.status)) {
945
+ ctx.breaker.failure(candidate.provider.name, `HTTP ${response.status}`);
946
+ failures.push({
947
+ candidate,
948
+ status: response.status,
949
+ message: text.slice(0, 300),
950
+ });
951
+ continue;
952
+ }
953
+ const error = translateUpstreamError(inbound.format, response.status, text);
954
+ sendJson(res, error.status, error.payload);
955
+ return {
956
+ status: error.status,
957
+ model: inbound.requestedModel,
958
+ provider: candidate.provider.name,
959
+ attempts: index + 1,
960
+ };
961
+ }
962
+ ctx.breaker.success(candidate.provider.name);
963
+ let payload;
964
+ try {
965
+ payload = JSON.parse(text);
966
+ }
967
+ catch {
968
+ payload = {};
969
+ }
970
+ sendJson(res, 200, withRequestedModel(payload, inbound.requestedModel));
971
+ recordUsage({
972
+ keyId: ctx.key.id,
973
+ provider: candidate.provider.name,
974
+ model: inbound.requestedModel,
975
+ ...extractTokenUsage(payload),
976
+ });
977
+ return {
978
+ status: 200,
979
+ model: inbound.requestedModel,
980
+ provider: candidate.provider.name,
981
+ attempts: index + 1,
982
+ };
983
+ }
984
+ return respondWithFailures(res, inbound, failures, usable.length);
985
+ }
986
+ finally {
987
+ abort.dispose();
988
+ }
989
+ }
990
+ /**
991
+ * Anthropic `count_tokens`. Native upstreams answer authoritatively; for other
992
+ * formats the gateway returns a clearly-labelled local estimate rather than
993
+ * failing, because clients use this call to size requests.
994
+ */
995
+ async function forwardCountTokens(ctx, inbound, candidates) {
996
+ const { res, limits } = ctx;
997
+ const native = candidates.find((candidate) => providerFormat(candidate.provider) === "anthropic");
998
+ if (native) {
999
+ const abort = clientAbortSignal(ctx.req, res);
1000
+ try {
1001
+ const response = await requestWithNodeTransport({
1002
+ url: upstreamUrl(native.provider, "/messages/count_tokens"),
1003
+ method: "POST",
1004
+ headers: buildUpstreamHeaders(native.provider, ctx.req),
1005
+ body: JSON.stringify({ ...inbound.body, model: native.model }),
1006
+ ...transportOptionsFor(native.provider, limits, abort.signal),
1007
+ });
1008
+ const text = await response.text().catch(() => "");
1009
+ if (response.ok) {
1010
+ ctx.breaker.success(native.provider.name);
1011
+ try {
1012
+ sendJson(res, 200, JSON.parse(text));
1013
+ }
1014
+ catch {
1015
+ sendJson(res, 200, { input_tokens: 0 });
1016
+ }
1017
+ return {
1018
+ status: 200,
1019
+ model: inbound.requestedModel,
1020
+ provider: native.provider.name,
1021
+ attempts: 1,
1022
+ };
1023
+ }
1024
+ // Fall through to the local estimate on upstream failure.
1025
+ }
1026
+ catch {
1027
+ if (abort.signal.aborted)
1028
+ return { status: 499 };
1029
+ }
1030
+ finally {
1031
+ abort.dispose();
1032
+ }
1033
+ }
1034
+ const estimate = await countAnthropicInputTokens(inbound.body);
1035
+ sendJson(res, 200, {
1036
+ input_tokens: estimate.inputTokens,
1037
+ llm_switch: {
1038
+ estimated: true,
1039
+ reason: native ? "upstream_count_failed" : "upstream_not_anthropic",
1040
+ method: estimate.method,
1041
+ ...(estimate.tokenizer ? { tokenizer: estimate.tokenizer } : {}),
1042
+ breakdown: estimate.breakdown,
1043
+ },
1044
+ });
1045
+ return { status: 200, model: inbound.requestedModel, attempts: 1 };
1046
+ }
1047
+ export function listenGateway(port, host, options = {}) {
1048
+ const server = createGatewayServer(options);
1049
+ return new Promise((resolve, reject) => {
1050
+ server.once("error", reject);
1051
+ server.listen(port, host, () => resolve(server));
1052
+ });
1053
+ }