@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.
Files changed (231) hide show
  1. package/README.md +27 -564
  2. package/bin/evolver-llm-proxy.js +3 -0
  3. package/bin/evolver-mcp.js +2 -0
  4. package/bin/evolver-proxy.js +3 -0
  5. package/bin/evolver.js +4 -0
  6. package/index.js +1 -3586
  7. package/package.json +32 -62
  8. package/CONTRIBUTING.md +0 -19
  9. package/LICENSE +0 -641
  10. package/README.ja-JP.md +0 -521
  11. package/README.ko-KR.md +0 -520
  12. package/README.zh-CN.md +0 -531
  13. package/SKILL.md +0 -365
  14. package/assets/cover.png +0 -0
  15. package/assets/gep/genes.seed.json +0 -496
  16. package/conformance/savings-core/constants.json +0 -30
  17. package/conformance/savings-core/golden-vectors.json +0 -333
  18. package/scripts/a2a_export.js +0 -63
  19. package/scripts/a2a_ingest.js +0 -79
  20. package/scripts/a2a_promote.js +0 -118
  21. package/scripts/analyze_by_skill.js +0 -121
  22. package/scripts/build_binaries.js +0 -479
  23. package/scripts/check-changelog.js +0 -166
  24. package/scripts/extract_log.js +0 -85
  25. package/scripts/generate_history.js +0 -75
  26. package/scripts/gep_append_event.js +0 -96
  27. package/scripts/gep_personality_report.js +0 -234
  28. package/scripts/human_report.js +0 -147
  29. package/scripts/recall-verify-report.js +0 -234
  30. package/scripts/recover_loop.js +0 -61
  31. package/scripts/refresh_stars_badge.js +0 -168
  32. package/scripts/seed-merchants.js +0 -91
  33. package/scripts/skill2recipes.js +0 -118
  34. package/scripts/suggest_version.js +0 -89
  35. package/scripts/validate-modules.js +0 -38
  36. package/scripts/validate-suite.js +0 -78
  37. package/skills/_meta/SKILL.md +0 -41
  38. package/skills/index.json +0 -14
  39. package/src/adapters/claudeCode.js +0 -194
  40. package/src/adapters/codex.js +0 -216
  41. package/src/adapters/cursor.js +0 -91
  42. package/src/adapters/hookAdapter.js +0 -469
  43. package/src/adapters/kiro.js +0 -195
  44. package/src/adapters/opencode.js +0 -326
  45. package/src/adapters/scripts/_lockPaths.js +0 -74
  46. package/src/adapters/scripts/_memoryFiltering.js +0 -35
  47. package/src/adapters/scripts/_runtimePaths.js +0 -440
  48. package/src/adapters/scripts/evolver-session-end.js +0 -321
  49. package/src/adapters/scripts/evolver-session-start.js +0 -587
  50. package/src/adapters/scripts/evolver-signal-detect.js +0 -98
  51. package/src/adapters/scripts/evolver-task-recall.js +0 -173
  52. package/src/atp/atpExecute.js +0 -283
  53. package/src/atp/atpTaskPickup.js +0 -233
  54. package/src/atp/autoBuyer.js +0 -382
  55. package/src/atp/autoDeliver.js +0 -215
  56. package/src/atp/cli.js +0 -354
  57. package/src/atp/cliAutobuyPrompt.js +0 -154
  58. package/src/atp/consumerAgent.js +0 -157
  59. package/src/atp/defaultHandler.js +0 -69
  60. package/src/atp/heartbeatSignalsHandler.js +0 -254
  61. package/src/atp/hubClient.js +0 -317
  62. package/src/atp/index.js +0 -38
  63. package/src/atp/merchantAgent.js +0 -118
  64. package/src/atp/protocol.js +0 -41
  65. package/src/atp/questionComposer.js +0 -133
  66. package/src/atp/serviceHelper.js +0 -92
  67. package/src/canary.js +0 -13
  68. package/src/config.js +0 -351
  69. package/src/evolve/guards.js +0 -1
  70. package/src/evolve/pipeline/collect.js +0 -1
  71. package/src/evolve/pipeline/dispatch.js +0 -1
  72. package/src/evolve/pipeline/enrich.js +0 -1
  73. package/src/evolve/pipeline/hub.js +0 -1
  74. package/src/evolve/pipeline/select.js +0 -1
  75. package/src/evolve/pipeline/signals.js +0 -1
  76. package/src/evolve/utils.js +0 -1
  77. package/src/evolve.js +0 -1
  78. package/src/experiment/agentRunner.js +0 -229
  79. package/src/experiment/cli.js +0 -159
  80. package/src/experiment/comparison.js +0 -233
  81. package/src/experiment/metrics.js +0 -75
  82. package/src/forceUpdate.js +0 -991
  83. package/src/gep/a2a.js +0 -173
  84. package/src/gep/a2aProtocol.js +0 -1
  85. package/src/gep/analyzer.js +0 -35
  86. package/src/gep/antiAbuseTelemetry.js +0 -1
  87. package/src/gep/assetCallLog.js +0 -197
  88. package/src/gep/assetStore.js +0 -723
  89. package/src/gep/assets.js +0 -36
  90. package/src/gep/autoDistillConv.js +0 -1
  91. package/src/gep/autoDistillLlm.js +0 -1
  92. package/src/gep/bridge.js +0 -138
  93. package/src/gep/candidateEval.js +0 -1
  94. package/src/gep/candidates.js +0 -1
  95. package/src/gep/claimNudge.js +0 -121
  96. package/src/gep/cliContracts.js +0 -1190
  97. package/src/gep/contentHash.js +0 -1
  98. package/src/gep/conversationDistiller.js +0 -1
  99. package/src/gep/conversationSniffer.js +0 -1
  100. package/src/gep/crypto.js +0 -1
  101. package/src/gep/curriculum.js +0 -1
  102. package/src/gep/deviceId.js +0 -1
  103. package/src/gep/directoryClient.js +0 -115
  104. package/src/gep/envFingerprint.js +0 -1
  105. package/src/gep/epigenetics.js +0 -1
  106. package/src/gep/execBridge.js +0 -1
  107. package/src/gep/executionTrace.js +0 -291
  108. package/src/gep/explore.js +0 -1
  109. package/src/gep/featureFlags.js +0 -121
  110. package/src/gep/gitOps.js +0 -265
  111. package/src/gep/hash.js +0 -1
  112. package/src/gep/hostErrorClassifier.js +0 -34
  113. package/src/gep/hubFetch.js +0 -1
  114. package/src/gep/hubReview.js +0 -1
  115. package/src/gep/hubSearch.js +0 -1
  116. package/src/gep/hubVerify.js +0 -1
  117. package/src/gep/idleScheduler.js +0 -400
  118. package/src/gep/issueReporter.js +0 -416
  119. package/src/gep/learningSignals.js +0 -1
  120. package/src/gep/llmReview.js +0 -92
  121. package/src/gep/localStateAwareness.js +0 -243
  122. package/src/gep/mailboxTransport.js +0 -119
  123. package/src/gep/memoryGraph.js +0 -1
  124. package/src/gep/memoryGraphAdapter.js +0 -1
  125. package/src/gep/mutation.js +0 -1
  126. package/src/gep/narrativeMemory.js +0 -1
  127. package/src/gep/oauthLogin.js +0 -181
  128. package/src/gep/openPRRegistry.js +0 -1
  129. package/src/gep/paths.js +0 -522
  130. package/src/gep/personality.js +0 -1
  131. package/src/gep/policyCheck.js +0 -1
  132. package/src/gep/portable.js +0 -103
  133. package/src/gep/privacyClient.js +0 -235
  134. package/src/gep/prompt.js +0 -1
  135. package/src/gep/questionGenerator.js +0 -518
  136. package/src/gep/recallInject.js +0 -1
  137. package/src/gep/recallVerifier.js +0 -1
  138. package/src/gep/reflection.js +0 -1
  139. package/src/gep/sanitize.js +0 -264
  140. package/src/gep/savingsCore.js +0 -1
  141. package/src/gep/schemas/capsule.js +0 -170
  142. package/src/gep/schemas/gene.js +0 -154
  143. package/src/gep/schemas/index.js +0 -8
  144. package/src/gep/schemas/protocol.js +0 -51
  145. package/src/gep/schemas/task.js +0 -74
  146. package/src/gep/selector.js +0 -1
  147. package/src/gep/selfPR.js +0 -469
  148. package/src/gep/signals.js +0 -776
  149. package/src/gep/skill2gep.js +0 -1056
  150. package/src/gep/skill2gepAudit.js +0 -303
  151. package/src/gep/skill2recipes.js +0 -511
  152. package/src/gep/skillDistiller.js +0 -1
  153. package/src/gep/skillPublisher.js +0 -358
  154. package/src/gep/solidify.js +0 -1
  155. package/src/gep/strategy.js +0 -1
  156. package/src/gep/taskReceiver.js +0 -575
  157. package/src/gep/tokenSavings.js +0 -1
  158. package/src/gep/trajectoryExport.js +0 -1
  159. package/src/gep/validationReport.js +0 -55
  160. package/src/gep/validator/index.js +0 -411
  161. package/src/gep/validator/reporter.js +0 -210
  162. package/src/gep/validator/sandboxExecutor.js +0 -480
  163. package/src/gep/validator/stakeBootstrap.js +0 -357
  164. package/src/gep/workspaceKeychain.js +0 -1
  165. package/src/ops/cleanup.js +0 -80
  166. package/src/ops/commentary.js +0 -60
  167. package/src/ops/health_check.js +0 -104
  168. package/src/ops/index.js +0 -11
  169. package/src/ops/innovation.js +0 -67
  170. package/src/ops/lifecycle.js +0 -798
  171. package/src/ops/self_repair.js +0 -76
  172. package/src/ops/skills_monitor.js +0 -147
  173. package/src/ops/trigger.js +0 -33
  174. package/src/proxy/clientSettings.js +0 -405
  175. package/src/proxy/envelope.js +0 -59
  176. package/src/proxy/extensions/dmHandler.js +0 -45
  177. package/src/proxy/extensions/sessionHandler.js +0 -141
  178. package/src/proxy/extensions/skillUpdater.js +0 -64
  179. package/src/proxy/extensions/traceControl.js +0 -1
  180. package/src/proxy/index.js +0 -1395
  181. package/src/proxy/inject.js +0 -1
  182. package/src/proxy/lifecycle/manager.js +0 -1568
  183. package/src/proxy/mailbox/state.js +0 -207
  184. package/src/proxy/mailbox/store.js +0 -590
  185. package/src/proxy/router/cache_passthrough.js +0 -26
  186. package/src/proxy/router/features.js +0 -84
  187. package/src/proxy/router/gemini_route.js +0 -154
  188. package/src/proxy/router/messages_route.js +0 -535
  189. package/src/proxy/router/model_router.js +0 -113
  190. package/src/proxy/router/models_route.js +0 -52
  191. package/src/proxy/router/ollama_route.js +0 -103
  192. package/src/proxy/router/responses_route.js +0 -170
  193. package/src/proxy/router/vertex_route.js +0 -110
  194. package/src/proxy/server/http.js +0 -363
  195. package/src/proxy/server/routes.js +0 -558
  196. package/src/proxy/server/settings.js +0 -115
  197. package/src/proxy/sync/engine.js +0 -179
  198. package/src/proxy/sync/inbound.js +0 -211
  199. package/src/proxy/sync/outbound.js +0 -320
  200. package/src/proxy/task/monitor.js +0 -131
  201. package/src/proxy/trace/extractor.js +0 -1
  202. package/src/proxy/trace/usage.js +0 -1
  203. package/src/webui/client/clientJs/assets.js +0 -111
  204. package/src/webui/client/clientJs/bootstrap.js +0 -92
  205. package/src/webui/client/clientJs/common.js +0 -77
  206. package/src/webui/client/clientJs/i18n.js +0 -366
  207. package/src/webui/client/clientJs/index.js +0 -35
  208. package/src/webui/client/clientJs/interactions.js +0 -351
  209. package/src/webui/client/clientJs/overview.js +0 -152
  210. package/src/webui/client/clientJs/personality.js +0 -285
  211. package/src/webui/client/clientJs/pipelines.js +0 -330
  212. package/src/webui/client/indexHtml.js +0 -221
  213. package/src/webui/client/static.js +0 -23
  214. package/src/webui/client/stylesCss.js +0 -639
  215. package/src/webui/client/vendor/README.md +0 -15
  216. package/src/webui/client/vendor/echarts.min.js +0 -45
  217. package/src/webui/index.js +0 -14
  218. package/src/webui/observer/assets.js +0 -146
  219. package/src/webui/observer/index.js +0 -37
  220. package/src/webui/observer/interactions.js +0 -127
  221. package/src/webui/observer/jsonl.js +0 -75
  222. package/src/webui/observer/paths.js +0 -46
  223. package/src/webui/observer/personality.js +0 -43
  224. package/src/webui/observer/pipelineEvents.js +0 -58
  225. package/src/webui/observer/redact.js +0 -63
  226. package/src/webui/observer/runs.js +0 -356
  227. package/src/webui/observer/safety.js +0 -57
  228. package/src/webui/observer/skills.js +0 -70
  229. package/src/webui/observer/status.js +0 -71
  230. package/src/webui/server/http.js +0 -138
  231. 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
- };