@evomap/evolver 1.90.0 → 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 -3670
  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/src/adapters/claudeCode.js +0 -194
  38. package/src/adapters/codex.js +0 -216
  39. package/src/adapters/cursor.js +0 -91
  40. package/src/adapters/hookAdapter.js +0 -469
  41. package/src/adapters/kiro.js +0 -195
  42. package/src/adapters/opencode.js +0 -326
  43. package/src/adapters/scripts/_lockPaths.js +0 -74
  44. package/src/adapters/scripts/_memoryFiltering.js +0 -35
  45. package/src/adapters/scripts/_runtimePaths.js +0 -440
  46. package/src/adapters/scripts/evolver-session-end.js +0 -321
  47. package/src/adapters/scripts/evolver-session-start.js +0 -587
  48. package/src/adapters/scripts/evolver-signal-detect.js +0 -98
  49. package/src/adapters/scripts/evolver-task-recall.js +0 -173
  50. package/src/atp/atpExecute.js +0 -283
  51. package/src/atp/atpTaskPickup.js +0 -233
  52. package/src/atp/autoBuyer.js +0 -382
  53. package/src/atp/autoDeliver.js +0 -215
  54. package/src/atp/cli.js +0 -354
  55. package/src/atp/cliAutobuyPrompt.js +0 -154
  56. package/src/atp/consumerAgent.js +0 -157
  57. package/src/atp/defaultHandler.js +0 -69
  58. package/src/atp/heartbeatSignalsHandler.js +0 -254
  59. package/src/atp/hubClient.js +0 -317
  60. package/src/atp/index.js +0 -38
  61. package/src/atp/merchantAgent.js +0 -118
  62. package/src/atp/protocol.js +0 -41
  63. package/src/atp/questionComposer.js +0 -133
  64. package/src/atp/serviceHelper.js +0 -92
  65. package/src/canary.js +0 -13
  66. package/src/config.js +0 -351
  67. package/src/evolve/guards.js +0 -1
  68. package/src/evolve/pipeline/collect.js +0 -1
  69. package/src/evolve/pipeline/dispatch.js +0 -1
  70. package/src/evolve/pipeline/enrich.js +0 -1
  71. package/src/evolve/pipeline/hub.js +0 -1
  72. package/src/evolve/pipeline/select.js +0 -1
  73. package/src/evolve/pipeline/signals.js +0 -1
  74. package/src/evolve/utils.js +0 -1
  75. package/src/evolve.js +0 -1
  76. package/src/experiment/agentRunner.js +0 -229
  77. package/src/experiment/cli.js +0 -159
  78. package/src/experiment/comparison.js +0 -233
  79. package/src/experiment/metrics.js +0 -75
  80. package/src/forceUpdate.js +0 -991
  81. package/src/gep/a2a.js +0 -173
  82. package/src/gep/a2aProtocol.js +0 -1
  83. package/src/gep/analyzer.js +0 -35
  84. package/src/gep/antiAbuseTelemetry.js +0 -1
  85. package/src/gep/assetCallLog.js +0 -197
  86. package/src/gep/assetStore.js +0 -723
  87. package/src/gep/assets.js +0 -36
  88. package/src/gep/autoDistillConv.js +0 -1
  89. package/src/gep/autoDistillLlm.js +0 -1
  90. package/src/gep/bridge.js +0 -138
  91. package/src/gep/candidateEval.js +0 -1
  92. package/src/gep/candidates.js +0 -1
  93. package/src/gep/claimNudge.js +0 -121
  94. package/src/gep/cliContracts.js +0 -1190
  95. package/src/gep/contentHash.js +0 -1
  96. package/src/gep/conversationDistiller.js +0 -1
  97. package/src/gep/conversationSniffer.js +0 -1
  98. package/src/gep/crypto.js +0 -1
  99. package/src/gep/curriculum.js +0 -1
  100. package/src/gep/deviceId.js +0 -1
  101. package/src/gep/directoryClient.js +0 -115
  102. package/src/gep/envFingerprint.js +0 -1
  103. package/src/gep/epigenetics.js +0 -1
  104. package/src/gep/execBridge.js +0 -1
  105. package/src/gep/executionTrace.js +0 -291
  106. package/src/gep/explore.js +0 -1
  107. package/src/gep/featureFlags.js +0 -121
  108. package/src/gep/gitOps.js +0 -265
  109. package/src/gep/hash.js +0 -1
  110. package/src/gep/hostErrorClassifier.js +0 -34
  111. package/src/gep/hubFetch.js +0 -1
  112. package/src/gep/hubReview.js +0 -1
  113. package/src/gep/hubSearch.js +0 -1
  114. package/src/gep/hubVerify.js +0 -1
  115. package/src/gep/idleScheduler.js +0 -400
  116. package/src/gep/issueReporter.js +0 -416
  117. package/src/gep/learningSignals.js +0 -1
  118. package/src/gep/llmReview.js +0 -92
  119. package/src/gep/localStateAwareness.js +0 -243
  120. package/src/gep/mailboxTransport.js +0 -119
  121. package/src/gep/memoryGraph.js +0 -1
  122. package/src/gep/memoryGraphAdapter.js +0 -1
  123. package/src/gep/mutation.js +0 -1
  124. package/src/gep/narrativeMemory.js +0 -1
  125. package/src/gep/oauthLogin.js +0 -181
  126. package/src/gep/openPRRegistry.js +0 -1
  127. package/src/gep/paths.js +0 -522
  128. package/src/gep/personality.js +0 -1
  129. package/src/gep/policyCheck.js +0 -1
  130. package/src/gep/portable.js +0 -103
  131. package/src/gep/privacyClient.js +0 -235
  132. package/src/gep/prompt.js +0 -1
  133. package/src/gep/questionGenerator.js +0 -518
  134. package/src/gep/recallInject.js +0 -1
  135. package/src/gep/recallVerifier.js +0 -1
  136. package/src/gep/reflection.js +0 -1
  137. package/src/gep/sanitize.js +0 -264
  138. package/src/gep/savingsCore.js +0 -1
  139. package/src/gep/schemas/capsule.js +0 -170
  140. package/src/gep/schemas/gene.js +0 -154
  141. package/src/gep/schemas/index.js +0 -8
  142. package/src/gep/schemas/protocol.js +0 -51
  143. package/src/gep/schemas/task.js +0 -74
  144. package/src/gep/selector.js +0 -1
  145. package/src/gep/selfPR.js +0 -469
  146. package/src/gep/signals.js +0 -776
  147. package/src/gep/skill2gep.js +0 -1056
  148. package/src/gep/skill2gepAudit.js +0 -303
  149. package/src/gep/skill2recipes.js +0 -511
  150. package/src/gep/skillDistiller.js +0 -1
  151. package/src/gep/skillPublisher.js +0 -358
  152. package/src/gep/solidify.js +0 -1
  153. package/src/gep/strategy.js +0 -1
  154. package/src/gep/taskReceiver.js +0 -575
  155. package/src/gep/tokenSavings.js +0 -1
  156. package/src/gep/trajectoryExport.js +0 -1
  157. package/src/gep/validationReport.js +0 -55
  158. package/src/gep/validator/index.js +0 -411
  159. package/src/gep/validator/reporter.js +0 -210
  160. package/src/gep/validator/sandboxExecutor.js +0 -480
  161. package/src/gep/validator/stakeBootstrap.js +0 -357
  162. package/src/gep/workspaceKeychain.js +0 -1
  163. package/src/ops/cleanup.js +0 -80
  164. package/src/ops/commentary.js +0 -60
  165. package/src/ops/health_check.js +0 -104
  166. package/src/ops/index.js +0 -11
  167. package/src/ops/innovation.js +0 -67
  168. package/src/ops/lifecycle.js +0 -798
  169. package/src/ops/self_repair.js +0 -76
  170. package/src/ops/skills_monitor.js +0 -147
  171. package/src/ops/trigger.js +0 -33
  172. package/src/proxy/clientSettings.js +0 -405
  173. package/src/proxy/envelope.js +0 -59
  174. package/src/proxy/extensions/dmHandler.js +0 -45
  175. package/src/proxy/extensions/sessionHandler.js +0 -141
  176. package/src/proxy/extensions/skillUpdater.js +0 -64
  177. package/src/proxy/extensions/traceControl.js +0 -1
  178. package/src/proxy/index.js +0 -1395
  179. package/src/proxy/inject.js +0 -1
  180. package/src/proxy/lifecycle/manager.js +0 -1568
  181. package/src/proxy/mailbox/state.js +0 -207
  182. package/src/proxy/mailbox/store.js +0 -602
  183. package/src/proxy/router/cache_passthrough.js +0 -26
  184. package/src/proxy/router/features.js +0 -84
  185. package/src/proxy/router/gemini_route.js +0 -154
  186. package/src/proxy/router/messages_route.js +0 -535
  187. package/src/proxy/router/model_router.js +0 -113
  188. package/src/proxy/router/models_route.js +0 -52
  189. package/src/proxy/router/ollama_route.js +0 -103
  190. package/src/proxy/router/responses_route.js +0 -170
  191. package/src/proxy/router/vertex_route.js +0 -110
  192. package/src/proxy/server/http.js +0 -363
  193. package/src/proxy/server/routes.js +0 -558
  194. package/src/proxy/server/settings.js +0 -115
  195. package/src/proxy/sync/engine.js +0 -179
  196. package/src/proxy/sync/inbound.js +0 -211
  197. package/src/proxy/sync/outbound.js +0 -361
  198. package/src/proxy/task/monitor.js +0 -131
  199. package/src/proxy/trace/extractor.js +0 -1
  200. package/src/proxy/trace/usage.js +0 -1
  201. package/src/solo/breaker.js +0 -25
  202. package/src/solo/gitGuard.js +0 -65
  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,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
- };
@@ -1,113 +0,0 @@
1
- 'use strict';
2
-
3
- // Phase C of the EvoX multi-tier router. The authoritative algorithm
4
- // lives in Rust at
5
- // evox/crates/evox-agent-core/src/model_router.rs
6
- // and ships a generated JSON fixture at
7
- // evox/crates/evox-agent-core/tests/fixtures/model_router_cases.json
8
- // A vendored copy of that fixture (test/fixtures/model_router_cases.json)
9
- // pins this port byte-for-byte; CI diffs against a regenerated copy so
10
- // silent drift turns red.
11
- //
12
- // Why a fresh module under src/proxy/router/ and not a fold into an
13
- // existing router-ish helper: src/proxy/ has no prior router/classifier
14
- // concept (mailbox/, sync/, task/, extensions/, lifecycle/ all sit at a
15
- // different layer). A first-class subdir is the cheapest place to grow
16
- // the Phase C pieces (cache_passthrough, anthropic egress) without
17
- // muddying the proxy core.
18
-
19
- const REASONS = Object.freeze({
20
- ROUTER_DISABLED: 'router_disabled',
21
- HARD_PINNED: 'hard_pinned',
22
- GENE_HINT: 'gene_hint',
23
- POST_TOOL_RESULT_SYNTHESIS: 'post_tool_result_synthesis',
24
- USER_REQUESTED_PLANNING: 'user_requested_planning',
25
- HIGH_TOOL_USE_DENSITY: 'high_tool_use_density',
26
- TRIVIAL_LOOKUP: 'trivial_lookup',
27
- DEFAULT_TIER: 'default_tier',
28
- ESCALATED_FROM_HISTORY: 'escalated_from_history',
29
- });
30
-
31
- const TIER_ORDER = Object.freeze({ cheap: 0, mid: 1, expensive: 2 });
32
-
33
- function tierStepUp(tier) {
34
- if (tier === 'cheap') return 'mid';
35
- if (tier === 'mid') return 'expensive';
36
- return 'expensive';
37
- }
38
-
39
- function classify(features, config) {
40
- // Parity invariant with model_router.rs:340 — branch order matters.
41
- // post_tool_result_synthesis must win over high_tool_use_density when
42
- // both signal: the synthesis turn is just summarising tool output.
43
- if (
44
- features.last_user_is_tool_result_only &&
45
- features.last_assistant_stop_reason === 'ToolUse'
46
- ) {
47
- return ['cheap', REASONS.POST_TOOL_RESULT_SYNTHESIS];
48
- }
49
- if (features.user_requested_planning) {
50
- return ['expensive', REASONS.USER_REQUESTED_PLANNING];
51
- }
52
- if (features.last_assistant_tool_call_count > 3) {
53
- return ['expensive', REASONS.HIGH_TOOL_USE_DENSITY];
54
- }
55
- if (features.user_simple_lookup) {
56
- return ['cheap', REASONS.TRIVIAL_LOOKUP];
57
- }
58
- return [config.default_tier, REASONS.DEFAULT_TIER];
59
- }
60
-
61
- function maybeEscalate(base, history) {
62
- // Stalled-turn signal from model_router.rs:373: the last decision
63
- // produced <50 output tokens with no tool call. Bump one tier up,
64
- // never above expensive, and never weaker than what classify chose.
65
- if (!history || history.length === 0) return [base, null];
66
- const last = history[history.length - 1];
67
- const stalled = last.output_tokens < 50 && !last.had_tool_call;
68
- if (!stalled) return [base, null];
69
- if (last.tier === 'expensive') return [base, null];
70
- const target = tierStepUp(last.tier);
71
- if (TIER_ORDER[target] > TIER_ORDER[base]) {
72
- return [target, last.tier];
73
- }
74
- return [base, null];
75
- }
76
-
77
- function pickForTurn(input) {
78
- const { features, router_state, config } = input;
79
- const geneHint = input.gene_hint;
80
-
81
- if (config.disable) {
82
- return {
83
- tier: config.default_tier,
84
- reason: REASONS.ROUTER_DISABLED,
85
- escalated_from: null,
86
- };
87
- }
88
- if (config.hard_pin_after_plan && router_state && router_state.pinned) {
89
- return {
90
- tier: router_state.pinned,
91
- reason: REASONS.HARD_PINNED,
92
- escalated_from: null,
93
- };
94
- }
95
- if (geneHint) {
96
- return {
97
- tier: geneHint,
98
- reason: REASONS.GENE_HINT,
99
- escalated_from: null,
100
- };
101
- }
102
- const [baseTier, baseReason] = classify(features, config);
103
- const history = router_state ? router_state.history : [];
104
- const [escalatedTier, escalatedFrom] = maybeEscalate(baseTier, history);
105
- const reason = escalatedFrom !== null ? REASONS.ESCALATED_FROM_HISTORY : baseReason;
106
- return {
107
- tier: escalatedTier,
108
- reason,
109
- escalated_from: escalatedFrom,
110
- };
111
- }
112
-
113
- module.exports = { pickForTurn, REASONS };