@evomap/evolver 1.89.20 → 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.
- package/README.md +27 -564
- package/bin/evolver-llm-proxy.js +3 -0
- package/bin/evolver-mcp.js +2 -0
- package/bin/evolver-proxy.js +3 -0
- package/bin/evolver.js +4 -0
- package/index.js +1 -3586
- package/package.json +32 -62
- package/CONTRIBUTING.md +0 -19
- package/LICENSE +0 -641
- package/README.ja-JP.md +0 -521
- package/README.ko-KR.md +0 -520
- package/README.zh-CN.md +0 -531
- package/SKILL.md +0 -365
- package/assets/cover.png +0 -0
- package/assets/gep/genes.seed.json +0 -496
- package/conformance/savings-core/constants.json +0 -30
- package/conformance/savings-core/golden-vectors.json +0 -333
- package/scripts/a2a_export.js +0 -63
- package/scripts/a2a_ingest.js +0 -79
- package/scripts/a2a_promote.js +0 -118
- package/scripts/analyze_by_skill.js +0 -121
- package/scripts/build_binaries.js +0 -479
- package/scripts/check-changelog.js +0 -166
- package/scripts/extract_log.js +0 -85
- package/scripts/generate_history.js +0 -75
- package/scripts/gep_append_event.js +0 -96
- package/scripts/gep_personality_report.js +0 -234
- package/scripts/human_report.js +0 -147
- package/scripts/recall-verify-report.js +0 -234
- package/scripts/recover_loop.js +0 -61
- package/scripts/refresh_stars_badge.js +0 -168
- package/scripts/seed-merchants.js +0 -91
- package/scripts/skill2recipes.js +0 -118
- package/scripts/suggest_version.js +0 -89
- package/scripts/validate-modules.js +0 -38
- package/scripts/validate-suite.js +0 -78
- package/skills/_meta/SKILL.md +0 -41
- package/skills/index.json +0 -14
- package/src/adapters/claudeCode.js +0 -194
- package/src/adapters/codex.js +0 -216
- package/src/adapters/cursor.js +0 -91
- package/src/adapters/hookAdapter.js +0 -469
- package/src/adapters/kiro.js +0 -195
- package/src/adapters/opencode.js +0 -326
- package/src/adapters/scripts/_lockPaths.js +0 -74
- package/src/adapters/scripts/_memoryFiltering.js +0 -35
- package/src/adapters/scripts/_runtimePaths.js +0 -440
- package/src/adapters/scripts/evolver-session-end.js +0 -321
- package/src/adapters/scripts/evolver-session-start.js +0 -587
- package/src/adapters/scripts/evolver-signal-detect.js +0 -98
- package/src/adapters/scripts/evolver-task-recall.js +0 -173
- package/src/atp/atpExecute.js +0 -283
- package/src/atp/atpTaskPickup.js +0 -233
- package/src/atp/autoBuyer.js +0 -382
- package/src/atp/autoDeliver.js +0 -215
- package/src/atp/cli.js +0 -354
- package/src/atp/cliAutobuyPrompt.js +0 -154
- package/src/atp/consumerAgent.js +0 -157
- package/src/atp/defaultHandler.js +0 -69
- package/src/atp/heartbeatSignalsHandler.js +0 -254
- package/src/atp/hubClient.js +0 -317
- package/src/atp/index.js +0 -38
- package/src/atp/merchantAgent.js +0 -118
- package/src/atp/protocol.js +0 -41
- package/src/atp/questionComposer.js +0 -133
- package/src/atp/serviceHelper.js +0 -92
- package/src/canary.js +0 -13
- package/src/config.js +0 -351
- package/src/evolve/guards.js +0 -1
- package/src/evolve/pipeline/collect.js +0 -1
- package/src/evolve/pipeline/dispatch.js +0 -1
- package/src/evolve/pipeline/enrich.js +0 -1
- package/src/evolve/pipeline/hub.js +0 -1
- package/src/evolve/pipeline/select.js +0 -1
- package/src/evolve/pipeline/signals.js +0 -1
- package/src/evolve/utils.js +0 -1
- package/src/evolve.js +0 -1
- package/src/experiment/agentRunner.js +0 -229
- package/src/experiment/cli.js +0 -159
- package/src/experiment/comparison.js +0 -233
- package/src/experiment/metrics.js +0 -75
- package/src/forceUpdate.js +0 -991
- package/src/gep/a2a.js +0 -173
- package/src/gep/a2aProtocol.js +0 -1
- package/src/gep/analyzer.js +0 -35
- package/src/gep/antiAbuseTelemetry.js +0 -1
- package/src/gep/assetCallLog.js +0 -197
- package/src/gep/assetStore.js +0 -723
- package/src/gep/assets.js +0 -36
- package/src/gep/autoDistillConv.js +0 -1
- package/src/gep/autoDistillLlm.js +0 -1
- package/src/gep/bridge.js +0 -138
- package/src/gep/candidateEval.js +0 -1
- package/src/gep/candidates.js +0 -1
- package/src/gep/claimNudge.js +0 -121
- package/src/gep/cliContracts.js +0 -1190
- package/src/gep/contentHash.js +0 -1
- package/src/gep/conversationDistiller.js +0 -1
- package/src/gep/conversationSniffer.js +0 -1
- package/src/gep/crypto.js +0 -1
- package/src/gep/curriculum.js +0 -1
- package/src/gep/deviceId.js +0 -1
- package/src/gep/directoryClient.js +0 -115
- package/src/gep/envFingerprint.js +0 -1
- package/src/gep/epigenetics.js +0 -1
- package/src/gep/execBridge.js +0 -1
- package/src/gep/executionTrace.js +0 -291
- package/src/gep/explore.js +0 -1
- package/src/gep/featureFlags.js +0 -121
- package/src/gep/gitOps.js +0 -265
- package/src/gep/hash.js +0 -1
- package/src/gep/hostErrorClassifier.js +0 -34
- package/src/gep/hubFetch.js +0 -1
- package/src/gep/hubReview.js +0 -1
- package/src/gep/hubSearch.js +0 -1
- package/src/gep/hubVerify.js +0 -1
- package/src/gep/idleScheduler.js +0 -400
- package/src/gep/issueReporter.js +0 -416
- package/src/gep/learningSignals.js +0 -1
- package/src/gep/llmReview.js +0 -92
- package/src/gep/localStateAwareness.js +0 -243
- package/src/gep/mailboxTransport.js +0 -119
- package/src/gep/memoryGraph.js +0 -1
- package/src/gep/memoryGraphAdapter.js +0 -1
- package/src/gep/mutation.js +0 -1
- package/src/gep/narrativeMemory.js +0 -1
- package/src/gep/oauthLogin.js +0 -181
- package/src/gep/openPRRegistry.js +0 -1
- package/src/gep/paths.js +0 -522
- package/src/gep/personality.js +0 -1
- package/src/gep/policyCheck.js +0 -1
- package/src/gep/portable.js +0 -103
- package/src/gep/privacyClient.js +0 -235
- package/src/gep/prompt.js +0 -1
- package/src/gep/questionGenerator.js +0 -518
- package/src/gep/recallInject.js +0 -1
- package/src/gep/recallVerifier.js +0 -1
- package/src/gep/reflection.js +0 -1
- package/src/gep/sanitize.js +0 -264
- package/src/gep/savingsCore.js +0 -1
- package/src/gep/schemas/capsule.js +0 -170
- package/src/gep/schemas/gene.js +0 -154
- package/src/gep/schemas/index.js +0 -8
- package/src/gep/schemas/protocol.js +0 -51
- package/src/gep/schemas/task.js +0 -74
- package/src/gep/selector.js +0 -1
- package/src/gep/selfPR.js +0 -469
- package/src/gep/signals.js +0 -776
- package/src/gep/skill2gep.js +0 -1056
- package/src/gep/skill2gepAudit.js +0 -303
- package/src/gep/skill2recipes.js +0 -511
- package/src/gep/skillDistiller.js +0 -1
- package/src/gep/skillPublisher.js +0 -358
- package/src/gep/solidify.js +0 -1
- package/src/gep/strategy.js +0 -1
- package/src/gep/taskReceiver.js +0 -575
- package/src/gep/tokenSavings.js +0 -1
- package/src/gep/trajectoryExport.js +0 -1
- package/src/gep/validationReport.js +0 -55
- package/src/gep/validator/index.js +0 -411
- package/src/gep/validator/reporter.js +0 -210
- package/src/gep/validator/sandboxExecutor.js +0 -480
- package/src/gep/validator/stakeBootstrap.js +0 -357
- package/src/gep/workspaceKeychain.js +0 -1
- package/src/ops/cleanup.js +0 -80
- package/src/ops/commentary.js +0 -60
- package/src/ops/health_check.js +0 -104
- package/src/ops/index.js +0 -11
- package/src/ops/innovation.js +0 -67
- package/src/ops/lifecycle.js +0 -798
- package/src/ops/self_repair.js +0 -76
- package/src/ops/skills_monitor.js +0 -147
- package/src/ops/trigger.js +0 -33
- package/src/proxy/clientSettings.js +0 -405
- package/src/proxy/envelope.js +0 -59
- package/src/proxy/extensions/dmHandler.js +0 -45
- package/src/proxy/extensions/sessionHandler.js +0 -141
- package/src/proxy/extensions/skillUpdater.js +0 -64
- package/src/proxy/extensions/traceControl.js +0 -1
- package/src/proxy/index.js +0 -1395
- package/src/proxy/inject.js +0 -1
- package/src/proxy/lifecycle/manager.js +0 -1568
- package/src/proxy/mailbox/state.js +0 -207
- package/src/proxy/mailbox/store.js +0 -590
- package/src/proxy/router/cache_passthrough.js +0 -26
- package/src/proxy/router/features.js +0 -84
- package/src/proxy/router/gemini_route.js +0 -154
- package/src/proxy/router/messages_route.js +0 -535
- package/src/proxy/router/model_router.js +0 -113
- package/src/proxy/router/models_route.js +0 -52
- package/src/proxy/router/ollama_route.js +0 -103
- package/src/proxy/router/responses_route.js +0 -170
- package/src/proxy/router/vertex_route.js +0 -110
- package/src/proxy/server/http.js +0 -363
- package/src/proxy/server/routes.js +0 -558
- package/src/proxy/server/settings.js +0 -115
- package/src/proxy/sync/engine.js +0 -179
- package/src/proxy/sync/inbound.js +0 -211
- package/src/proxy/sync/outbound.js +0 -320
- package/src/proxy/task/monitor.js +0 -131
- package/src/proxy/trace/extractor.js +0 -1
- package/src/proxy/trace/usage.js +0 -1
- package/src/webui/client/clientJs/assets.js +0 -111
- package/src/webui/client/clientJs/bootstrap.js +0 -92
- package/src/webui/client/clientJs/common.js +0 -77
- package/src/webui/client/clientJs/i18n.js +0 -366
- package/src/webui/client/clientJs/index.js +0 -35
- package/src/webui/client/clientJs/interactions.js +0 -351
- package/src/webui/client/clientJs/overview.js +0 -152
- package/src/webui/client/clientJs/personality.js +0 -285
- package/src/webui/client/clientJs/pipelines.js +0 -330
- package/src/webui/client/indexHtml.js +0 -221
- package/src/webui/client/static.js +0 -23
- package/src/webui/client/stylesCss.js +0 -639
- package/src/webui/client/vendor/README.md +0 -15
- package/src/webui/client/vendor/echarts.min.js +0 -45
- package/src/webui/index.js +0 -14
- package/src/webui/observer/assets.js +0 -146
- package/src/webui/observer/index.js +0 -37
- package/src/webui/observer/interactions.js +0 -127
- package/src/webui/observer/jsonl.js +0 -75
- package/src/webui/observer/paths.js +0 -46
- package/src/webui/observer/personality.js +0 -43
- package/src/webui/observer/pipelineEvents.js +0 -58
- package/src/webui/observer/redact.js +0 -63
- package/src/webui/observer/runs.js +0 -356
- package/src/webui/observer/safety.js +0 -57
- package/src/webui/observer/skills.js +0 -70
- package/src/webui/observer/status.js +0 -71
- package/src/webui/server/http.js +0 -138
- package/src/webui/server/routes.js +0 -41
|
@@ -1,154 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
// Gemini passthrough handler (format-aware routing, NO translation). A Gemini-shaped request — Google's native
|
|
4
|
-
// `/v1beta/models/<model>:generateContent` | `:streamGenerateContent` path, body `{contents, generationConfig,
|
|
5
|
-
// systemInstruction, tools}` — is forwarded verbatim to the Gemini upstream. The model + action live in the
|
|
6
|
-
// PATH (not the body), so we reconstruct the path (+ query like ?alt=sse) and pass it through. Trace capture
|
|
7
|
-
// mirrors the other providers (usage/finish/stream tee). Point the Gemini CLI/SDK's base URL at the proxy and
|
|
8
|
-
// it works unmodified — no Anthropic/OpenAI conversion (lossy translation is deliberately avoided).
|
|
9
|
-
|
|
10
|
-
const { createProxyTrace } = require('../trace/extractor');
|
|
11
|
-
|
|
12
|
-
const GEMINI_RESPONSE_HEADER_ALLOWLIST = new Set([
|
|
13
|
-
'content-type',
|
|
14
|
-
'retry-after',
|
|
15
|
-
'x-request-id',
|
|
16
|
-
]);
|
|
17
|
-
|
|
18
|
-
function hasGeminiUpstreamCredential() {
|
|
19
|
-
return !!(process.env.EVOMAP_GEMINI_API_KEY || process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function upstreamStatus(err, fallback = 502) {
|
|
23
|
-
const status = Number(err && err.statusCode);
|
|
24
|
-
return Number.isFinite(status) ? status : fallback;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function asUpstreamError(err, fallback = 502) {
|
|
28
|
-
if (err && err.statusCode && /^gemini upstream /.test(err.message || '')) return err;
|
|
29
|
-
const out = new Error('gemini upstream request failed');
|
|
30
|
-
out.statusCode = upstreamStatus(err, fallback);
|
|
31
|
-
out.cause = err;
|
|
32
|
-
return out;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function responseToBody(raw, status, headers, log) {
|
|
36
|
-
if (!raw) return {};
|
|
37
|
-
try {
|
|
38
|
-
return JSON.parse(raw);
|
|
39
|
-
} catch {
|
|
40
|
-
log.warn?.(JSON.stringify({
|
|
41
|
-
event: 'gemini_fallback',
|
|
42
|
-
reason: 'upstream_non_json',
|
|
43
|
-
upstream_status: status,
|
|
44
|
-
content_type: (headers && headers['content-type']) || '',
|
|
45
|
-
response_bytes: Buffer.byteLength(raw),
|
|
46
|
-
}));
|
|
47
|
-
return { error: raw };
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function copyGeminiResponseHeaders(headers = {}) {
|
|
52
|
-
const out = {};
|
|
53
|
-
for (const [name, value] of Object.entries(headers || {})) {
|
|
54
|
-
const lower = String(name || '').toLowerCase();
|
|
55
|
-
if (!GEMINI_RESPONSE_HEADER_ALLOWLIST.has(lower) && !lower.startsWith('x-goog-')) continue;
|
|
56
|
-
if (value === undefined || value === null) continue;
|
|
57
|
-
const headerValue = Array.isArray(value) ? value.join(', ') : String(value);
|
|
58
|
-
if (/[\r\n]/.test(headerValue)) continue;
|
|
59
|
-
out[lower] = headerValue;
|
|
60
|
-
}
|
|
61
|
-
return out;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// `<model>:<action>` — the model can contain dots/dashes; the action is the part after the LAST colon
|
|
65
|
-
// (generateContent | streamGenerateContent | countTokens | ...). Returns {model, action} (action '' if absent).
|
|
66
|
-
function parseModelAction(modelAction) {
|
|
67
|
-
const s = String(modelAction || '');
|
|
68
|
-
const idx = s.lastIndexOf(':');
|
|
69
|
-
if (idx === -1) return { model: s, action: '' };
|
|
70
|
-
return { model: s.slice(0, idx), action: s.slice(idx + 1) };
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function buildGeminiHandler({ geminiProxy, logger, traceStore, onTraceQueued } = {}) {
|
|
74
|
-
if (typeof geminiProxy !== 'function') {
|
|
75
|
-
throw new Error('buildGeminiHandler requires geminiProxy(path, body, opts)');
|
|
76
|
-
}
|
|
77
|
-
const log = logger || console;
|
|
78
|
-
|
|
79
|
-
return async ({ body, headers, params, query }) => {
|
|
80
|
-
const inboundHeaders = headers || {};
|
|
81
|
-
if (!hasGeminiUpstreamCredential()) {
|
|
82
|
-
throw Object.assign(new Error('gemini api key required'), { statusCode: 401 });
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const modelAction = (params && params.modelAction) || '';
|
|
86
|
-
const { model, action } = parseModelAction(modelAction);
|
|
87
|
-
// Reconstruct the native Gemini path + query (e.g. ?alt=sse for streaming) and forward verbatim.
|
|
88
|
-
const qs = query && Object.keys(query).length ? '?' + new URLSearchParams(query).toString() : '';
|
|
89
|
-
const reqPath = `/v1beta/models/${modelAction}${qs}`;
|
|
90
|
-
|
|
91
|
-
let trace = null;
|
|
92
|
-
try {
|
|
93
|
-
trace = createProxyTrace({
|
|
94
|
-
route: `POST /v1beta/models/${modelAction}`,
|
|
95
|
-
headers: inboundHeaders,
|
|
96
|
-
body,
|
|
97
|
-
upstreamMode: 'gemini',
|
|
98
|
-
originalModel: model,
|
|
99
|
-
chosenModel: model,
|
|
100
|
-
store: traceStore,
|
|
101
|
-
logger: traceStore ? log : null,
|
|
102
|
-
onTraceQueued,
|
|
103
|
-
});
|
|
104
|
-
} catch (_) { /* best-effort trace; never break the request */ }
|
|
105
|
-
|
|
106
|
-
let upstream;
|
|
107
|
-
try {
|
|
108
|
-
upstream = await geminiProxy(reqPath, body, { inboundHeaders, upstreamMode: 'gemini' });
|
|
109
|
-
} catch (err) {
|
|
110
|
-
const wrapped = asUpstreamError(err, upstreamStatus(err));
|
|
111
|
-
trace?.record({ status: wrapped.statusCode, error: wrapped, upstreamMode: 'gemini', model });
|
|
112
|
-
throw wrapped;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
if (upstream.stream) {
|
|
116
|
-
const forwardHeaders = copyGeminiResponseHeaders(upstream.headers);
|
|
117
|
-
const ct = upstream.headers && upstream.headers['content-type'];
|
|
118
|
-
if (ct) forwardHeaders['Content-Type'] = ct;
|
|
119
|
-
trace?.recordStreamStart({ status: upstream.status, upstreamMode: 'gemini', model, headers: forwardHeaders });
|
|
120
|
-
return {
|
|
121
|
-
status: upstream.status,
|
|
122
|
-
// Tee the Gemini SSE body so the deferred trace captures usageMetadata + finishReason. Bytes unchanged.
|
|
123
|
-
stream: trace ? trace.observeStream(upstream.stream) : upstream.stream,
|
|
124
|
-
headers: forwardHeaders,
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
let raw = '';
|
|
129
|
-
if (upstream.text) {
|
|
130
|
-
try {
|
|
131
|
-
raw = await upstream.text();
|
|
132
|
-
} catch (err) {
|
|
133
|
-
const wrapped = asUpstreamError(err, upstreamStatus(err));
|
|
134
|
-
trace?.record({ status: wrapped.statusCode, error: wrapped, upstreamMode: 'gemini', model });
|
|
135
|
-
throw wrapped;
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
const respBody = responseToBody(raw, upstream.status, upstream.headers, log);
|
|
139
|
-
trace?.record({ status: upstream.status, responseBody: respBody, upstreamMode: 'gemini', model, headers: upstream.headers });
|
|
140
|
-
return {
|
|
141
|
-
status: upstream.status,
|
|
142
|
-
body: respBody,
|
|
143
|
-
headers: copyGeminiResponseHeaders(upstream.headers),
|
|
144
|
-
};
|
|
145
|
-
};
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
module.exports = {
|
|
149
|
-
buildGeminiHandler,
|
|
150
|
-
copyGeminiResponseHeaders,
|
|
151
|
-
hasGeminiUpstreamCredential,
|
|
152
|
-
responseToBody,
|
|
153
|
-
parseModelAction,
|
|
154
|
-
};
|
|
@@ -1,535 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
// /v1/messages handler for Phase C. Three pipeline stages wrap _proxyAnthropic:
|
|
4
|
-
// 1. extract stateless features (router/features.js)
|
|
5
|
-
// 2. pickForTurn → tier → concrete model (router/model_router.js + DEFAULT_TIER_MODELS)
|
|
6
|
-
// 3. rewriteModel preserving cache_control breakpoints (router/cache_passthrough.js)
|
|
7
|
-
//
|
|
8
|
-
// Each stage has its own fallback so a single bad input never breaks the
|
|
9
|
-
// passthrough: classifier throw → forward unmodified; rewriter throw →
|
|
10
|
-
// forward unmodified; upstream 5xx on a router-rewritten request → one
|
|
11
|
-
// retry with the client's original model (a one-hub/prism-style gateway
|
|
12
|
-
// may return 503 "no channel" for a tier-target model the upstream isn't
|
|
13
|
-
// configured for; falling back to the original model is more useful than
|
|
14
|
-
// a hard 503). All other non-2xx is relayed verbatim — we don't fabricate
|
|
15
|
-
// SSE error frames. Telemetry-style log lines record which fallback fired
|
|
16
|
-
// so the realized-vs-projected delta is measurable post-merge.
|
|
17
|
-
|
|
18
|
-
const { pickForTurn } = require('./model_router');
|
|
19
|
-
const { rewriteModel } = require('./cache_passthrough');
|
|
20
|
-
const { extractFeatures } = require('./features');
|
|
21
|
-
const { createProxyTrace } = require('../trace/extractor');
|
|
22
|
-
|
|
23
|
-
// Bedrock-resolvable global.* aliases as of 2026-05-25:
|
|
24
|
-
// - opus-4-7 : bare alias OK
|
|
25
|
-
// - haiku-4-5 : ONLY the dated form resolves; bare alias 400s
|
|
26
|
-
// ("ValidationException: invalid model identifier")
|
|
27
|
-
// - sonnet-4-7 : not yet on Bedrock — sonnet-4-6 is the current global.*
|
|
28
|
-
// sonnet alias. The no-downgrade guard still blocks an
|
|
29
|
-
// inbound sonnet-4-7 → sonnet-4-6 rewrite, so callers
|
|
30
|
-
// pinned to 4-7 stay on 4-7.
|
|
31
|
-
const DEFAULT_TIER_MODELS = Object.freeze({
|
|
32
|
-
cheap: 'global.anthropic.claude-opus-4-7',
|
|
33
|
-
mid: 'global.anthropic.claude-opus-4-7',
|
|
34
|
-
expensive: 'global.anthropic.claude-opus-4-7',
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
// No-downgrade guard: when the router rewrites a request to a different model
|
|
38
|
-
// inside the same Claude family (opus / sonnet / haiku), the chosen generation
|
|
39
|
-
// must be >= the original. This catches the 2026-05-25 /compact incident where
|
|
40
|
-
// EVOMAP_MODEL_EXPENSIVE was misconfigured to opus-4-1 while users sent
|
|
41
|
-
// opus-4-7 — every planning turn silently rewrote 4-7 → 4-1, hit Bedrock 5xx
|
|
42
|
-
// on the older endpoint, and stalled the user behind retries. Cross-family
|
|
43
|
-
// rewrites (opus → haiku for cheap tier) are the router's core function and
|
|
44
|
-
// stay allowed; this guard only blocks intra-family generational downgrades.
|
|
45
|
-
//
|
|
46
|
-
// Parsers below handle two ID shapes the proxy actually sees:
|
|
47
|
-
// - global.anthropic.claude-{family}-{major}-{minor}
|
|
48
|
-
// - us.anthropic.claude-{family}-{major}-{minor}-YYYYMMDD-v1:0 (Bedrock dated)
|
|
49
|
-
// Anything else (third-party, opaque alias) → null on both fields → guard
|
|
50
|
-
// returns false (allow). We only block when we can prove the comparison.
|
|
51
|
-
function parseClaudeId(modelId) {
|
|
52
|
-
if (typeof modelId !== 'string') return null;
|
|
53
|
-
const m = modelId.match(/claude-(opus|sonnet|haiku)-(\d+)-(\d+)/i);
|
|
54
|
-
if (!m) return null;
|
|
55
|
-
const major = Number(m[2]);
|
|
56
|
-
const minor = Number(m[3]);
|
|
57
|
-
if (!Number.isFinite(major) || !Number.isFinite(minor)) return null;
|
|
58
|
-
return { family: m[1].toLowerCase(), major, minor };
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function isIntraFamilyDowngrade(chosen, original) {
|
|
62
|
-
const c = parseClaudeId(chosen);
|
|
63
|
-
const o = parseClaudeId(original);
|
|
64
|
-
if (!c || !o) return false;
|
|
65
|
-
if (c.family !== o.family) return false;
|
|
66
|
-
if (c.major !== o.major) return c.major < o.major;
|
|
67
|
-
return c.minor < o.minor;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// Bedrock InvokeModel rejects bare short IDs like `claude-opus-4-7` with
|
|
71
|
-
// ValidationException — it only accepts the explicit ARN-shaped aliases
|
|
72
|
-
// below. CC clients and many SDKs route via short IDs since that's what
|
|
73
|
-
// api.anthropic.com expects, so when upstreamMode === 'bedrock' we
|
|
74
|
-
// canonicalize at the proxy boundary. Unknown / non-Claude IDs pass
|
|
75
|
-
// through untouched (Bedrock owns the rejection in that case).
|
|
76
|
-
//
|
|
77
|
-
// Map keys are `family/major/minor` from parseClaudeId. Add new entries
|
|
78
|
-
// here as Anthropic ships new Bedrock aliases.
|
|
79
|
-
const KNOWN_BEDROCK_ALIASES = Object.freeze({
|
|
80
|
-
'opus/4/7': 'global.anthropic.claude-opus-4-7',
|
|
81
|
-
'haiku/4/5': 'global.anthropic.claude-haiku-4-5-20251001-v1:0',
|
|
82
|
-
'sonnet/4/6': 'global.anthropic.claude-sonnet-4-6',
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
function canonicalizeForBedrock(modelId) {
|
|
86
|
-
const parsed = parseClaudeId(modelId);
|
|
87
|
-
if (!parsed) return modelId;
|
|
88
|
-
const key = `${parsed.family}/${parsed.major}/${parsed.minor}`;
|
|
89
|
-
return KNOWN_BEDROCK_ALIASES[key] || modelId;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function supportsAdaptiveThinking(modelId) {
|
|
93
|
-
const parsed = parseClaudeId(modelId);
|
|
94
|
-
if (!parsed) return false;
|
|
95
|
-
if (parsed.major > 4) return true;
|
|
96
|
-
return parsed.major === 4 && parsed.minor >= 7;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function resolveTierModels() {
|
|
100
|
-
return {
|
|
101
|
-
cheap: process.env.EVOMAP_MODEL_CHEAP || DEFAULT_TIER_MODELS.cheap,
|
|
102
|
-
mid: process.env.EVOMAP_MODEL_MID || DEFAULT_TIER_MODELS.mid,
|
|
103
|
-
expensive: process.env.EVOMAP_MODEL_EXPENSIVE || DEFAULT_TIER_MODELS.expensive,
|
|
104
|
-
};
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function buildMessagesHandler({ anthropicProxy, logger, routerEnabled, traceStore, onTraceQueued } = {}) {
|
|
108
|
-
if (typeof anthropicProxy !== 'function') {
|
|
109
|
-
throw new Error('buildMessagesHandler requires anthropicProxy(path, body, opts)');
|
|
110
|
-
}
|
|
111
|
-
const log = logger || console;
|
|
112
|
-
// Phase C slice 6: flag is read at handler construction (proxy start), not
|
|
113
|
-
// per-request — flipping the env var requires a proxy restart, fine for an
|
|
114
|
-
// MVP feature flag. Explicit boolean override wins so tests stay hermetic.
|
|
115
|
-
const enabled = typeof routerEnabled === 'boolean'
|
|
116
|
-
? routerEnabled
|
|
117
|
-
: process.env.EVOMAP_ROUTER_ENABLED === '1';
|
|
118
|
-
|
|
119
|
-
// Degenerate-tier guard (#152). The shipped DEFAULT_TIER_MODELS pins all
|
|
120
|
-
// three tiers to the same model on purpose: operators tuning tier mapping
|
|
121
|
-
// run tier-uniform so the no-downgrade guard never engages and 5xx retries
|
|
122
|
-
// always replay the same model (PR #135). Per-tier `EVOMAP_MODEL_*` env
|
|
123
|
-
// overrides are how a real deployment opts into cost-saving. The trap is the
|
|
124
|
-
// user who flips `EVOMAP_ROUTER_ENABLED=1` expecting savings (per README)
|
|
125
|
-
// but leaves the overrides unset: every tier resolves to one model, so
|
|
126
|
-
// routing is a silent no-op and — for anyone previously on a cheaper model —
|
|
127
|
-
// a cost *increase*. Emit one loud boot WARN so the degenerate config is
|
|
128
|
-
// visible in logs instead of manifesting as a surprise bill. Resolved at
|
|
129
|
-
// construction (proxy start) to match how `enabled` is read.
|
|
130
|
-
if (enabled) {
|
|
131
|
-
const tiers = resolveTierModels();
|
|
132
|
-
const distinct = new Set([tiers.cheap, tiers.mid, tiers.expensive]);
|
|
133
|
-
if (distinct.size === 1) {
|
|
134
|
-
log.warn?.(JSON.stringify({
|
|
135
|
-
event: 'router_degenerate_tiers',
|
|
136
|
-
message: 'router enabled but all tiers map to the same model — no '
|
|
137
|
-
+ 'cost-saving effect. Set EVOMAP_MODEL_CHEAP / EVOMAP_MODEL_MID / '
|
|
138
|
-
+ 'EVOMAP_MODEL_EXPENSIVE to enable tier-based routing.',
|
|
139
|
-
model: tiers.cheap,
|
|
140
|
-
}));
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
return async ({ body, headers }) => {
|
|
145
|
-
const inboundHeaders = headers || {};
|
|
146
|
-
// x-api-key is satisfied by either the inbound header OR a proxy-side
|
|
147
|
-
// EVOMAP_ANTHROPIC_API_KEY / ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN env
|
|
148
|
-
// (token mediation, see _proxyAnthropic). The proxy server itself has already auth-checked
|
|
149
|
-
// `Authorization: Bearer <proxy_token>` before reaching this handler.
|
|
150
|
-
//
|
|
151
|
-
// Bedrock upstream uses SigV4 with AWS_* env, so neither inbound
|
|
152
|
-
// x-api-key nor ANTHROPIC_* env are meaningful. Skip the check —
|
|
153
|
-
// the real proxy gate is still the Bearer proxy_token enforced
|
|
154
|
-
// upstream of this handler in ProxyHttpServer.
|
|
155
|
-
// Read EVOMAP_UPSTREAM exactly once per request and thread it through to
|
|
156
|
-
// the proxy callable via opts.upstreamMode. Reading it here AND in the
|
|
157
|
-
// dispatch closure would let a mid-request env hot-swap make the two
|
|
158
|
-
// decisions disagree (e.g. gate skipped on the assumption of bedrock,
|
|
159
|
-
// but the request still hits _proxyAnthropic with no credentials).
|
|
160
|
-
const upstreamMode = (process.env.EVOMAP_UPSTREAM || 'anthropic').toLowerCase();
|
|
161
|
-
if (upstreamMode !== 'bedrock') {
|
|
162
|
-
const hasInboundKey = !!inboundHeaders['x-api-key'];
|
|
163
|
-
const hasProxyEnvCreds = !!(
|
|
164
|
-
process.env.EVOMAP_ANTHROPIC_API_KEY
|
|
165
|
-
|| process.env.ANTHROPIC_API_KEY
|
|
166
|
-
|| process.env.EVOMAP_ANTHROPIC_AUTH_TOKEN
|
|
167
|
-
|| (process.env.EVOMAP_PROXY_AUTO_INJECTED === '1' ? '' : process.env.ANTHROPIC_AUTH_TOKEN)
|
|
168
|
-
);
|
|
169
|
-
if (!hasInboundKey && !hasProxyEnvCreds) {
|
|
170
|
-
throw Object.assign(new Error('x-api-key required'), { statusCode: 401 });
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// Phase C ABC fix: in bedrock mode, normalize the inbound model to the
|
|
175
|
-
// Bedrock-resolvable form up front. Client-side IDs like
|
|
176
|
-
// `claude-opus-4-7` are valid on api.anthropic.com but Bedrock's
|
|
177
|
-
// InvokeModel rejects them with ValidationException; the retry path
|
|
178
|
-
// would replay that exact rejected ID and turn an upstream blip into
|
|
179
|
-
// 100% failure. Canonicalizing here makes router decisions, the
|
|
180
|
-
// outbound rewrite, the no-downgrade comparison, and the retry body
|
|
181
|
-
// all see the same Bedrock-OK ID. anthropic mode passes through
|
|
182
|
-
// unchanged so api.anthropic.com keeps accepting short IDs.
|
|
183
|
-
const rawInboundModel = body && typeof body.model === 'string' ? body.model : null;
|
|
184
|
-
const originalModel = upstreamMode === 'bedrock'
|
|
185
|
-
? canonicalizeForBedrock(rawInboundModel)
|
|
186
|
-
: rawInboundModel;
|
|
187
|
-
let chosenModel = originalModel;
|
|
188
|
-
let decisionTier = null;
|
|
189
|
-
let decisionReason = null;
|
|
190
|
-
let fallback = null;
|
|
191
|
-
|
|
192
|
-
if (enabled) {
|
|
193
|
-
try {
|
|
194
|
-
const features = extractFeatures(body);
|
|
195
|
-
const decision = pickForTurn({
|
|
196
|
-
features,
|
|
197
|
-
router_state: { history: [], pinned: null },
|
|
198
|
-
config: { default_tier: 'mid', disable: false, hard_pin_after_plan: false },
|
|
199
|
-
});
|
|
200
|
-
decisionTier = decision.tier;
|
|
201
|
-
decisionReason = decision.reason;
|
|
202
|
-
const tierModel = resolveTierModels()[decision.tier];
|
|
203
|
-
if (tierModel) {
|
|
204
|
-
if (isIntraFamilyDowngrade(tierModel, originalModel)) {
|
|
205
|
-
// Intra-family downgrade detected (e.g. opus-4-7 -> opus-4-1).
|
|
206
|
-
// Refuse the rewrite, keep the user's original model, and log a
|
|
207
|
-
// structured fallback so misconfigured tier env vars are visible
|
|
208
|
-
// in telemetry instead of manifesting as latency / 5xx stalls.
|
|
209
|
-
fallback = 'downgrade_blocked';
|
|
210
|
-
log.warn?.(JSON.stringify({
|
|
211
|
-
event: 'router_fallback',
|
|
212
|
-
reason: 'downgrade_blocked',
|
|
213
|
-
original_model: originalModel,
|
|
214
|
-
would_have_been: tierModel,
|
|
215
|
-
}));
|
|
216
|
-
} else {
|
|
217
|
-
chosenModel = tierModel;
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
} catch (err) {
|
|
221
|
-
fallback = 'classifier_error';
|
|
222
|
-
log.warn?.(JSON.stringify({
|
|
223
|
-
event: 'router_fallback',
|
|
224
|
-
reason: 'classifier_error',
|
|
225
|
-
original_model: originalModel,
|
|
226
|
-
would_have_been: null,
|
|
227
|
-
error: err.message,
|
|
228
|
-
}));
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
let outboundBody = body;
|
|
234
|
-
// Rewrite the outbound body when chosenModel differs from what the
|
|
235
|
-
// CLIENT actually sent (rawInboundModel), not just from the canonical
|
|
236
|
-
// originalModel. Otherwise bedrock-mode short-ID inbounds where the
|
|
237
|
-
// router didn't change tier (chosenModel === canonical(rawInbound))
|
|
238
|
-
// would forward the original body — leaking the short ID to Bedrock.
|
|
239
|
-
if (enabled && chosenModel && chosenModel !== rawInboundModel) {
|
|
240
|
-
try {
|
|
241
|
-
outboundBody = rewriteModel(body, chosenModel);
|
|
242
|
-
} catch (err) {
|
|
243
|
-
fallback = fallback || 'rewrite_error';
|
|
244
|
-
log.warn?.(JSON.stringify({
|
|
245
|
-
event: 'router_fallback',
|
|
246
|
-
reason: 'rewrite_error',
|
|
247
|
-
original_model: originalModel,
|
|
248
|
-
would_have_been: chosenModel,
|
|
249
|
-
error: err.message,
|
|
250
|
-
}));
|
|
251
|
-
outboundBody = body;
|
|
252
|
-
chosenModel = originalModel;
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
if (enabled) {
|
|
257
|
-
log.log?.(JSON.stringify({
|
|
258
|
-
event: 'router_decision',
|
|
259
|
-
tier: decisionTier,
|
|
260
|
-
reason: decisionReason,
|
|
261
|
-
original_model: originalModel,
|
|
262
|
-
chosen_model: chosenModel,
|
|
263
|
-
escalation_skipped: false,
|
|
264
|
-
fallback,
|
|
265
|
-
}));
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
// Trace extraction is best-effort observability — it must never break the
|
|
269
|
-
// request. A throwing body accessor (e.g. the simulated classifier failure
|
|
270
|
-
// in routerFeatureFlag) would otherwise propagate out of createProxyTrace /
|
|
271
|
-
// extractCWD and 500 the call. Downstream uses `trace?.`, so null is safe.
|
|
272
|
-
let trace = null;
|
|
273
|
-
try {
|
|
274
|
-
trace = createProxyTrace({
|
|
275
|
-
route: 'POST /v1/messages',
|
|
276
|
-
headers: inboundHeaders,
|
|
277
|
-
body: outboundBody,
|
|
278
|
-
upstreamMode,
|
|
279
|
-
originalModel,
|
|
280
|
-
chosenModel,
|
|
281
|
-
store: traceStore,
|
|
282
|
-
logger: traceStore ? log : null,
|
|
283
|
-
onTraceQueued,
|
|
284
|
-
});
|
|
285
|
-
} catch (_) { /* best-effort trace; never break the request */ }
|
|
286
|
-
|
|
287
|
-
let upstream;
|
|
288
|
-
try {
|
|
289
|
-
upstream = await anthropicProxy('/v1/messages', outboundBody, {
|
|
290
|
-
inboundHeaders,
|
|
291
|
-
upstreamMode,
|
|
292
|
-
});
|
|
293
|
-
if (upstream.traceRequestBody !== undefined) trace?.setRequestBody(upstream.traceRequestBody);
|
|
294
|
-
} catch (err) {
|
|
295
|
-
trace?.record({ status: 502, error: err, upstreamMode, model: chosenModel });
|
|
296
|
-
throw err;
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
const recordStreamTrace = (result) => {
|
|
300
|
-
trace?.recordStreamStart({ status: result.status, upstreamMode, model: chosenModel, headers: result.headers });
|
|
301
|
-
// Tee the SSE body so the trace captures end-of-stream usage/finish/response-id. Bytes forward unchanged;
|
|
302
|
-
// observeStream emits the deferred row once the stream ends/cancels/errors.
|
|
303
|
-
if (trace && result.stream) result.stream = trace.observeStream(result.stream);
|
|
304
|
-
return result;
|
|
305
|
-
};
|
|
306
|
-
|
|
307
|
-
if (upstream.stream) {
|
|
308
|
-
const forwardHeaders = {};
|
|
309
|
-
const ct = upstream.headers && upstream.headers['content-type'];
|
|
310
|
-
if (ct) forwardHeaders['Content-Type'] = ct;
|
|
311
|
-
return recordStreamTrace({
|
|
312
|
-
status: upstream.status,
|
|
313
|
-
stream: upstream.stream,
|
|
314
|
-
headers: forwardHeaders,
|
|
315
|
-
});
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
// First upstream returned non-stream. If it's a 5xx on a router-rewritten
|
|
319
|
-
// request, retry once with the client's original model. This covers the
|
|
320
|
-
// common one-hub/prism case where the chosen tier model has no channel
|
|
321
|
-
// configured — a hard 503 is worse for the caller than a slightly more
|
|
322
|
-
// expensive successful response. The retry may come back streaming when
|
|
323
|
-
// the client originally sent stream:true (the first attempt errored out
|
|
324
|
-
// as JSON before any SSE flowed, so streaming the second attempt is
|
|
325
|
-
// still safe). The result-shape branch below handles both cases.
|
|
326
|
-
let finalUpstream = upstream;
|
|
327
|
-
let finalModel = chosenModel;
|
|
328
|
-
if (
|
|
329
|
-
enabled
|
|
330
|
-
&& upstream.status >= 500
|
|
331
|
-
&& chosenModel
|
|
332
|
-
&& chosenModel !== originalModel
|
|
333
|
-
) {
|
|
334
|
-
log.warn?.(JSON.stringify({
|
|
335
|
-
event: 'router_fallback',
|
|
336
|
-
reason: 'upstream_5xx_retry',
|
|
337
|
-
original_model: originalModel,
|
|
338
|
-
would_have_been: chosenModel,
|
|
339
|
-
upstream_status: upstream.status,
|
|
340
|
-
}));
|
|
341
|
-
// Drain the first upstream's body before retrying. fetch Response
|
|
342
|
-
// bodies are single-read streams; if the retry succeeds, finalUpstream
|
|
343
|
-
// moves to the retry response and the original `upstream` body is
|
|
344
|
-
// never consumed, so undici keeps the underlying TCP socket pinned
|
|
345
|
-
// in the awaiting-body state. Under sustained 5xx storms from the
|
|
346
|
-
// rewritten model (the exact scenario this branch targets), every
|
|
347
|
-
// successful retry leaks one socket out of the pool.
|
|
348
|
-
//
|
|
349
|
-
// We don't need the parsed body — text() reads the full body before
|
|
350
|
-
// returning, which is enough to release the socket. If the retry
|
|
351
|
-
// throws, finalUpstream stays pointing at the (now-drained) upstream
|
|
352
|
-
// and the .text() at line 195 short-circuits on the empty Response
|
|
353
|
-
// — but that loses the original 503 body, so cache it here too and
|
|
354
|
-
// restore it on the throw path.
|
|
355
|
-
let drainedFirst = '';
|
|
356
|
-
let firstAttemptResponseBody = {};
|
|
357
|
-
if (upstream.text) {
|
|
358
|
-
// Bound response-body drain to 10s to prevent hanging on large or
|
|
359
|
-
// slow-streaming error responses. If the drain times out, log it
|
|
360
|
-
// but continue — the original 5xx is still cached and will be
|
|
361
|
-
// returned to the caller if the retry throws.
|
|
362
|
-
//
|
|
363
|
-
// Clear the timer when text() resolves first, otherwise the
|
|
364
|
-
// setTimeout sits in the event loop for 10s holding a closure
|
|
365
|
-
// reference. Under sustained 5xx storms (the exact scenario this
|
|
366
|
-
// branch targets) one such timer per retry would accumulate.
|
|
367
|
-
let drainTimer;
|
|
368
|
-
try {
|
|
369
|
-
drainedFirst = await Promise.race([
|
|
370
|
-
upstream.text(),
|
|
371
|
-
new Promise((_, reject) => {
|
|
372
|
-
drainTimer = setTimeout(
|
|
373
|
-
() => reject(new Error('response drain timeout')),
|
|
374
|
-
10_000,
|
|
375
|
-
);
|
|
376
|
-
}),
|
|
377
|
-
]);
|
|
378
|
-
} catch (e) {
|
|
379
|
-
// socket already gone, timeout, or parse error. Log drain errors
|
|
380
|
-
// and continue with empty body — the retry response will carry the
|
|
381
|
-
// actual error to the caller.
|
|
382
|
-
if (e?.message?.includes('timeout')) {
|
|
383
|
-
log.warn?.(JSON.stringify({
|
|
384
|
-
event: 'router_fallback',
|
|
385
|
-
reason: 'upstream_5xx_drain_timeout',
|
|
386
|
-
original_model: originalModel,
|
|
387
|
-
would_have_been: chosenModel,
|
|
388
|
-
}));
|
|
389
|
-
}
|
|
390
|
-
} finally {
|
|
391
|
-
if (drainTimer) clearTimeout(drainTimer);
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
if (drainedFirst.length > 0) {
|
|
395
|
-
try {
|
|
396
|
-
firstAttemptResponseBody = JSON.parse(drainedFirst);
|
|
397
|
-
} catch {
|
|
398
|
-
firstAttemptResponseBody = { error: drainedFirst };
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
trace?.recordAttempt({
|
|
402
|
-
attempt_index: 0,
|
|
403
|
-
status: upstream.status,
|
|
404
|
-
requestBody: upstream.traceRequestBody !== undefined ? upstream.traceRequestBody : outboundBody,
|
|
405
|
-
responseBody: firstAttemptResponseBody,
|
|
406
|
-
upstreamMode,
|
|
407
|
-
model: chosenModel,
|
|
408
|
-
headers: upstream.headers,
|
|
409
|
-
});
|
|
410
|
-
let retryBody = body;
|
|
411
|
-
try {
|
|
412
|
-
retryBody = rewriteModel(body, originalModel);
|
|
413
|
-
finalUpstream = await anthropicProxy('/v1/messages', retryBody, {
|
|
414
|
-
inboundHeaders,
|
|
415
|
-
upstreamMode,
|
|
416
|
-
});
|
|
417
|
-
trace?.setRequestBody(finalUpstream.traceRequestBody !== undefined ? finalUpstream.traceRequestBody : retryBody);
|
|
418
|
-
trace?.recordAttempt({
|
|
419
|
-
attempt_index: 1,
|
|
420
|
-
requestBody: finalUpstream.traceRequestBody !== undefined ? finalUpstream.traceRequestBody : retryBody,
|
|
421
|
-
upstreamMode,
|
|
422
|
-
model: originalModel,
|
|
423
|
-
headers: finalUpstream.headers,
|
|
424
|
-
});
|
|
425
|
-
finalModel = originalModel;
|
|
426
|
-
} catch (err) {
|
|
427
|
-
trace?.recordAttempt({
|
|
428
|
-
attempt_index: 1,
|
|
429
|
-
status: 502,
|
|
430
|
-
requestBody: retryBody,
|
|
431
|
-
error: err,
|
|
432
|
-
upstreamMode,
|
|
433
|
-
model: originalModel,
|
|
434
|
-
});
|
|
435
|
-
// Replay the drained first response so the caller still sees the
|
|
436
|
-
// original 503 + body, not an empty stream.
|
|
437
|
-
finalUpstream = {
|
|
438
|
-
status: upstream.status,
|
|
439
|
-
headers: upstream.headers,
|
|
440
|
-
stream: null,
|
|
441
|
-
text: () => drainedFirst,
|
|
442
|
-
};
|
|
443
|
-
finalModel = chosenModel;
|
|
444
|
-
log.warn?.(JSON.stringify({
|
|
445
|
-
event: 'router_fallback',
|
|
446
|
-
reason: 'upstream_5xx_retry_failed',
|
|
447
|
-
original_model: originalModel,
|
|
448
|
-
would_have_been: chosenModel,
|
|
449
|
-
error: err.message,
|
|
450
|
-
}));
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
if (finalUpstream.stream) {
|
|
455
|
-
const forwardHeaders = {};
|
|
456
|
-
const ct = finalUpstream.headers && finalUpstream.headers['content-type'];
|
|
457
|
-
if (ct) forwardHeaders['Content-Type'] = ct;
|
|
458
|
-
trace?.recordStreamStart({
|
|
459
|
-
status: finalUpstream.status,
|
|
460
|
-
upstreamMode,
|
|
461
|
-
model: finalModel,
|
|
462
|
-
headers: forwardHeaders,
|
|
463
|
-
attempt_index: finalModel === originalModel && chosenModel !== originalModel ? 1 : undefined,
|
|
464
|
-
});
|
|
465
|
-
return {
|
|
466
|
-
status: finalUpstream.status,
|
|
467
|
-
stream: trace ? trace.observeStream(finalUpstream.stream) : finalUpstream.stream,
|
|
468
|
-
headers: forwardHeaders,
|
|
469
|
-
};
|
|
470
|
-
}
|
|
471
|
-
// Upstream is normally JSON, but a misconfigured local gateway (prism
|
|
472
|
-
// without a route configured), a CDN 4xx page, or a load balancer 502
|
|
473
|
-
// can return text/plain or HTML. Read the body as text (single
|
|
474
|
-
// consumption — fetch Response bodies cannot be read twice) and parse
|
|
475
|
-
// ourselves; on parse failure, wrap the raw text in an {error} envelope
|
|
476
|
-
// so the client gets the real upstream status + a readable body
|
|
477
|
-
// instead of a 500 "Unexpected non-whitespace character".
|
|
478
|
-
// Default to {} not null: src/proxy/server/http.js wraps results as
|
|
479
|
-
// `result.body || result`, and a falsy body would serialize the entire
|
|
480
|
-
// internal {status, body} envelope to the client.
|
|
481
|
-
let respBody = {};
|
|
482
|
-
let raw = '';
|
|
483
|
-
if (finalUpstream.text) {
|
|
484
|
-
try { raw = await finalUpstream.text(); } catch { /* ignore */ }
|
|
485
|
-
}
|
|
486
|
-
if (raw.length > 0) {
|
|
487
|
-
try {
|
|
488
|
-
respBody = JSON.parse(raw);
|
|
489
|
-
} catch {
|
|
490
|
-
respBody = { error: raw };
|
|
491
|
-
log.warn?.(JSON.stringify({
|
|
492
|
-
event: 'router_fallback',
|
|
493
|
-
reason: 'upstream_non_json',
|
|
494
|
-
upstream_status: finalUpstream.status,
|
|
495
|
-
content_type: finalUpstream.headers && finalUpstream.headers['content-type'] || '',
|
|
496
|
-
response_bytes: Buffer.byteLength(raw),
|
|
497
|
-
}));
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
if (
|
|
501
|
-
enabled
|
|
502
|
-
&& chosenModel
|
|
503
|
-
&& chosenModel !== originalModel
|
|
504
|
-
&& finalModel === originalModel
|
|
505
|
-
) {
|
|
506
|
-
trace?.recordAttempt({
|
|
507
|
-
attempt_index: 1,
|
|
508
|
-
status: finalUpstream.status,
|
|
509
|
-
responseBody: respBody,
|
|
510
|
-
upstreamMode,
|
|
511
|
-
model: finalModel,
|
|
512
|
-
headers: finalUpstream.headers,
|
|
513
|
-
});
|
|
514
|
-
}
|
|
515
|
-
trace?.record({
|
|
516
|
-
status: finalUpstream.status,
|
|
517
|
-
responseBody: respBody,
|
|
518
|
-
upstreamMode,
|
|
519
|
-
model: finalModel,
|
|
520
|
-
headers: finalUpstream.headers,
|
|
521
|
-
});
|
|
522
|
-
return { status: finalUpstream.status, body: respBody };
|
|
523
|
-
};
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
module.exports = {
|
|
527
|
-
buildMessagesHandler,
|
|
528
|
-
DEFAULT_TIER_MODELS,
|
|
529
|
-
resolveTierModels,
|
|
530
|
-
parseClaudeId,
|
|
531
|
-
isIntraFamilyDowngrade,
|
|
532
|
-
canonicalizeForBedrock,
|
|
533
|
-
supportsAdaptiveThinking,
|
|
534
|
-
KNOWN_BEDROCK_ALIASES,
|
|
535
|
-
};
|