@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.
- package/LICENSE +201 -0
- package/README.md +28 -0
- package/dist/acp-agent.d.ts +38 -0
- package/dist/acp-agent.js +142 -0
- package/dist/acp-registry.d.ts +36 -0
- package/dist/acp-registry.js +85 -0
- package/dist/adapters/anthropic.d.ts +131 -0
- package/dist/adapters/anthropic.js +1195 -0
- package/dist/adapters/chat.d.ts +14 -0
- package/dist/adapters/chat.js +34 -0
- package/dist/adapters/cursor.d.ts +34 -0
- package/dist/adapters/cursor.js +305 -0
- package/dist/adapters/dropped.d.ts +10 -0
- package/dist/adapters/dropped.js +24 -0
- package/dist/adapters/openai-chat-wire.d.ts +93 -0
- package/dist/adapters/openai-chat-wire.js +143 -0
- package/dist/adapters/responses-stream.d.ts +7 -0
- package/dist/adapters/responses-stream.js +597 -0
- package/dist/adapters/responses.d.ts +174 -0
- package/dist/adapters/responses.js +778 -0
- package/dist/adapters/server-tool-loop.d.ts +94 -0
- package/dist/adapters/server-tool-loop.js +477 -0
- package/dist/adapters/upstream-error.d.ts +14 -0
- package/dist/adapters/upstream-error.js +25 -0
- package/dist/adapters/validate.d.ts +27 -0
- package/dist/adapters/validate.js +180 -0
- package/dist/adapters/web-search.d.ts +46 -0
- package/dist/adapters/web-search.js +151 -0
- package/dist/auth.d.ts +10 -0
- package/dist/auth.js +28 -0
- package/dist/backend.d.ts +151 -0
- package/dist/backend.js +143 -0
- package/dist/capacity-pool.d.ts +31 -0
- package/dist/capacity-pool.js +99 -0
- package/dist/cost.d.ts +49 -0
- package/dist/cost.js +112 -0
- package/dist/endpoint-health.d.ts +54 -0
- package/dist/endpoint-health.js +123 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.js +23 -0
- package/dist/provenance.d.ts +31 -0
- package/dist/provenance.js +191 -0
- package/dist/provider-backends.d.ts +40 -0
- package/dist/provider-backends.js +1050 -0
- package/dist/provider-source.d.ts +40 -0
- package/dist/provider-source.js +293 -0
- package/dist/router.d.ts +168 -0
- package/dist/router.js +474 -0
- package/dist/server.d.ts +67 -0
- package/dist/server.js +930 -0
- package/dist/sse/chat-assembler.d.ts +45 -0
- package/dist/sse/chat-assembler.js +190 -0
- package/dist/sse/parse.d.ts +50 -0
- package/dist/sse/parse.js +149 -0
- package/dist/sse-wire.d.ts +10 -0
- package/dist/sse-wire.js +31 -0
- package/dist/switching-proxy.d.ts +15 -0
- package/dist/switching-proxy.js +232 -0
- package/dist/test/acp-agent.test.d.ts +1 -0
- package/dist/test/acp-agent.test.js +66 -0
- package/dist/test/acp-registry.test.d.ts +1 -0
- package/dist/test/acp-registry.test.js +70 -0
- package/dist/test/anthropic.test.d.ts +1 -0
- package/dist/test/anthropic.test.js +793 -0
- package/dist/test/auth.test.d.ts +1 -0
- package/dist/test/auth.test.js +25 -0
- package/dist/test/boundary.test.d.ts +1 -0
- package/dist/test/boundary.test.js +32 -0
- package/dist/test/chat.test.d.ts +1 -0
- package/dist/test/chat.test.js +418 -0
- package/dist/test/cost.test.d.ts +1 -0
- package/dist/test/cost.test.js +60 -0
- package/dist/test/cursor.test.d.ts +1 -0
- package/dist/test/cursor.test.js +100 -0
- package/dist/test/drain.test.d.ts +1 -0
- package/dist/test/drain.test.js +116 -0
- package/dist/test/dropped.test.d.ts +1 -0
- package/dist/test/dropped.test.js +80 -0
- package/dist/test/endpoint-health.test.d.ts +1 -0
- package/dist/test/endpoint-health.test.js +73 -0
- package/dist/test/provenance.test.d.ts +1 -0
- package/dist/test/provenance.test.js +176 -0
- package/dist/test/provider-backends.test.d.ts +1 -0
- package/dist/test/provider-backends.test.js +699 -0
- package/dist/test/responses.test.d.ts +1 -0
- package/dist/test/responses.test.js +813 -0
- package/dist/test/routed-backend.test.d.ts +1 -0
- package/dist/test/routed-backend.test.js +39 -0
- package/dist/test/router.test.d.ts +1 -0
- package/dist/test/router.test.js +297 -0
- package/dist/test/server-resilience.test.d.ts +1 -0
- package/dist/test/server-resilience.test.js +169 -0
- package/dist/test/sse-codec.test.d.ts +1 -0
- package/dist/test/sse-codec.test.js +186 -0
- package/dist/test/web-search-loop.test.d.ts +1 -0
- package/dist/test/web-search-loop.test.js +469 -0
- package/dist/test/wire-validation.test.d.ts +1 -0
- package/dist/test/wire-validation.test.js +140 -0
- 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
|
+
}
|