@velum-labs/routekit-gateway 0.9.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.
Files changed (99) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +28 -0
  3. package/dist/acp-agent.d.ts +38 -0
  4. package/dist/acp-agent.js +142 -0
  5. package/dist/acp-registry.d.ts +36 -0
  6. package/dist/acp-registry.js +85 -0
  7. package/dist/adapters/anthropic.d.ts +131 -0
  8. package/dist/adapters/anthropic.js +1195 -0
  9. package/dist/adapters/chat.d.ts +14 -0
  10. package/dist/adapters/chat.js +34 -0
  11. package/dist/adapters/cursor.d.ts +34 -0
  12. package/dist/adapters/cursor.js +305 -0
  13. package/dist/adapters/dropped.d.ts +10 -0
  14. package/dist/adapters/dropped.js +24 -0
  15. package/dist/adapters/openai-chat-wire.d.ts +93 -0
  16. package/dist/adapters/openai-chat-wire.js +143 -0
  17. package/dist/adapters/responses-stream.d.ts +7 -0
  18. package/dist/adapters/responses-stream.js +597 -0
  19. package/dist/adapters/responses.d.ts +174 -0
  20. package/dist/adapters/responses.js +778 -0
  21. package/dist/adapters/server-tool-loop.d.ts +94 -0
  22. package/dist/adapters/server-tool-loop.js +477 -0
  23. package/dist/adapters/upstream-error.d.ts +14 -0
  24. package/dist/adapters/upstream-error.js +25 -0
  25. package/dist/adapters/validate.d.ts +27 -0
  26. package/dist/adapters/validate.js +180 -0
  27. package/dist/adapters/web-search.d.ts +46 -0
  28. package/dist/adapters/web-search.js +151 -0
  29. package/dist/auth.d.ts +10 -0
  30. package/dist/auth.js +28 -0
  31. package/dist/backend.d.ts +151 -0
  32. package/dist/backend.js +143 -0
  33. package/dist/capacity-pool.d.ts +31 -0
  34. package/dist/capacity-pool.js +99 -0
  35. package/dist/cost.d.ts +49 -0
  36. package/dist/cost.js +112 -0
  37. package/dist/endpoint-health.d.ts +54 -0
  38. package/dist/endpoint-health.js +123 -0
  39. package/dist/index.d.ts +40 -0
  40. package/dist/index.js +23 -0
  41. package/dist/provenance.d.ts +31 -0
  42. package/dist/provenance.js +191 -0
  43. package/dist/provider-backends.d.ts +40 -0
  44. package/dist/provider-backends.js +1050 -0
  45. package/dist/provider-source.d.ts +40 -0
  46. package/dist/provider-source.js +293 -0
  47. package/dist/router.d.ts +168 -0
  48. package/dist/router.js +474 -0
  49. package/dist/server.d.ts +67 -0
  50. package/dist/server.js +930 -0
  51. package/dist/sse/chat-assembler.d.ts +45 -0
  52. package/dist/sse/chat-assembler.js +190 -0
  53. package/dist/sse/parse.d.ts +50 -0
  54. package/dist/sse/parse.js +149 -0
  55. package/dist/sse-wire.d.ts +10 -0
  56. package/dist/sse-wire.js +31 -0
  57. package/dist/switching-proxy.d.ts +15 -0
  58. package/dist/switching-proxy.js +232 -0
  59. package/dist/test/acp-agent.test.d.ts +1 -0
  60. package/dist/test/acp-agent.test.js +66 -0
  61. package/dist/test/acp-registry.test.d.ts +1 -0
  62. package/dist/test/acp-registry.test.js +70 -0
  63. package/dist/test/anthropic.test.d.ts +1 -0
  64. package/dist/test/anthropic.test.js +793 -0
  65. package/dist/test/auth.test.d.ts +1 -0
  66. package/dist/test/auth.test.js +25 -0
  67. package/dist/test/boundary.test.d.ts +1 -0
  68. package/dist/test/boundary.test.js +32 -0
  69. package/dist/test/chat.test.d.ts +1 -0
  70. package/dist/test/chat.test.js +418 -0
  71. package/dist/test/cost.test.d.ts +1 -0
  72. package/dist/test/cost.test.js +60 -0
  73. package/dist/test/cursor.test.d.ts +1 -0
  74. package/dist/test/cursor.test.js +100 -0
  75. package/dist/test/drain.test.d.ts +1 -0
  76. package/dist/test/drain.test.js +116 -0
  77. package/dist/test/dropped.test.d.ts +1 -0
  78. package/dist/test/dropped.test.js +80 -0
  79. package/dist/test/endpoint-health.test.d.ts +1 -0
  80. package/dist/test/endpoint-health.test.js +73 -0
  81. package/dist/test/provenance.test.d.ts +1 -0
  82. package/dist/test/provenance.test.js +176 -0
  83. package/dist/test/provider-backends.test.d.ts +1 -0
  84. package/dist/test/provider-backends.test.js +699 -0
  85. package/dist/test/responses.test.d.ts +1 -0
  86. package/dist/test/responses.test.js +813 -0
  87. package/dist/test/routed-backend.test.d.ts +1 -0
  88. package/dist/test/routed-backend.test.js +39 -0
  89. package/dist/test/router.test.d.ts +1 -0
  90. package/dist/test/router.test.js +297 -0
  91. package/dist/test/server-resilience.test.d.ts +1 -0
  92. package/dist/test/server-resilience.test.js +169 -0
  93. package/dist/test/sse-codec.test.d.ts +1 -0
  94. package/dist/test/sse-codec.test.js +186 -0
  95. package/dist/test/web-search-loop.test.d.ts +1 -0
  96. package/dist/test/web-search-loop.test.js +469 -0
  97. package/dist/test/wire-validation.test.d.ts +1 -0
  98. package/dist/test/wire-validation.test.js +140 -0
  99. package/package.json +48 -0
package/dist/server.js ADDED
@@ -0,0 +1,930 @@
1
+ import { once } from "node:events";
2
+ import { createServer } from "node:http";
3
+ import { ProviderFailureError } from "@velum-labs/routekit-contracts";
4
+ import { anthropicModelsResponse, handleAnthropicMessages, handleCountTokens, resolveClaudeModelAlias } from "./adapters/anthropic.js";
5
+ import { effectiveModel, isStream, withDefaultModel } from "./adapters/chat.js";
6
+ import { authorizedRequest } from "./auth.js";
7
+ import { isCursorChatBody, translateCursorRequest } from "./adapters/cursor.js";
8
+ import { handleResponses } from "./adapters/responses.js";
9
+ import { validateAnthropicRequest, validateChatRequest, validateCountTokensRequest, validateResponsesRequest } from "./adapters/validate.js";
10
+ import { buildModelCallRecord, MODEL_CALL_ID_HEADER, modelCallId } from "./provenance.js";
11
+ import { NoModelAvailableError, UnknownModelError } from "./router.js";
12
+ function codexModelInfo(id, priority, reasoning) {
13
+ const levels = (reasoning?.efforts ?? []).map((effort) => ({
14
+ effort: effort.id,
15
+ description: effort.description ?? effort.label ?? effort.id
16
+ }));
17
+ return {
18
+ slug: id,
19
+ prefer_websockets: false,
20
+ display_name: id,
21
+ description: "RouteKit live model",
22
+ ...(reasoning?.defaultEffort !== undefined
23
+ ? { default_reasoning_level: reasoning.defaultEffort }
24
+ : {}),
25
+ // Codex parses ModelInfo strictly; this field must exist on every entry,
26
+ // and an empty list means "no discovered effort controls".
27
+ supported_reasoning_levels: levels,
28
+ shell_type: "shell_command",
29
+ visibility: "list",
30
+ supported_in_api: true,
31
+ priority,
32
+ availability_nux: null,
33
+ upgrade: null,
34
+ base_instructions: "You are a coding agent.",
35
+ model_messages: {
36
+ instructions_template: "You are a coding agent.",
37
+ instructions_variables: null
38
+ },
39
+ supports_reasoning_summaries: reasoning?.status === "supported",
40
+ default_reasoning_summary: "none",
41
+ support_verbosity: true,
42
+ default_verbosity: "low",
43
+ apply_patch_tool_type: "freeform",
44
+ web_search_tool_type: "text_and_image",
45
+ truncation_policy: { mode: "tokens", limit: 10_000 },
46
+ supports_parallel_tool_calls: true,
47
+ supports_image_detail_original: true,
48
+ context_window: 272_000,
49
+ max_context_window: 272_000,
50
+ effective_context_window_percent: 95,
51
+ experimental_supported_tools: [],
52
+ input_modalities: ["text", "image"],
53
+ supports_search_tool: true,
54
+ use_responses_lite: false
55
+ };
56
+ }
57
+ function catalogModelRoutes(backend) {
58
+ if (backend.resolveModelRoute === undefined)
59
+ return [];
60
+ return (backend.listModelIds?.() ?? []).flatMap((model) => {
61
+ const route = backend.resolveModelRoute?.(model);
62
+ return route === undefined ? [] : [route];
63
+ });
64
+ }
65
+ function resolveNativeModelRoute(backend, provider, requested) {
66
+ if (backend.resolveModelRoute === undefined)
67
+ return undefined;
68
+ const route = backend.resolveModelRoute(requested, provider);
69
+ if (route === undefined && requested !== undefined) {
70
+ throw new UnknownModelError(requested);
71
+ }
72
+ return route;
73
+ }
74
+ export function initialAttribution(backend, requested, nativeProvider) {
75
+ const route = backend.resolveModelRoute?.(requested, nativeProvider);
76
+ const effectiveModel = route?.publicId ?? requested ?? backend.defaultModel;
77
+ if (effectiveModel === undefined)
78
+ return {};
79
+ const slash = effectiveModel.indexOf("/");
80
+ const provider = route?.provider ??
81
+ (slash > 0
82
+ ? effectiveModel.slice(0, slash)
83
+ : nativeProvider ?? "unknown");
84
+ const nativeModel = route?.nativeId ??
85
+ (slash > 0 ? effectiveModel.slice(slash + 1) : effectiveModel);
86
+ return {
87
+ effective_model: effectiveModel,
88
+ native_model: nativeModel,
89
+ provider,
90
+ billing_mode: provider === "codex" ||
91
+ provider === "claude-code" ||
92
+ provider === "cliproxy"
93
+ ? "subscription"
94
+ : "api_key"
95
+ };
96
+ }
97
+ function withModel(body, model) {
98
+ return { ...body, model };
99
+ }
100
+ /**
101
+ * Codex keeps the session's initial model instructions when `/model` switches
102
+ * to a different provider. A session started on a stock Codex model therefore
103
+ * sends its "You are Codex ... based on GPT-5" identity even after selecting
104
+ * Claude. The selected model's current instructions are already present in the
105
+ * Responses input, so remove only this contradictory stale identity at the
106
+ * cross-provider boundary. Native Codex routes remain byte-for-byte intact.
107
+ */
108
+ function withoutStaleCodexIdentity(body, route) {
109
+ if (route?.provider === "codex" ||
110
+ typeof body.instructions !== "string" ||
111
+ !/^\s*You are Codex\b/i.test(body.instructions) ||
112
+ !/\bbased on GPT-5\b/i.test(body.instructions)) {
113
+ return body;
114
+ }
115
+ const { instructions: _staleIdentity, ...rest } = body;
116
+ return rest;
117
+ }
118
+ function codexPickerModels(backend, configured, native, includeUnroutedNative) {
119
+ const nativeBySlug = new Map(native.flatMap((entry) => typeof entry.slug === "string" ? [[entry.slug, entry]] : []));
120
+ const seen = new Set();
121
+ const models = configured.map((entry, priority) => {
122
+ const route = backend.resolveModelRoute?.(entry.id);
123
+ const slug = route?.provider === "codex" ? route.nativeId : entry.id;
124
+ seen.add(slug);
125
+ const upstream = nativeBySlug.get(slug);
126
+ return upstream === undefined
127
+ ? codexModelInfo(slug, priority, route?.reasoning)
128
+ : { ...upstream, slug, priority };
129
+ });
130
+ if (!includeUnroutedNative)
131
+ return models;
132
+ for (const entry of native) {
133
+ const slug = typeof entry.slug === "string" ? entry.slug : undefined;
134
+ if (slug === undefined || seen.has(slug))
135
+ continue;
136
+ seen.add(slug);
137
+ models.push(entry);
138
+ }
139
+ return models;
140
+ }
141
+ async function mergeAnthropicCatalogs(configured, native) {
142
+ if (!native.ok)
143
+ return configured;
144
+ const configuredBody = (await configured.json());
145
+ const nativeBody = (await native.json());
146
+ const data = [...(configuredBody.data ?? [])];
147
+ const seen = new Set(data.flatMap((entry) => (typeof entry.id === "string" ? [entry.id] : [])));
148
+ for (const entry of nativeBody.data ?? []) {
149
+ if (typeof entry.id !== "string" || seen.has(entry.id))
150
+ continue;
151
+ seen.add(entry.id);
152
+ data.push(entry);
153
+ }
154
+ return Response.json({
155
+ ...nativeBody,
156
+ data,
157
+ has_more: false,
158
+ first_id: typeof data[0]?.id === "string" ? data[0].id : undefined,
159
+ last_id: typeof data.at(-1)?.id === "string" ? data.at(-1)?.id : undefined
160
+ }, { headers: native.headers });
161
+ }
162
+ export async function startGateway(options) {
163
+ const host = options.host ?? "127.0.0.1";
164
+ const { backend, authToken, provenance } = options;
165
+ // Client-forwarded Codex auth and server-owned subscription accounts are
166
+ // distinct trust models. Gateway auth disables only the client-forwarded
167
+ // relay; a server-owned account set remains available behind the proxy key.
168
+ const codexClientRelay = authToken === undefined ? options.codexRelay : undefined;
169
+ const anthropicRelay = options.providerRelays?.anthropic;
170
+ const codexProviderRelay = options.providerRelays?.codex;
171
+ const codexCatalogRelay = codexProviderRelay?.mergedCatalog !== undefined
172
+ ? codexProviderRelay
173
+ : codexClientRelay;
174
+ const codexRequestRelay = codexProviderRelay ?? codexClientRelay;
175
+ // In-flight request count drives the drain loop: a drain completes as soon
176
+ // as every accepted request has finished (or its grace expires).
177
+ let inflight = 0;
178
+ let draining = false;
179
+ const server = createServer((req, res) => {
180
+ inflight += 1;
181
+ res.once("close", () => {
182
+ inflight -= 1;
183
+ });
184
+ void handle(req, res).catch((error) => {
185
+ // This catch must never throw: a throw here becomes an unhandled
186
+ // rejection that kills the process hosting the gateway.
187
+ writeGatewayError(res, error);
188
+ });
189
+ });
190
+ async function handle(req, res) {
191
+ const method = req.method ?? "GET";
192
+ const url = new URL(req.url ?? "/", "http://localhost");
193
+ const path = url.pathname;
194
+ if (path === "/health") {
195
+ // A draining gateway reports unhealthy so pollers (readiness probes,
196
+ // upgrade orchestration) route new work elsewhere.
197
+ if (draining)
198
+ writeJson(res, 503, { status: "draining" });
199
+ else
200
+ writeJson(res, 200, { status: "ok" });
201
+ return;
202
+ }
203
+ if (draining) {
204
+ writeJson(res, 503, {
205
+ error: { message: "gateway is draining", type: "unavailable" }
206
+ });
207
+ return;
208
+ }
209
+ if (authToken !== undefined && !authorizedRequest(req, authToken)) {
210
+ writeJson(res, 401, { error: { message: "unauthorized", type: "auth_error" } });
211
+ return;
212
+ }
213
+ if (method === "GET" && path === "/usage") {
214
+ const usage = options.usage === undefined ? undefined : await options.usage();
215
+ writeJson(res, usage === undefined ? 404 : 200, usage ?? {
216
+ error: { message: "provider usage is not configured", type: "not_found" }
217
+ });
218
+ return;
219
+ }
220
+ if (method === "GET" &&
221
+ (path === "/v1/models" || path === "/models" || path === "/backend-api/codex/models")) {
222
+ // Claude Code's discovery probe carries `anthropic-version` and expects
223
+ // the Anthropic-shaped model list; everyone else gets the OpenAI shape.
224
+ if (req.headers["anthropic-version"] !== undefined) {
225
+ const configured = anthropicModelsResponse(backend.defaultModel, backend.listModelIds?.(), catalogModelRoutes(backend));
226
+ if (anthropicRelay?.models !== undefined &&
227
+ backend.resolveModelRoute === undefined) {
228
+ await pipeUpstream(res, await mergeAnthropicCatalogs(configured, await anthropicRelay.models(req.headers, url.search)));
229
+ return;
230
+ }
231
+ await pipeUpstream(res, configured);
232
+ return;
233
+ }
234
+ if (codexCatalogRelay !== undefined) {
235
+ // Codex parses the `models` key (its ModelInfo catalog — this is what
236
+ // drives its /model picker); OpenAI-shape clients read `data`. Serving
237
+ // both keys on one response keeps every client working.
238
+ const merged = await codexCatalogRelay.mergedCatalog?.(req.headers, url.search);
239
+ if (merged !== undefined) {
240
+ const base = (await (await backend.models()).json());
241
+ if (merged.etag !== undefined &&
242
+ codexProviderRelay === undefined) {
243
+ res.setHeader("etag", merged.etag);
244
+ }
245
+ const data = codexProviderRelay === undefined
246
+ ? codexCatalogRelay.mergeDataIds?.(base.data ?? [], merged.models) ?? base.data ?? []
247
+ : base.data ?? [];
248
+ writeJson(res, 200, {
249
+ object: "list",
250
+ data,
251
+ models: codexPickerModels(backend, base.data ?? [], merged.models, codexProviderRelay === undefined)
252
+ });
253
+ return;
254
+ }
255
+ }
256
+ const modelResponse = await backend.models();
257
+ if (!modelResponse.ok) {
258
+ await pipeUpstream(res, modelResponse);
259
+ return;
260
+ }
261
+ const modelPayload = (await modelResponse.json());
262
+ const data = modelPayload.data ?? [];
263
+ writeJson(res, 200, {
264
+ ...modelPayload,
265
+ object: typeof modelPayload.object === "string" ? modelPayload.object : "list",
266
+ data,
267
+ models: codexPickerModels(backend, data.flatMap((entry) => typeof entry.id === "string"
268
+ ? [{ ...entry, id: entry.id }]
269
+ : []), [], false)
270
+ });
271
+ return;
272
+ }
273
+ // Cursor may probe the models list relative to its BYOK base URL
274
+ // (`.../v1/cursor`); mirror /v1/models there.
275
+ if (method === "GET" && path === "/v1/cursor/models") {
276
+ await pipeUpstream(res, await backend.models());
277
+ return;
278
+ }
279
+ // Anthropic single-model retrieve (`GET /v1/models/{id}`): Claude Code probes
280
+ // this to validate a selected model before its first turn. Echo the id back
281
+ // so any advertised/aliased id validates; routing is decided at chat time.
282
+ if (method === "GET" && path.startsWith("/v1/models/")) {
283
+ const id = decodeURIComponent(path.slice("/v1/models/".length));
284
+ const alias = resolveClaudeModelAlias(id, backend.listModelIds?.());
285
+ const route = backend.resolveModelRoute?.(alias, "claude-code");
286
+ const resolved = route?.publicId ?? alias;
287
+ if (resolved === undefined ||
288
+ (backend.resolveModelRoute !== undefined && route === undefined) ||
289
+ (backend.resolveModelRoute === undefined &&
290
+ !(backend.servesModel?.(resolved) ?? false) &&
291
+ anthropicRelay === undefined)) {
292
+ writeJson(res, 404, {
293
+ error: { message: `unknown model: ${id}`, type: "not_found" }
294
+ });
295
+ return;
296
+ }
297
+ writeJson(res, 200, {
298
+ type: "model",
299
+ id,
300
+ display_name: route?.nativeId ?? resolved,
301
+ created_at: new Date(0).toISOString()
302
+ });
303
+ return;
304
+ }
305
+ const requestContext = { headers: req.headers };
306
+ if (method === "POST" && (path === "/v1/chat/completions" || path === "/chat/completions")) {
307
+ const raw = await readJson(req, res);
308
+ if (raw === NO_BODY)
309
+ return;
310
+ if (rejectInvalid(res, validateChatRequest(raw)))
311
+ return;
312
+ const body = withDefaultModel(raw, backend.defaultModel);
313
+ await handleModelCall(res, provenance, {
314
+ dialect: "openai-chat",
315
+ body,
316
+ defaultModel: backend.defaultModel,
317
+ attribution: initialAttribution(backend, effectiveModel(body, backend.defaultModel)),
318
+ invoke: (callId, signal, onAttribution) => backend.chat(body, signal, {
319
+ modelCallId: callId,
320
+ requestContext,
321
+ onAttribution
322
+ })
323
+ });
324
+ return;
325
+ }
326
+ // Cursor's BYOK base-URL override POSTs a Responses-API-shaped body to
327
+ // `{base_url}/chat/completions` while expecting Chat Completions back (a
328
+ // known Cursor hybrid). Translate it, then delegate to the exact code path
329
+ // the plain /v1/chat/completions route uses. Plain Chat Completions bodies
330
+ // (Cursor Ask mode) pass through untranslated.
331
+ if (method === "POST" && path === "/v1/cursor/chat/completions") {
332
+ const raw = await readJson(req, res);
333
+ if (raw === NO_BODY)
334
+ return;
335
+ if (!isCursorChatBody(raw)) {
336
+ writeJson(res, 400, {
337
+ error: {
338
+ message: 'request body must be a JSON object with "messages" or "input"',
339
+ type: "invalid_request_error"
340
+ }
341
+ });
342
+ return;
343
+ }
344
+ if ("input" in raw && rejectInvalid(res, validateResponsesRequest(raw)))
345
+ return;
346
+ // Validate the translated body before invoking the backend.
347
+ const translated = translateCursorRequest(raw);
348
+ if (rejectInvalid(res, validateChatRequest(translated)))
349
+ return;
350
+ const body = withDefaultModel(translated, backend.defaultModel);
351
+ await handleModelCall(res, provenance, {
352
+ dialect: "openai-chat",
353
+ body,
354
+ defaultModel: backend.defaultModel,
355
+ attribution: initialAttribution(backend, effectiveModel(body, backend.defaultModel)),
356
+ invoke: (callId, signal, onAttribution) => backend.chat(body, signal, {
357
+ modelCallId: callId,
358
+ requestContext,
359
+ onAttribution
360
+ })
361
+ });
362
+ return;
363
+ }
364
+ if (method === "POST" && path === "/v1/embeddings") {
365
+ const raw = await readJson(req, res);
366
+ if (raw === NO_BODY)
367
+ return;
368
+ const body = withDefaultModel(raw, backend.defaultModel);
369
+ await handleModelCall(res, provenance, {
370
+ dialect: "openai-embeddings",
371
+ body,
372
+ defaultModel: backend.defaultModel,
373
+ attribution: initialAttribution(backend, effectiveModel(body, backend.defaultModel)),
374
+ invoke: (callId, signal, onAttribution) => backend.embeddings(body, signal, {
375
+ modelCallId: callId,
376
+ requestContext,
377
+ onAttribution
378
+ })
379
+ });
380
+ return;
381
+ }
382
+ if (method === "POST" && path === "/v1/messages/count_tokens") {
383
+ const raw = await readJson(req, res);
384
+ if (raw === NO_BODY)
385
+ return;
386
+ if (rejectInvalid(res, validateCountTokensRequest(raw)))
387
+ return;
388
+ const rawBody = raw;
389
+ const alias = resolveClaudeModelAlias(rawBody.model, backend.listModelIds?.());
390
+ const route = backend.resolveModelRoute?.(alias, "claude-code");
391
+ if (alias !== undefined &&
392
+ backend.resolveModelRoute !== undefined &&
393
+ route === undefined) {
394
+ writeJson(res, 400, {
395
+ type: "error",
396
+ error: {
397
+ type: "invalid_request_error",
398
+ message: `unknown model: ${alias}`
399
+ }
400
+ });
401
+ return;
402
+ }
403
+ if (anthropicRelay?.countTokens !== undefined &&
404
+ (route?.provider === "claude-code" ||
405
+ backend.resolveModelRoute === undefined)) {
406
+ const relayBody = route?.provider === "claude-code"
407
+ ? withModel(rawBody, route.nativeId)
408
+ : rawBody;
409
+ await pipeUpstream(res, await anthropicRelay.countTokens(req.headers, relayBody));
410
+ return;
411
+ }
412
+ await pipeUpstream(res, handleCountTokens(rawBody));
413
+ return;
414
+ }
415
+ if (method === "POST" && path === "/v1/messages") {
416
+ const raw = await readJson(req, res);
417
+ if (raw === NO_BODY)
418
+ return;
419
+ if (rejectInvalid(res, validateAnthropicRequest(raw)))
420
+ return;
421
+ const rawBody = raw;
422
+ const resolvedModel = resolveClaudeModelAlias(rawBody.model, backend.listModelIds?.());
423
+ // Defer an unknown Claude model to the Anthropic adapter so it can emit
424
+ // the native Anthropic error envelope. The later handleModelCall wrapper
425
+ // still records attribution and provenance for the rejected request.
426
+ const route = backend.resolveModelRoute?.(resolvedModel, "claude-code");
427
+ const canonicalModel = route?.publicId ?? resolvedModel;
428
+ const body = canonicalModel === rawBody.model || canonicalModel === undefined
429
+ ? rawBody
430
+ : withModel(rawBody, canonicalModel);
431
+ const requestedModel = typeof body.model === "string" ? body.model : undefined;
432
+ if (anthropicRelay !== undefined &&
433
+ route?.provider === "claude-code") {
434
+ const relayBody = withModel(rawBody, route.nativeId);
435
+ await handleModelCall(res, provenance, {
436
+ dialect: "anthropic-messages",
437
+ body,
438
+ defaultModel: backend.defaultModel,
439
+ attribution: {
440
+ effective_model: canonicalModel ?? rawBody.model,
441
+ native_model: route.nativeId,
442
+ provider: route.provider,
443
+ billing_mode: "subscription"
444
+ },
445
+ invoke: (_callId, signal, onAttribution) => anthropicRelay.relay(req.headers, relayBody, signal, {
446
+ onAttribution
447
+ })
448
+ });
449
+ return;
450
+ }
451
+ if (anthropicRelay !== undefined &&
452
+ backend.resolveModelRoute === undefined &&
453
+ anthropicRelay.shouldRelay(req.headers, requestedModel, (model) => backend.servesModel?.(model) ?? false)) {
454
+ await handleModelCall(res, provenance, {
455
+ dialect: "anthropic-messages",
456
+ body,
457
+ defaultModel: backend.defaultModel,
458
+ attribution: {
459
+ effective_model: requestedModel ?? "claude-code/default",
460
+ ...(requestedModel !== undefined
461
+ ? { native_model: requestedModel }
462
+ : {}),
463
+ provider: "claude-code",
464
+ billing_mode: "subscription"
465
+ },
466
+ invoke: (_callId, signal, onAttribution) => anthropicRelay.relay(req.headers, body, signal, {
467
+ onAttribution
468
+ })
469
+ });
470
+ return;
471
+ }
472
+ await handleModelCall(res, provenance, {
473
+ dialect: "anthropic-messages",
474
+ body,
475
+ defaultModel: backend.defaultModel,
476
+ attribution: initialAttribution(backend, resolvedModel, "claude-code"),
477
+ invoke: (callId, signal, onAttribution) => handleAnthropicMessages(backend, body, callId, signal, {
478
+ requestContext,
479
+ onAttribution
480
+ })
481
+ });
482
+ return;
483
+ }
484
+ if (method === "POST" &&
485
+ (path === "/v1/responses" || path === "/backend-api/codex/responses")) {
486
+ const raw = await readJson(req, res);
487
+ if (raw === NO_BODY)
488
+ return;
489
+ if (rejectInvalid(res, validateResponsesRequest(raw)))
490
+ return;
491
+ const body = raw;
492
+ // A stock-model pick from a Codex client: the gateway does not serve
493
+ // this model itself, and the request carries the client's own ChatGPT
494
+ // auth — forward it verbatim to the Codex backend instead of silently
495
+ // folding it into the default.
496
+ const requestedModel = typeof body.model === "string" ? body.model : undefined;
497
+ let route;
498
+ try {
499
+ route =
500
+ codexProviderRelay === undefined
501
+ ? backend.resolveModelRoute?.(requestedModel, "codex")
502
+ : resolveNativeModelRoute(backend, "codex", requestedModel);
503
+ }
504
+ catch (error) {
505
+ await handleModelCall(res, provenance, {
506
+ dialect: "openai-responses",
507
+ body,
508
+ defaultModel: backend.defaultModel,
509
+ attribution: initialAttribution(backend, requestedModel, "codex"),
510
+ invoke: async () => {
511
+ throw error;
512
+ }
513
+ });
514
+ return;
515
+ }
516
+ const routedBody = withoutStaleCodexIdentity(body, route);
517
+ const canonicalBody = route === undefined || route.publicId === requestedModel
518
+ ? routedBody
519
+ : withModel(routedBody, route.publicId);
520
+ if (codexProviderRelay !== undefined &&
521
+ route?.provider === "codex") {
522
+ const relayBody = withModel(body, route.nativeId);
523
+ await handleModelCall(res, provenance, {
524
+ dialect: "openai-responses",
525
+ body: canonicalBody,
526
+ defaultModel: backend.defaultModel,
527
+ attribution: {
528
+ effective_model: route.publicId,
529
+ native_model: route.nativeId,
530
+ provider: route.provider,
531
+ billing_mode: "subscription"
532
+ },
533
+ invoke: (_callId, signal, onAttribution) => codexProviderRelay.relay(req.headers, relayBody, signal, {
534
+ onAttribution
535
+ })
536
+ });
537
+ return;
538
+ }
539
+ if (route === undefined &&
540
+ codexRequestRelay !== undefined &&
541
+ (codexProviderRelay === undefined ||
542
+ backend.resolveModelRoute === undefined) &&
543
+ codexRequestRelay.shouldRelay(req.headers, requestedModel, (model) => backend.servesModel?.(model) ?? false)) {
544
+ await handleModelCall(res, provenance, {
545
+ dialect: "openai-responses",
546
+ body,
547
+ defaultModel: backend.defaultModel,
548
+ attribution: {
549
+ effective_model: requestedModel ?? "codex/default",
550
+ ...(requestedModel !== undefined
551
+ ? { native_model: requestedModel }
552
+ : {}),
553
+ provider: "codex",
554
+ billing_mode: "client_auth"
555
+ },
556
+ invoke: (_callId, signal, onAttribution) => codexRequestRelay.relay(req.headers, body, signal, {
557
+ onAttribution
558
+ })
559
+ });
560
+ return;
561
+ }
562
+ await handleModelCall(res, provenance, {
563
+ dialect: "openai-responses",
564
+ body: canonicalBody,
565
+ defaultModel: backend.defaultModel,
566
+ attribution: initialAttribution(backend, requestedModel, codexProviderRelay !== undefined ? "codex" : undefined),
567
+ invoke: (callId, signal, onAttribution) => handleResponses(backend, canonicalBody, callId, signal, {
568
+ requestContext,
569
+ onAttribution
570
+ })
571
+ });
572
+ return;
573
+ }
574
+ writeJson(res, 404, { error: { message: `no route for ${method} ${path}`, type: "not_found" } });
575
+ }
576
+ await new Promise((resolve, reject) => {
577
+ const onError = (error) => reject(error);
578
+ server.once("error", onError);
579
+ server.listen(options.port ?? 0, host, () => {
580
+ server.off("error", onError);
581
+ resolve();
582
+ });
583
+ });
584
+ const address = server.address();
585
+ const port = typeof address === "object" && address !== null ? address.port : options.port ?? 0;
586
+ let drainRun;
587
+ const drain = (graceMs = 0) => {
588
+ drainRun ??= (async () => {
589
+ draining = true;
590
+ // Reap idle keep-alive sockets now; active streams keep their sockets
591
+ // until they finish or the grace expires.
592
+ server.closeIdleConnections();
593
+ const deadline = Date.now() + graceMs;
594
+ while (inflight > 0 && Date.now() < deadline) {
595
+ await new Promise((resolve) => setTimeout(resolve, 50));
596
+ }
597
+ const closed = new Promise((resolve) => {
598
+ server.close(() => resolve());
599
+ });
600
+ server.closeAllConnections();
601
+ await closed;
602
+ })();
603
+ return drainRun;
604
+ };
605
+ return {
606
+ url: () => `http://${host}:${port}`,
607
+ port: () => port,
608
+ drain,
609
+ close: async () => {
610
+ await drain(0);
611
+ await backend.close?.();
612
+ const relays = new Set([codexClientRelay, anthropicRelay, codexProviderRelay].filter((relay) => relay !== undefined));
613
+ await Promise.all([...relays].map(async (relay) => relay.close?.()));
614
+ }
615
+ };
616
+ }
617
+ // ---- HTTP helpers (Node built-ins only) ----
618
+ const NO_BODY = Symbol("no-body");
619
+ const MAX_REQUEST_BODY_BYTES = 16 * 1024 * 1024;
620
+ async function readBody(req) {
621
+ const chunks = [];
622
+ let total = 0;
623
+ let tooLarge = false;
624
+ for await (const value of req) {
625
+ const chunk = value;
626
+ total += chunk.length;
627
+ if (total > MAX_REQUEST_BODY_BYTES) {
628
+ tooLarge = true;
629
+ continue;
630
+ }
631
+ chunks.push(chunk);
632
+ }
633
+ if (tooLarge)
634
+ throw new RequestBodyTooLargeError();
635
+ return Buffer.concat(chunks);
636
+ }
637
+ class RequestBodyTooLargeError extends Error {
638
+ }
639
+ /**
640
+ * Read and parse a JSON request body. On malformed JSON, write a 400 and
641
+ * return the NO_BODY sentinel so the caller stops processing.
642
+ */
643
+ async function readJson(req, res) {
644
+ const declaredLength = Number(req.headers["content-length"]);
645
+ if (Number.isFinite(declaredLength) && declaredLength > MAX_REQUEST_BODY_BYTES) {
646
+ req.resume();
647
+ writeJson(res, 413, {
648
+ error: { message: "request body exceeds the 16 MiB limit", type: "payload_too_large" }
649
+ });
650
+ return NO_BODY;
651
+ }
652
+ let buffer;
653
+ try {
654
+ buffer = await readBody(req);
655
+ }
656
+ catch (error) {
657
+ if (!(error instanceof RequestBodyTooLargeError))
658
+ throw error;
659
+ writeJson(res, 413, {
660
+ error: { message: "request body exceeds the 16 MiB limit", type: "payload_too_large" }
661
+ });
662
+ return NO_BODY;
663
+ }
664
+ if (buffer.length === 0)
665
+ return {};
666
+ try {
667
+ return JSON.parse(buffer.toString("utf8"));
668
+ }
669
+ catch {
670
+ writeJson(res, 400, { error: { message: "invalid JSON body", type: "bad_request" } });
671
+ return NO_BODY;
672
+ }
673
+ }
674
+ /** Write a structural-validation rejection (if any) and report whether one was written. */
675
+ function rejectInvalid(res, rejection) {
676
+ if (rejection === undefined)
677
+ return false;
678
+ writeJson(res, rejection.status, rejection.body);
679
+ return true;
680
+ }
681
+ function writeJson(res, status, value) {
682
+ const payload = Buffer.from(JSON.stringify(value), "utf8");
683
+ res.statusCode = status;
684
+ res.setHeader("content-type", "application/json");
685
+ res.setHeader("content-length", String(payload.byteLength));
686
+ res.end(payload);
687
+ return payload;
688
+ }
689
+ /**
690
+ * Report an error on a response that may already be mid-stream. Once headers
691
+ * are sent (e.g. an SSE turn whose upstream died — a crashed local model
692
+ * server), `writeJson` would throw ERR_HTTP_HEADERS_SENT, so instead destroy
693
+ * the socket: the client sees a clean disconnect for that one request while
694
+ * the gateway process stays alive. Never throws.
695
+ */
696
+ function writeErrorSafely(res, status, value) {
697
+ try {
698
+ if (!res.headersSent)
699
+ return writeJson(res, status, value);
700
+ if (!res.writableEnded)
701
+ res.destroy();
702
+ }
703
+ catch {
704
+ // last resort: nothing we can do with this response, but the server lives
705
+ }
706
+ return Buffer.alloc(0);
707
+ }
708
+ /**
709
+ * Map a normalized provider failure to HTTP, preserving retry timing.
710
+ */
711
+ function writeGatewayError(res, error) {
712
+ if (error instanceof NoModelAvailableError) {
713
+ const payload = writeErrorSafely(res, 503, {
714
+ error: {
715
+ message: error.message,
716
+ type: "unavailable"
717
+ }
718
+ });
719
+ return { statusCode: 503, payload };
720
+ }
721
+ if (error instanceof UnknownModelError) {
722
+ const payload = writeErrorSafely(res, 400, {
723
+ error: {
724
+ message: error.message,
725
+ type: "invalid_request_error",
726
+ param: "model"
727
+ }
728
+ });
729
+ return { statusCode: 400, payload };
730
+ }
731
+ if (error instanceof ProviderFailureError) {
732
+ const { failure } = error;
733
+ const resetAt = failure.resetsAt;
734
+ if (resetAt !== undefined && !res.headersSent) {
735
+ res.setHeader("retry-after", Math.max(0, Math.ceil(resetAt - Date.now() / 1000)));
736
+ }
737
+ const payload = writeErrorSafely(res, 429, {
738
+ error: {
739
+ message: failure.message,
740
+ type: "rate_limit_error",
741
+ ...(resetAt !== undefined ? { resets_at: resetAt } : {})
742
+ }
743
+ });
744
+ return { statusCode: 429, payload };
745
+ }
746
+ process.stderr.write(`routekit gateway upstream error: type=${safeErrorType(error)}\n`);
747
+ const payload = writeErrorSafely(res, 502, {
748
+ error: { message: "upstream request failed", type: "upstream_error" }
749
+ });
750
+ return { statusCode: 502, payload };
751
+ }
752
+ export function collectAttribution(seed) {
753
+ let current = { ...seed };
754
+ let attempts = 0;
755
+ let retries = 0;
756
+ let accountFailovers = 0;
757
+ const operations = new Map();
758
+ return {
759
+ report: (update) => {
760
+ if (update.accountAttempt !== undefined) {
761
+ const attempt = update.accountAttempt;
762
+ const operation = operations.get(attempt.operationId) ?? { attempts: 0 };
763
+ attempts += 1;
764
+ if (operation.attempts > 0)
765
+ retries += 1;
766
+ if (operation.lastSeat !== undefined &&
767
+ operation.lastSeat !== attempt.seat) {
768
+ accountFailovers += 1;
769
+ }
770
+ operations.set(attempt.operationId, {
771
+ attempts: operation.attempts + 1,
772
+ lastSeat: attempt.seat
773
+ });
774
+ current = { ...current, account: { seat: attempt.seat } };
775
+ }
776
+ const { accountAttempt: _accountAttempt, ...attribution } = update;
777
+ current = { ...current, ...attribution };
778
+ },
779
+ snapshot: () => {
780
+ if (current.effective_model === undefined ||
781
+ current.provider === undefined ||
782
+ current.billing_mode === undefined) {
783
+ return undefined;
784
+ }
785
+ return {
786
+ effective_model: current.effective_model,
787
+ ...(current.native_model !== undefined
788
+ ? { native_model: current.native_model }
789
+ : {}),
790
+ provider: current.provider,
791
+ billing_mode: current.billing_mode,
792
+ ...(current.account !== undefined ? { account: current.account } : {}),
793
+ attempts: Math.max(1, attempts),
794
+ retries,
795
+ account_failovers: accountFailovers
796
+ };
797
+ }
798
+ };
799
+ }
800
+ async function handleModelCall(res, sink, route) {
801
+ const callId = modelCallId();
802
+ const attribution = collectAttribution(route.attribution);
803
+ const started = Date.now();
804
+ const startedAt = new Date(started).toISOString();
805
+ const context = {
806
+ callId,
807
+ dialect: route.dialect,
808
+ requestedModel: effectiveModel(route.body, route.defaultModel),
809
+ model: effectiveModel(route.body, route.defaultModel),
810
+ stream: isStream(route.body),
811
+ requestBody: route.body,
812
+ startedAt,
813
+ endpointId: effectiveModel(route.body, route.defaultModel) ?? route.dialect
814
+ };
815
+ res.setHeader(MODEL_CALL_ID_HEADER, callId);
816
+ // Cancel upstream work if the client hangs up before we finish responding.
817
+ const aborter = new AbortController();
818
+ const onClose = () => {
819
+ if (!res.writableEnded)
820
+ aborter.abort();
821
+ };
822
+ res.once("close", onClose);
823
+ try {
824
+ const upstream = await route.invoke(callId, aborter.signal, attribution.report);
825
+ // Only buffer the response body when a provenance sink will consume it.
826
+ const body = await pipeUpstream(res, upstream, sink !== undefined, aborter.signal);
827
+ const result = {
828
+ statusCode: upstream.status,
829
+ responseBody: body,
830
+ durationMs: Date.now() - started
831
+ };
832
+ context.attribution = attribution.snapshot();
833
+ sink?.onModelCall?.(buildModelCallRecord(context, result));
834
+ sink?.onModelCallRaw?.(context, result);
835
+ }
836
+ catch (error) {
837
+ const { statusCode, payload } = writeGatewayError(res, error);
838
+ const result = {
839
+ statusCode,
840
+ responseBody: payload,
841
+ durationMs: Date.now() - started,
842
+ error
843
+ };
844
+ context.attribution = attribution.snapshot();
845
+ sink?.onModelCall?.(buildModelCallRecord(context, result));
846
+ sink?.onModelCallRaw?.(context, result);
847
+ }
848
+ finally {
849
+ res.off("close", onClose);
850
+ }
851
+ }
852
+ /**
853
+ * Cap on the body we buffer for provenance. A streamed turn is piped to the
854
+ * client regardless; this only bounds the in-memory copy kept for the
855
+ * provenance sink (request/response hashing + usage extraction), so a runaway
856
+ * upstream cannot grow gateway memory without bound. 2 MiB comfortably covers a
857
+ * routed chat completion (JSON or SSE); past it, provenance sees a truncated body.
858
+ */
859
+ const PROVENANCE_BODY_CAP_BYTES = 2 * 1024 * 1024;
860
+ async function pipeUpstream(res, upstream, collectBody = false, signal) {
861
+ res.statusCode = upstream.status;
862
+ const contentType = upstream.headers.get("content-type");
863
+ if (contentType !== null)
864
+ res.setHeader("content-type", contentType);
865
+ const body = upstream.body;
866
+ if (body === null) {
867
+ res.end();
868
+ return Buffer.alloc(0);
869
+ }
870
+ const reader = body.getReader();
871
+ const onAbort = () => {
872
+ void reader.cancel(signal?.reason).catch(() => undefined);
873
+ };
874
+ signal?.addEventListener("abort", onAbort, { once: true });
875
+ const chunks = [];
876
+ let collectedBytes = 0;
877
+ try {
878
+ for (;;) {
879
+ const { done, value } = await reader.read();
880
+ if (done)
881
+ break;
882
+ if (value !== undefined) {
883
+ if (signal?.aborted === true || res.destroyed || res.writableEnded) {
884
+ await reader.cancel(signal?.reason).catch(() => undefined);
885
+ break;
886
+ }
887
+ const chunk = Buffer.from(value);
888
+ // Accumulate for provenance only when a sink wants it, and only up to
889
+ // the cap; the client always receives the full stream via res.write.
890
+ if (collectBody && collectedBytes < PROVENANCE_BODY_CAP_BYTES) {
891
+ chunks.push(chunk);
892
+ collectedBytes += chunk.length;
893
+ }
894
+ if (!res.write(chunk)) {
895
+ await Promise.race([once(res, "drain"), once(res, "close")]);
896
+ }
897
+ }
898
+ }
899
+ }
900
+ catch (error) {
901
+ // The upstream stream died mid-response (e.g. a local model server was
902
+ // OOM-killed). Destroy instead of end so the client sees an abnormal
903
+ // disconnect for this request rather than a silently truncated body.
904
+ res.destroy();
905
+ throw error;
906
+ }
907
+ finally {
908
+ signal?.removeEventListener("abort", onAbort);
909
+ }
910
+ if (!res.destroyed && !res.writableEnded)
911
+ res.end();
912
+ return Buffer.concat(chunks);
913
+ }
914
+ function safeErrorType(error) {
915
+ if (error instanceof AggregateError)
916
+ return "AggregateError";
917
+ if (error instanceof TypeError)
918
+ return "TypeError";
919
+ if (error instanceof RangeError)
920
+ return "RangeError";
921
+ if (error instanceof ReferenceError)
922
+ return "ReferenceError";
923
+ if (error instanceof SyntaxError)
924
+ return "SyntaxError";
925
+ if (error instanceof URIError)
926
+ return "URIError";
927
+ if (error instanceof Error)
928
+ return "Error";
929
+ return "NonError";
930
+ }