@evomap/evolver-proxy 2.0.0-beta.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 (82) hide show
  1. package/README.md +49 -0
  2. package/dist/bin/envFile.d.ts +10 -0
  3. package/dist/bin/envFile.js +68 -0
  4. package/dist/bin/evolver-llm-proxy.d.ts +2 -0
  5. package/dist/bin/evolver-llm-proxy.js +111 -0
  6. package/dist/bin/evolver-proxy.d.ts +83 -0
  7. package/dist/bin/evolver-proxy.js +511 -0
  8. package/dist/bin/proxySettings.d.ts +15 -0
  9. package/dist/bin/proxySettings.js +84 -0
  10. package/dist/bin/proxyStorePath.d.ts +1 -0
  11. package/dist/bin/proxyStorePath.js +16 -0
  12. package/dist/daemon/atpConsent.d.ts +13 -0
  13. package/dist/daemon/atpConsent.js +60 -0
  14. package/dist/daemon/ipcConfig.d.ts +1 -0
  15. package/dist/daemon/ipcConfig.js +13 -0
  16. package/dist/daemon/proxyDaemon.d.ts +191 -0
  17. package/dist/daemon/proxyDaemon.js +1015 -0
  18. package/dist/daemon/selectHub.d.ts +16 -0
  19. package/dist/daemon/selectHub.js +30 -0
  20. package/dist/index.d.ts +8 -0
  21. package/dist/index.js +8 -0
  22. package/dist/lifecycle/deployGuard.d.ts +46 -0
  23. package/dist/lifecycle/deployGuard.js +53 -0
  24. package/dist/lifecycle/legacyNodeId.d.ts +96 -0
  25. package/dist/lifecycle/legacyNodeId.js +163 -0
  26. package/dist/lifecycle/manager.d.ts +106 -0
  27. package/dist/lifecycle/manager.js +390 -0
  28. package/dist/llm/bodyCapture.d.ts +31 -0
  29. package/dist/llm/bodyCapture.js +293 -0
  30. package/dist/llm/index.d.ts +3 -0
  31. package/dist/llm/index.js +3 -0
  32. package/dist/llm/server.d.ts +35 -0
  33. package/dist/llm/server.js +359 -0
  34. package/dist/llm/traceBackfill.d.ts +53 -0
  35. package/dist/llm/traceBackfill.js +525 -0
  36. package/dist/llm/traceConfig.d.ts +7 -0
  37. package/dist/llm/traceConfig.js +44 -0
  38. package/dist/llm/traceControl.d.ts +16 -0
  39. package/dist/llm/traceControl.js +85 -0
  40. package/dist/llm/traceEnvelope.d.ts +75 -0
  41. package/dist/llm/traceEnvelope.js +286 -0
  42. package/dist/llm/traceSink.d.ts +61 -0
  43. package/dist/llm/traceSink.js +278 -0
  44. package/dist/llm/traceUploadPayload.d.ts +14 -0
  45. package/dist/llm/traceUploadPayload.js +27 -0
  46. package/dist/llm/upstream.d.ts +68 -0
  47. package/dist/llm/upstream.js +491 -0
  48. package/dist/private/adapterLoader.d.ts +46 -0
  49. package/dist/private/adapterLoader.js +62 -0
  50. package/dist/private/privateRuntimeSmokeOptions.d.ts +15 -0
  51. package/dist/private/privateRuntimeSmokeOptions.js +132 -0
  52. package/dist/router/cachePassthrough.d.ts +3 -0
  53. package/dist/router/cachePassthrough.js +13 -0
  54. package/dist/router/features.d.ts +9 -0
  55. package/dist/router/features.js +52 -0
  56. package/dist/router/index.d.ts +6 -0
  57. package/dist/router/index.js +6 -0
  58. package/dist/router/messagesRoute.d.ts +138 -0
  59. package/dist/router/messagesRoute.js +753 -0
  60. package/dist/router/modelRouter.d.ts +49 -0
  61. package/dist/router/modelRouter.js +66 -0
  62. package/dist/router/providerRoutes.d.ts +42 -0
  63. package/dist/router/providerRoutes.js +1579 -0
  64. package/dist/router/sseScan.d.ts +56 -0
  65. package/dist/router/sseScan.js +543 -0
  66. package/dist/selfUpdate/executor.d.ts +90 -0
  67. package/dist/selfUpdate/executor.js +179 -0
  68. package/dist/selfUpdate/failureCodes.d.ts +33 -0
  69. package/dist/selfUpdate/failureCodes.js +84 -0
  70. package/dist/selfUpdate/index.d.ts +5 -0
  71. package/dist/selfUpdate/index.js +5 -0
  72. package/dist/selfUpdate/lastUpdate.d.ts +43 -0
  73. package/dist/selfUpdate/lastUpdate.js +195 -0
  74. package/dist/selfUpdate/policy.d.ts +3 -0
  75. package/dist/selfUpdate/policy.js +9 -0
  76. package/dist/selfUpdate/releaseBinary.d.ts +56 -0
  77. package/dist/selfUpdate/releaseBinary.js +498 -0
  78. package/dist/selfUpdate/version.d.ts +2 -0
  79. package/dist/selfUpdate/version.js +14 -0
  80. package/dist/sync/engine.d.ts +65 -0
  81. package/dist/sync/engine.js +461 -0
  82. package/package.json +41 -0
@@ -0,0 +1,753 @@
1
+ // /v1/messages handler (ported from v1 proxy/router/messages_route.js). Wraps an injected upstream callable
2
+ // with three stages — extract features → pick tier → cache-preserving model rewrite — each with its own
3
+ // fallback so a single bad input never breaks passthrough: classifier throw → forward unmodified; rewriter
4
+ // throw → forward unmodified; upstream 5xx on a rewritten request → one retry with the client's original model.
5
+ // The actual network call is the `anthropicProxy` seam, so the whole handler is unit-testable with a fake.
6
+ import { randomUUID } from 'node:crypto';
7
+ import { pickForTurn } from './modelRouter.js';
8
+ import { rewriteModel } from './cachePassthrough.js';
9
+ import { extractFeatures } from './features.js';
10
+ import { SseUsageScanner, teeStreamForScan } from './sseScan.js';
11
+ import { captureBodiesEnabled, captureBody, REDACTION_VERSION, redactText, stableUserIdHash } from '../llm/bodyCapture.js';
12
+ export function captureTraceMetadata(value, env = process.env) {
13
+ const captured = captureBody(value, env);
14
+ if (!captured)
15
+ return undefined;
16
+ try {
17
+ return JSON.parse(captured.body);
18
+ }
19
+ catch {
20
+ return captured.body;
21
+ }
22
+ }
23
+ // Tier → concrete model is OPERATOR CONFIG, never hardcoded — model IDs go stale fast (opus-4-7 → 4-8 → …),
24
+ // so a baked-in default would silently route to a dead/old model. Each tier is read from env; an unset tier
25
+ // has NO model, and the handler then leaves the client's model untouched (transparent passthrough). The
26
+ // operator opts a tier into routing by setting EVOMAP_MODEL_{CHEAP,MID,EXPENSIVE}.
27
+ export function resolveTierModels(env = process.env) {
28
+ const out = {};
29
+ if (env['EVOMAP_MODEL_CHEAP'])
30
+ out.cheap = env['EVOMAP_MODEL_CHEAP'];
31
+ if (env['EVOMAP_MODEL_MID'])
32
+ out.mid = env['EVOMAP_MODEL_MID'];
33
+ if (env['EVOMAP_MODEL_EXPENSIVE'])
34
+ out.expensive = env['EVOMAP_MODEL_EXPENSIVE'];
35
+ return out;
36
+ }
37
+ export function parseClaudeId(modelId) {
38
+ if (typeof modelId !== 'string')
39
+ return null;
40
+ const m = /claude-(opus|sonnet|haiku)-(\d+)-(\d+)/i.exec(modelId);
41
+ if (!m)
42
+ return null;
43
+ const major = Number(m[2]);
44
+ const minor = Number(m[3]);
45
+ if (!Number.isFinite(major) || !Number.isFinite(minor))
46
+ return null;
47
+ return { family: m[1].toLowerCase(), major, minor };
48
+ }
49
+ /** Block an intra-family generational DOWNGRADE (opus-4-7 → opus-4-1). Cross-family (opus→haiku) is allowed. */
50
+ export function isIntraFamilyDowngrade(chosen, original) {
51
+ const c = parseClaudeId(chosen);
52
+ const o = parseClaudeId(original);
53
+ if (!c || !o || c.family !== o.family)
54
+ return false;
55
+ if (c.major !== o.major)
56
+ return c.major < o.major;
57
+ return c.minor < o.minor;
58
+ }
59
+ // Bedrock InvokeModel rejects bare short IDs and needs ARN-shaped aliases. Keep the v1 known-safe defaults so
60
+ // existing clients that send short Claude IDs keep working in bedrock mode, while still letting operators override
61
+ // any stale target with EVOMAP_BEDROCK_ALIASES.
62
+ const DEFAULT_BEDROCK_ALIASES = Object.freeze({
63
+ 'opus/4/7': 'global.anthropic.claude-opus-4-7',
64
+ 'haiku/4/5': 'global.anthropic.claude-haiku-4-5-20251001-v1:0',
65
+ 'sonnet/4/6': 'global.anthropic.claude-sonnet-4-6',
66
+ });
67
+ // EVOMAP_BEDROCK_ALIASES is a JSON object keyed by `family/major/minor`
68
+ // (e.g. {"haiku/4/5":"global.anthropic.claude-haiku-4-5-...-v1:0"}).
69
+ export function resolveBedrockAliases(env = process.env) {
70
+ const out = { ...DEFAULT_BEDROCK_ALIASES };
71
+ const raw = env['EVOMAP_BEDROCK_ALIASES'];
72
+ if (!raw)
73
+ return out;
74
+ try {
75
+ const o = JSON.parse(raw);
76
+ if (o && typeof o === 'object' && !Array.isArray(o)) {
77
+ for (const [k, v] of Object.entries(o))
78
+ if (typeof v === 'string')
79
+ out[k] = v;
80
+ return out;
81
+ }
82
+ }
83
+ catch { /* malformed → defaults only */ }
84
+ return out;
85
+ }
86
+ /** Canonicalize a short Claude ID to its operator-configured Bedrock alias. Unmapped/unknown → unchanged. */
87
+ export function canonicalizeForBedrock(modelId, aliases) {
88
+ const parsed = parseClaudeId(modelId);
89
+ if (!parsed)
90
+ return modelId;
91
+ return aliases[`${parsed.family}/${parsed.major}/${parsed.minor}`] ?? modelId;
92
+ }
93
+ export function supportsAdaptiveThinking(modelId) {
94
+ const parsed = parseClaudeId(modelId);
95
+ if (!parsed)
96
+ return false;
97
+ if (parsed.major > 4)
98
+ return true;
99
+ return parsed.major === 4 && parsed.minor >= 7;
100
+ }
101
+ const j = (o) => JSON.stringify(o);
102
+ async function drain(text, log, ctx) {
103
+ if (!text)
104
+ return '';
105
+ let timer;
106
+ try {
107
+ return await Promise.race([
108
+ Promise.resolve(text()),
109
+ new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('response drain timeout')), 10_000); }),
110
+ ]);
111
+ }
112
+ catch (e) {
113
+ if (e instanceof Error && e.message.includes('timeout'))
114
+ log.warn?.(j({ event: 'router_fallback', reason: 'upstream_5xx_drain_timeout', ...ctx }));
115
+ return '';
116
+ }
117
+ finally {
118
+ if (timer)
119
+ clearTimeout(timer);
120
+ }
121
+ }
122
+ function getHeader(headers, name) {
123
+ const want = name.toLowerCase();
124
+ for (const [key, value] of Object.entries(headers)) {
125
+ if (key.toLowerCase() === want && value)
126
+ return value;
127
+ }
128
+ return '';
129
+ }
130
+ function clip(value, max = 128) {
131
+ return value.length <= max ? value : value.slice(0, max);
132
+ }
133
+ function safeTraceError(value, max = 256) {
134
+ return clip(redactText(value).replace(/\s+/g, ' ').trim(), max);
135
+ }
136
+ function safePlainSessionId(value) {
137
+ const s = value.trim();
138
+ if (s !== value)
139
+ return '';
140
+ if (s.length < 4 || s.length > 128)
141
+ return '';
142
+ if (s.includes('@') || /\s/.test(s))
143
+ return '';
144
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*[A-Za-z0-9]$/.test(s))
145
+ return '';
146
+ if (/^(?:bearer|basic|sk-|ghp_|github_pat_|gho_|ghu_|ghs_|glpat-|xox[baprs]-)/i.test(s))
147
+ return '';
148
+ if (/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(s))
149
+ return '';
150
+ if (/(?:^|[-_.])(?:token|secret|apikey|api[_-]?key|password|passwd|credential|auth)(?:$|[-_.])/i.test(s))
151
+ return '';
152
+ if (/^[a-f0-9]{32,}$/i.test(s))
153
+ return '';
154
+ if (/^[A-Za-z0-9_-]{40,}$/.test(s) && !/[-_.]/.test(s))
155
+ return '';
156
+ if (/(?:session|sess)/i.test(s))
157
+ return s;
158
+ return /[-_.]/.test(s) ? s : '';
159
+ }
160
+ function sessionIdFromPlainField(value) {
161
+ return typeof value === 'string' ? safePlainSessionId(value) : '';
162
+ }
163
+ function sessionIdFromClaudeUserId(value) {
164
+ const marker = '__session_';
165
+ const index = value.indexOf(marker);
166
+ if (index < 0)
167
+ return '';
168
+ return safePlainSessionId(value.slice(index + marker.length));
169
+ }
170
+ function sessionIdFromUserField(value) {
171
+ if (!value)
172
+ return '';
173
+ let parsed = value;
174
+ if (typeof value === 'string') {
175
+ const s = value.trim();
176
+ if (!s.startsWith('{'))
177
+ return sessionIdFromClaudeUserId(s) || safePlainSessionId(s);
178
+ try {
179
+ parsed = JSON.parse(s);
180
+ }
181
+ catch {
182
+ return '';
183
+ }
184
+ }
185
+ if (parsed && typeof parsed === 'object') {
186
+ const sid = parsed['session_id'];
187
+ if (typeof sid === 'string' && sid.length > 0)
188
+ return safePlainSessionId(sid);
189
+ }
190
+ return '';
191
+ }
192
+ function extractSessionId(headers, body) {
193
+ for (const name of ['x-session-id', 'x-cursor-session-id', 'x-conversation-id']) {
194
+ const value = getHeader(headers, name).trim();
195
+ const sid = sessionIdFromPlainField(value);
196
+ if (sid)
197
+ return clip(sid);
198
+ }
199
+ const metadata = body['metadata'];
200
+ if (metadata && typeof metadata === 'object') {
201
+ const m = metadata;
202
+ const sid = sessionIdFromUserField(m['user_id']) || sessionIdFromPlainField(m['session_id']);
203
+ if (sid)
204
+ return clip(sid);
205
+ }
206
+ const sid = sessionIdFromUserField(body['user']);
207
+ if (sid)
208
+ return clip(sid);
209
+ return null;
210
+ }
211
+ function extractTopLevelUserIdHash(body) {
212
+ const metadata = body['metadata'];
213
+ if (metadata && typeof metadata === 'object' && !Array.isArray(metadata)) {
214
+ const userId = metadata['user_id'];
215
+ if (typeof userId === 'string' || typeof userId === 'number')
216
+ return stableUserIdHash(userId);
217
+ }
218
+ const user = body['user'];
219
+ if (typeof user === 'string' || typeof user === 'number')
220
+ return stableUserIdHash(user);
221
+ return undefined;
222
+ }
223
+ function isThinkingEffortRecord(value) {
224
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
225
+ }
226
+ // FIX-9: normalize "how hard should the model think" across provider request shapes into one top-level field.
227
+ // Anthropic: body.thinking = { type:'enabled', budget_tokens:N }
228
+ // OpenAI: body.reasoning = { effort:'low'|'medium'|'high'|'minimal' }
229
+ // OpenAI alt: body.output_config = { effort:'...' } / body.reasoning_effort = '...'
230
+ // Fallback: body.metadata.{thinking_effort|reasoning_effort|effort}
231
+ // Returns undefined when no effort signal is present.
232
+ export function extractThinkingEffort(body) {
233
+ if (!isThinkingEffortRecord(body))
234
+ return undefined;
235
+ const out = {};
236
+ const thinking = body['thinking'];
237
+ if (isThinkingEffortRecord(thinking)) {
238
+ if (typeof thinking['type'] === 'string')
239
+ out.type = thinking['type'];
240
+ if (typeof thinking['budget_tokens'] === 'number' && Number.isFinite(thinking['budget_tokens']))
241
+ out.budget_tokens = thinking['budget_tokens'];
242
+ if (typeof thinking['effort'] === 'string')
243
+ out.effort = thinking['effort'];
244
+ }
245
+ const reasoning = body['reasoning'];
246
+ if (out.effort === undefined && isThinkingEffortRecord(reasoning) && typeof reasoning['effort'] === 'string') {
247
+ out.effort = reasoning['effort'];
248
+ }
249
+ const outputConfig = body['output_config'];
250
+ if (out.effort === undefined && isThinkingEffortRecord(outputConfig) && typeof outputConfig['effort'] === 'string') {
251
+ out.effort = outputConfig['effort'];
252
+ }
253
+ if (out.effort === undefined && typeof body['reasoning_effort'] === 'string')
254
+ out.effort = body['reasoning_effort'];
255
+ const metadata = body['metadata'];
256
+ if (out.effort === undefined && isThinkingEffortRecord(metadata)) {
257
+ for (const key of ['thinking_effort', 'reasoning_effort', 'effort']) {
258
+ if (typeof metadata[key] === 'string') {
259
+ out.effort = metadata[key];
260
+ break;
261
+ }
262
+ }
263
+ }
264
+ return out.effort !== undefined || out.budget_tokens !== undefined || out.type !== undefined ? out : undefined;
265
+ }
266
+ function detectClient(headers) {
267
+ const text = [
268
+ getHeader(headers, 'user-agent'),
269
+ getHeader(headers, 'x-client-name'),
270
+ getHeader(headers, 'x-stainless-package-version'),
271
+ getHeader(headers, 'x-app'),
272
+ ].filter(Boolean).join(' ').toLowerCase();
273
+ if (text.includes('cursor'))
274
+ return 'cursor';
275
+ if (text.includes('codex'))
276
+ return 'codex';
277
+ if (text.includes('claude'))
278
+ return 'claude-code';
279
+ return 'unknown';
280
+ }
281
+ function wireApiForRoute(route) {
282
+ if (route === '/v1/responses')
283
+ return 'openai_responses';
284
+ if (route === '/v1/chat/completions')
285
+ return 'openai_chat_completions';
286
+ return 'anthropic_messages';
287
+ }
288
+ function defaultUpstreamMode(route) {
289
+ return route === '/v1/messages' ? 'anthropic' : 'openai';
290
+ }
291
+ function resolveUpstreamMode(route, env) {
292
+ const routeSpecific = route === '/v1/messages' ? env['EVOMAP_UPSTREAM'] : undefined;
293
+ return (routeSpecific || defaultUpstreamMode(route)).toLowerCase();
294
+ }
295
+ function providerForTrace(route, upstreamMode, env) {
296
+ if (upstreamMode === 'bedrock')
297
+ return 'aws-bedrock';
298
+ if (route === '/v1/responses' || route === '/v1/chat/completions') {
299
+ return (env['EVOLVER_LLM_OPENAI_UPSTREAM'] || env['EVOMAP_OPENAI_UPSTREAM'] || 'openai').toLowerCase();
300
+ }
301
+ return upstreamMode;
302
+ }
303
+ function streamResponse(up) {
304
+ const headers = {};
305
+ const ct = up.headers?.['content-type'];
306
+ if (ct)
307
+ headers['Content-Type'] = ct;
308
+ return { status: up.status, stream: up.stream, headers };
309
+ }
310
+ /** Pull usage/stop_reason out of a parsed non-stream response body. Tolerant: anything malformed → {}. */
311
+ function extractResponseMeta(respBody) {
312
+ if (!respBody || typeof respBody !== 'object')
313
+ return {};
314
+ const o = respBody;
315
+ const out = {};
316
+ const usageSource = o['usage']
317
+ ?? (o['response'] && typeof o['response'] === 'object' ? o['response']['usage'] : undefined);
318
+ if (usageSource && typeof usageSource === 'object') {
319
+ const u = usageSource;
320
+ const usage = {};
321
+ for (const k of ['input_tokens', 'output_tokens', 'cache_creation_input_tokens', 'cache_read_input_tokens']) {
322
+ if (typeof u[k] === 'number')
323
+ usage[k] = u[k];
324
+ }
325
+ if (typeof u['prompt_tokens'] === 'number')
326
+ usage.input_tokens = u['prompt_tokens'];
327
+ if (typeof u['completion_tokens'] === 'number')
328
+ usage.output_tokens = u['completion_tokens'];
329
+ const tokenDetails = u['input_tokens_details'] ?? u['prompt_tokens_details'];
330
+ if (tokenDetails && typeof tokenDetails === 'object') {
331
+ const cached = tokenDetails['cached_tokens'];
332
+ if (typeof cached === 'number')
333
+ usage.cache_read_input_tokens = cached;
334
+ }
335
+ if (Object.keys(usage).length > 0)
336
+ out.usage = usage;
337
+ }
338
+ let choiceFinish;
339
+ if (Array.isArray(o['choices']) && o['choices'][0] && typeof o['choices'][0] === 'object') {
340
+ const finish = o['choices'][0]['finish_reason'];
341
+ if (typeof finish === 'string' || finish === null)
342
+ choiceFinish = finish;
343
+ }
344
+ let incompleteReason;
345
+ if (o['incomplete_details'] && typeof o['incomplete_details'] === 'object') {
346
+ const reason = o['incomplete_details']['reason'];
347
+ if (typeof reason === 'string')
348
+ incompleteReason = reason;
349
+ }
350
+ if (typeof o['stop_reason'] === 'string' || o['stop_reason'] === null)
351
+ out.stop_reason = o['stop_reason'];
352
+ else if (typeof o['finish_reason'] === 'string' || o['finish_reason'] === null)
353
+ out.stop_reason = o['finish_reason'];
354
+ else if (choiceFinish !== undefined)
355
+ out.stop_reason = choiceFinish;
356
+ else if (incompleteReason)
357
+ out.stop_reason = incompleteReason;
358
+ else if (typeof o['status'] === 'string')
359
+ out.stop_reason = o['status'];
360
+ const response = o['response'];
361
+ const rid = typeof o['id'] === 'string'
362
+ ? o['id']
363
+ : response && typeof response === 'object' && typeof response['id'] === 'string'
364
+ ? String(response['id'])
365
+ : '';
366
+ if (rid)
367
+ out.response_id = rid;
368
+ return out;
369
+ }
370
+ function hasAnthropicProxyCredentials(env) {
371
+ return !!(env['EVOMAP_ANTHROPIC_API_KEY']
372
+ || env['ANTHROPIC_API_KEY']
373
+ || env['EVOMAP_ANTHROPIC_AUTH_TOKEN']
374
+ || (env['EVOMAP_PROXY_AUTO_INJECTED'] === '1' ? '' : env['ANTHROPIC_AUTH_TOKEN']));
375
+ }
376
+ function hasOpenAIProxyCredentials(env) {
377
+ return !!(env['EVOLVER_LLM_OPENAI_API_KEY'] || env['EVOMAP_OPENAI_API_KEY'] || env['OPENAI_API_KEY']);
378
+ }
379
+ /**
380
+ * Build the /v1/messages handler. `enabled` (env EVOMAP_ROUTER_ENABLED, or the explicit override) gates the
381
+ * whole router — when off, the body forwards unmodified (a pure passthrough). Returns {status, body|stream}.
382
+ */
383
+ export function buildMessagesHandler(opts) {
384
+ if (typeof opts.anthropicProxy !== 'function')
385
+ throw new Error('buildMessagesHandler requires anthropicProxy(path, body, opts)');
386
+ const log = opts.logger ?? console;
387
+ const env = opts.env ?? process.env;
388
+ const enabled = typeof opts.routerEnabled === 'boolean' ? opts.routerEnabled : env['EVOMAP_ROUTER_ENABLED'] === '1';
389
+ // Native body capture defaults to v1-compatible full trace mode. Operators that need metadata-only rows must
390
+ // explicitly disable it (a compliance decision; see bodyCapture.ts and docs/trace-body-capture.md).
391
+ const captureBodies = captureBodiesEnabled(env);
392
+ return async (req) => {
393
+ const clock = opts.clock ?? (() => Date.now());
394
+ const t0 = clock();
395
+ const inboundHeaders = req.headers ?? {};
396
+ const route = req.route ?? '/v1/messages';
397
+ const body = req.body;
398
+ const upstreamMode = resolveUpstreamMode(route, env);
399
+ const sessionId = extractSessionId(inboundHeaders, body);
400
+ const previousResponseId = typeof body['previous_response_id'] === 'string' && body['previous_response_id'].length > 0
401
+ ? clip(body['previous_response_id'])
402
+ : null;
403
+ const routeAllowsRouting = route === '/v1/messages';
404
+ // Model/decision fields live above the credential gate (all pure computation) so the trace closure can
405
+ // read them on every exit path, including the 401 throw.
406
+ const rawInboundModel = typeof body?.model === 'string' ? body.model : null;
407
+ const originalModel = upstreamMode === 'bedrock' ? canonicalizeForBedrock(rawInboundModel, resolveBedrockAliases(env)) : rawInboundModel;
408
+ let chosenModel = originalModel;
409
+ let decisionTier = null;
410
+ let decisionReason = null;
411
+ let fallback = null;
412
+ let ttfb = null;
413
+ let traced = false;
414
+ let traceRequestBody = body;
415
+ let bodyCaptureAllowed = false;
416
+ const attempts = [];
417
+ const parseUpstreamBody = (raw, status) => {
418
+ if (raw.length === 0)
419
+ return {};
420
+ try {
421
+ return JSON.parse(raw);
422
+ }
423
+ catch {
424
+ log.warn?.(j({
425
+ event: 'router_fallback',
426
+ reason: 'upstream_non_json',
427
+ upstream_status: status,
428
+ preview: redactText(raw).slice(0, 200),
429
+ }));
430
+ return { error: raw };
431
+ }
432
+ };
433
+ const responseBodyIndicatesTruncation = (responseBody) => {
434
+ const responseObject = responseBody && typeof responseBody === 'object'
435
+ ? responseBody
436
+ : undefined;
437
+ return responseObject?.content_truncated === true
438
+ || responseObject?.raw_stream_truncated === true
439
+ || typeof responseObject?.dropped_event_count === 'number';
440
+ };
441
+ const captureAttemptBodies = () => {
442
+ return attempts.map((attempt) => {
443
+ const record = {
444
+ attempt_index: attempt.attempt_index,
445
+ model: attempt.model,
446
+ provider: attempt.provider,
447
+ upstream_mode: attempt.upstream_mode,
448
+ status: attempt.status,
449
+ ...(attempt.error !== undefined ? { error: attempt.error } : {}),
450
+ ...(attempt.body_truncated === true ? { body_truncated: true } : {}),
451
+ };
452
+ const reqCap = captureBodies ? captureBody(attempt.requestBody, env) : undefined;
453
+ const respCap = captureBodies && attempt.responseBody !== undefined ? captureBody(attempt.responseBody, env) : undefined;
454
+ if (reqCap)
455
+ record.requestBody = reqCap.body;
456
+ if (respCap)
457
+ record.responseBody = respCap.body;
458
+ if (reqCap?.truncated
459
+ || respCap?.truncated
460
+ || attempt.body_truncated === true
461
+ || responseBodyIndicatesTruncation(attempt.responseBody))
462
+ record.body_truncated = true;
463
+ return record;
464
+ });
465
+ };
466
+ const capturedStreamResponseBody = (scanner) => {
467
+ return captureBodies && (scanner.result.content_events !== undefined
468
+ || scanner.result.semantic_tail_events !== undefined
469
+ || scanner.result.raw_stream_body !== undefined
470
+ || scanner.result.content_text !== undefined
471
+ || scanner.result.content_truncated === true
472
+ || scanner.result.raw_stream_truncated === true
473
+ || scanner.result.dropped_event_count !== undefined)
474
+ ? {
475
+ reconstructed: true,
476
+ ...(scanner.result.content_events !== undefined ? { events: scanner.result.content_events } : {}),
477
+ ...(scanner.result.semantic_tail_events !== undefined ? { semantic_tail_events: scanner.result.semantic_tail_events } : {}),
478
+ ...(scanner.result.raw_stream_body !== undefined ? { raw_stream_body: scanner.result.raw_stream_body } : {}),
479
+ ...(scanner.result.content_text !== undefined ? { content_text: scanner.result.content_text } : {}),
480
+ ...(scanner.result.content_truncated ? { content_truncated: true } : {}),
481
+ ...(scanner.result.raw_stream_truncated ? { raw_stream_truncated: true } : {}),
482
+ ...(scanner.result.dropped_event_count ? { dropped_event_count: scanner.result.dropped_event_count } : {}),
483
+ }
484
+ : undefined;
485
+ };
486
+ const trace = (last) => {
487
+ if (!opts.onTrace || traced)
488
+ return;
489
+ traced = true;
490
+ let features;
491
+ try {
492
+ if (routeAllowsRouting)
493
+ features = extractFeatures(body);
494
+ }
495
+ catch { /* a malformed body must not break trace emission */ }
496
+ const record = {
497
+ ts: new Date(t0).toISOString(),
498
+ event: 'llm_turn',
499
+ id: `llm_${randomUUID()}`,
500
+ request_id: getHeader(inboundHeaders, 'x-request-id') ? clip(getHeader(inboundHeaders, 'x-request-id')) : null,
501
+ route,
502
+ provider: providerForTrace(route, upstreamMode, env),
503
+ wire_api: wireApiForRoute(route),
504
+ client: detectClient(inboundHeaders),
505
+ ...(getHeader(inboundHeaders, 'user-agent') ? { user_agent: clip(getHeader(inboundHeaders, 'user-agent')) } : {}),
506
+ ...(() => {
507
+ try {
508
+ const hash = extractTopLevelUserIdHash(body);
509
+ return hash ? { user_id_hash: hash } : {};
510
+ }
511
+ catch {
512
+ return {};
513
+ }
514
+ })(),
515
+ ...(() => {
516
+ try {
517
+ const effort = extractThinkingEffort(body);
518
+ return effort ? { thinking_effort: effort } : {};
519
+ }
520
+ catch {
521
+ return {};
522
+ }
523
+ })(),
524
+ session_id: sessionId,
525
+ original_model: typeof originalModel === 'string' ? originalModel : null,
526
+ chosen_model: typeof chosenModel === 'string' ? chosenModel : null,
527
+ tier: decisionTier,
528
+ reason: decisionReason,
529
+ fallback,
530
+ router_enabled: enabled && routeAllowsRouting,
531
+ upstream_mode: upstreamMode,
532
+ status: last.status,
533
+ stream: last.stream,
534
+ ttfb_ms: ttfb,
535
+ latency_ms: clock() - t0,
536
+ ...(features ? { features } : {}),
537
+ ...(last.usage ? { usage: last.usage } : {}),
538
+ ...(last.stop_reason !== undefined ? { stop_reason: last.stop_reason } : {}),
539
+ ...(last.response_id ? { response_id: last.response_id } : {}),
540
+ ...(previousResponseId ? { previous_response_id: previousResponseId } : {}),
541
+ // clip(): the trace is shipped as evolution material on the documented promise that NO prompt/completion
542
+ // content enters it (see traceSink.ts). A raw upstream/exception error can echo request fragments and is
543
+ // unbounded, so bound+truncate it here — the single choke point every error path flows through.
544
+ ...(last.error !== undefined ? { error: safeTraceError(last.error) } : {}),
545
+ ...(captureBodies && bodyCaptureAllowed ? { request_headers: captureTraceMetadata(inboundHeaders, env) } : {}),
546
+ ...(captureBodies && bodyCaptureAllowed && last.responseHeaders ? { response_headers: captureTraceMetadata(last.responseHeaders, env) } : {}),
547
+ ...(captureBodies && bodyCaptureAllowed && last.transportMetadata !== undefined ? { transport_metadata: captureTraceMetadata(last.transportMetadata, env) } : {}),
548
+ };
549
+ if (attempts.length > 0) {
550
+ try {
551
+ record.attempts = captureAttemptBodies();
552
+ }
553
+ catch { /* attempt capture must never break trace emission */ }
554
+ }
555
+ // OPT-IN body capture: only when explicitly enabled. Redacted + size-capped. This is the ONLY place
556
+ // prompt/completion content can enter a record, and only behind the flag — capture must never throw.
557
+ if (captureBodies && bodyCaptureAllowed) {
558
+ try {
559
+ const reqCap = captureBody(traceRequestBody, env);
560
+ const respCap = last.responseBody !== undefined ? captureBody(last.responseBody, env) : undefined;
561
+ if (reqCap)
562
+ record.requestBody = reqCap.body;
563
+ if (respCap)
564
+ record.responseBody = respCap.body;
565
+ record.redaction = REDACTION_VERSION;
566
+ if (reqCap?.truncated
567
+ || respCap?.truncated
568
+ || responseBodyIndicatesTruncation(last.responseBody)
569
+ || record.attempts?.some((attempt) => attempt.body_truncated === true))
570
+ record.body_truncated = true;
571
+ }
572
+ catch { /* capture must never break trace emission */ }
573
+ }
574
+ else if (record.attempts?.some((attempt) => attempt.body_truncated === true)) {
575
+ record.body_truncated = true;
576
+ }
577
+ try {
578
+ opts.onTrace(record);
579
+ }
580
+ catch { /* sink errors never break serving */ }
581
+ };
582
+ // Relay a streaming upstream; with a trace sink attached, tee the bytes through a passive SSE scanner and
583
+ // emit the turn record when the stream finishes (or the client cancels).
584
+ const relayStream = (up) => {
585
+ if (opts.onTrace) {
586
+ const scanner = new SseUsageScanner({ captureContent: captureBodies, env });
587
+ const teed = teeStreamForScan(up.stream, (c) => scanner.push(c), (info) => {
588
+ scanner.finish();
589
+ const streamError = scanner.result.error ?? info?.error ?? (info?.cancelled ? 'stream cancelled' : undefined);
590
+ const responseBody = capturedStreamResponseBody(scanner);
591
+ const finalAttempt = attempts.find((attempt) => attempt.attempt_index === 1 && attempt.responseBody === undefined);
592
+ if (finalAttempt && responseBody !== undefined) {
593
+ finalAttempt.responseBody = responseBody;
594
+ finalAttempt.body_truncated = responseBodyIndicatesTruncation(responseBody);
595
+ }
596
+ trace({
597
+ status: up.status,
598
+ stream: true,
599
+ ...(scanner.result.usage ? { usage: scanner.result.usage } : {}),
600
+ ...(scanner.result.stop_reason !== undefined ? { stop_reason: scanner.result.stop_reason } : {}),
601
+ ...(scanner.result.response_id ? { response_id: scanner.result.response_id } : {}),
602
+ ...(streamError ? { error: streamError } : {}),
603
+ // Streamed completion is reconstructed from parsed SSE events only when body capture is on.
604
+ ...(responseBody !== undefined ? { responseBody } : {}),
605
+ ...(up.headers ? { responseHeaders: up.headers } : {}),
606
+ ...(up.transportMetadata !== undefined ? { transportMetadata: up.transportMetadata } : {}),
607
+ });
608
+ });
609
+ return streamResponse({ ...up, stream: teed });
610
+ }
611
+ return streamResponse(up);
612
+ };
613
+ try {
614
+ if (route === '/v1/messages' && upstreamMode !== 'bedrock') {
615
+ const hasInboundKey = !!inboundHeaders['x-api-key'];
616
+ const hasProxyEnvCreds = hasAnthropicProxyCredentials(env);
617
+ if (!hasInboundKey && !hasProxyEnvCreds)
618
+ throw Object.assign(new Error('x-api-key required'), { statusCode: 401 });
619
+ }
620
+ else if (route !== '/v1/messages') {
621
+ const hasOpenAiCreds = hasOpenAIProxyCredentials(env);
622
+ if (!hasOpenAiCreds)
623
+ throw Object.assign(new Error('OpenAI upstream API key required'), { statusCode: 401 });
624
+ }
625
+ if (enabled && routeAllowsRouting) {
626
+ try {
627
+ const decision = pickForTurn({
628
+ features: extractFeatures(body),
629
+ router_state: { history: [], pinned: null },
630
+ config: { default_tier: 'mid', disable: false, hard_pin_after_plan: false },
631
+ });
632
+ decisionTier = decision.tier;
633
+ decisionReason = decision.reason;
634
+ const tierModel = resolveTierModels(env)[decision.tier];
635
+ if (tierModel) {
636
+ if (isIntraFamilyDowngrade(tierModel, originalModel)) {
637
+ fallback = 'downgrade_blocked';
638
+ log.warn?.(j({ event: 'router_fallback', reason: 'downgrade_blocked', original_model: originalModel, would_have_been: tierModel }));
639
+ }
640
+ else {
641
+ chosenModel = tierModel;
642
+ }
643
+ }
644
+ }
645
+ catch (err) {
646
+ fallback = 'classifier_error';
647
+ log.warn?.(j({ event: 'router_fallback', reason: 'classifier_error', original_model: originalModel, error: err instanceof Error ? err.message : String(err) }));
648
+ }
649
+ }
650
+ let outboundBody = body;
651
+ // Rewrite when chosenModel differs from what the CLIENT sent (rawInboundModel), so a bedrock short-ID
652
+ // inbound that didn't change tier still gets canonicalized rather than leaking the short ID upstream.
653
+ if (enabled && routeAllowsRouting && typeof chosenModel === 'string' && chosenModel !== rawInboundModel) {
654
+ try {
655
+ outboundBody = rewriteModel(body, chosenModel);
656
+ }
657
+ catch (err) {
658
+ fallback = fallback ?? 'rewrite_error';
659
+ log.warn?.(j({ event: 'router_fallback', reason: 'rewrite_error', original_model: originalModel, would_have_been: chosenModel, error: err instanceof Error ? err.message : String(err) }));
660
+ outboundBody = body;
661
+ chosenModel = originalModel;
662
+ }
663
+ }
664
+ if (enabled && routeAllowsRouting) {
665
+ log.log?.(j({ event: 'router_decision', tier: decisionTier, reason: decisionReason, original_model: originalModel, chosen_model: chosenModel, fallback }));
666
+ }
667
+ traceRequestBody = outboundBody;
668
+ bodyCaptureAllowed = true;
669
+ const upstream = await opts.anthropicProxy(route, outboundBody, { inboundHeaders, upstreamMode });
670
+ if (upstream.traceRequestBody !== undefined)
671
+ traceRequestBody = upstream.traceRequestBody;
672
+ ttfb = clock() - t0;
673
+ if (upstream.stream)
674
+ return relayStream(upstream);
675
+ // 5xx on a router-rewritten request → retry once with the client's original model (a gateway may have no
676
+ // channel for the tier-target model; a successful slightly-pricier response beats a hard 503).
677
+ let finalUpstream = upstream;
678
+ if (enabled && routeAllowsRouting && upstream.status >= 500 && typeof chosenModel === 'string' && chosenModel !== originalModel) {
679
+ const ctx = { original_model: originalModel, would_have_been: chosenModel };
680
+ log.warn?.(j({ event: 'router_fallback', reason: 'upstream_5xx_retry', ...ctx, upstream_status: upstream.status }));
681
+ const drainedFirst = await drain(upstream.text, log, ctx); // release the socket before retrying
682
+ const firstResponseBody = parseUpstreamBody(drainedFirst, upstream.status);
683
+ const firstRequestBody = upstream.traceRequestBody !== undefined ? upstream.traceRequestBody : outboundBody;
684
+ let retryBody = body;
685
+ attempts.push({
686
+ attempt_index: 0,
687
+ model: chosenModel,
688
+ provider: providerForTrace(route, upstreamMode, env),
689
+ upstream_mode: upstreamMode,
690
+ status: upstream.status,
691
+ error: safeTraceError(`upstream ${upstream.status}`),
692
+ requestBody: firstRequestBody,
693
+ responseBody: firstResponseBody,
694
+ });
695
+ try {
696
+ retryBody = rewriteModel(body, String(originalModel));
697
+ finalUpstream = await opts.anthropicProxy(route, retryBody, { inboundHeaders, upstreamMode });
698
+ traceRequestBody = finalUpstream.traceRequestBody !== undefined ? finalUpstream.traceRequestBody : retryBody;
699
+ attempts.push({
700
+ attempt_index: 1,
701
+ model: typeof originalModel === 'string' ? originalModel : null,
702
+ provider: providerForTrace(route, upstreamMode, env),
703
+ upstream_mode: upstreamMode,
704
+ status: finalUpstream.status,
705
+ requestBody: traceRequestBody,
706
+ });
707
+ }
708
+ catch (err) {
709
+ attempts.push({
710
+ attempt_index: 1,
711
+ model: typeof originalModel === 'string' ? originalModel : null,
712
+ provider: providerForTrace(route, upstreamMode, env),
713
+ upstream_mode: upstreamMode,
714
+ status: 502,
715
+ error: safeTraceError(err instanceof Error ? err.message : String(err)),
716
+ requestBody: retryBody,
717
+ });
718
+ finalUpstream = { status: upstream.status, headers: upstream.headers, stream: null, text: () => drainedFirst };
719
+ log.warn?.(j({ event: 'router_fallback', reason: 'upstream_5xx_retry_failed', ...ctx, error: err instanceof Error ? err.message : String(err) }));
720
+ }
721
+ }
722
+ if (finalUpstream.stream)
723
+ return relayStream(finalUpstream);
724
+ // Upstream is normally JSON, but a misconfigured gateway/CDN/LB can return text/HTML. Read once, parse
725
+ // ourselves, and on failure wrap the raw text in an {error} envelope so the client sees the real status.
726
+ let raw = '';
727
+ if (finalUpstream.text) {
728
+ try {
729
+ raw = await Promise.resolve(finalUpstream.text());
730
+ }
731
+ catch { /* ignore */ }
732
+ }
733
+ const respBody = parseUpstreamBody(raw, finalUpstream.status);
734
+ const finalAttempt = attempts.find((attempt) => attempt.attempt_index === 1 && attempt.responseBody === undefined);
735
+ if (finalAttempt)
736
+ finalAttempt.responseBody = respBody;
737
+ trace({
738
+ status: finalUpstream.status,
739
+ stream: false,
740
+ ...extractResponseMeta(respBody),
741
+ responseBody: respBody,
742
+ ...(finalUpstream.headers ? { responseHeaders: finalUpstream.headers } : {}),
743
+ ...(finalUpstream.transportMetadata !== undefined ? { transportMetadata: finalUpstream.transportMetadata } : {}),
744
+ });
745
+ return { status: finalUpstream.status, body: respBody };
746
+ }
747
+ catch (err) {
748
+ const sc = err.statusCode;
749
+ trace({ status: typeof sc === 'number' ? sc : null, stream: false, error: err instanceof Error ? err.message : String(err) });
750
+ throw err;
751
+ }
752
+ };
753
+ }