@evomap/evolver 1.84.0 → 1.84.2
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/assets/gep/genes.seed.json +17 -15
- package/index.js +52 -16
- package/package.json +4 -3
- package/src/adapters/claudeCode.js +44 -31
- package/src/adapters/codex.js +70 -26
- package/src/adapters/cursor.js +3 -1
- package/src/adapters/hookAdapter.js +142 -2
- package/src/adapters/kiro.js +6 -14
- package/src/adapters/opencode.js +6 -14
- package/src/adapters/scripts/_runtimePaths.js +114 -0
- package/src/adapters/scripts/evolver-session-end.js +37 -61
- package/src/adapters/scripts/evolver-session-start.js +1 -31
- package/src/atp/hubClient.js +3 -1
- package/src/config.js +20 -1
- package/src/evolve/guards.js +1 -1
- package/src/evolve/pipeline/collect.js +1 -1
- package/src/evolve/pipeline/dispatch.js +1 -1
- package/src/evolve/pipeline/enrich.js +1 -1
- package/src/evolve/pipeline/hub.js +1 -1
- package/src/evolve/pipeline/select.js +1 -1
- package/src/evolve/pipeline/signals.js +1 -1
- package/src/evolve/utils.js +1 -1
- package/src/evolve.js +1 -1
- package/src/forceUpdate.js +5 -21
- package/src/gep/a2aProtocol.js +1 -1
- package/src/gep/assetStore.js +27 -6
- package/src/gep/candidateEval.js +1 -1
- package/src/gep/candidates.js +1 -1
- package/src/gep/contentHash.js +1 -1
- package/src/gep/crypto.js +1 -1
- package/src/gep/curriculum.js +1 -1
- package/src/gep/deviceId.js +1 -1
- package/src/gep/directoryClient.js +4 -3
- package/src/gep/envFingerprint.js +1 -1
- package/src/gep/epigenetics.js +1 -1
- package/src/gep/explore.js +1 -1
- package/src/gep/gitOps.js +0 -5
- package/src/gep/hash.js +1 -1
- package/src/gep/hubFetch.js +1 -0
- package/src/gep/hubReview.js +1 -1
- package/src/gep/hubSearch.js +1 -1
- package/src/gep/hubVerify.js +1 -1
- package/src/gep/learningSignals.js +1 -1
- package/src/gep/mailboxTransport.js +8 -5
- package/src/gep/memoryGraph.js +1 -1
- package/src/gep/memoryGraphAdapter.js +1 -1
- package/src/gep/mutation.js +1 -1
- package/src/gep/narrativeMemory.js +1 -1
- package/src/gep/openPRRegistry.js +1 -1
- package/src/gep/personality.js +1 -1
- package/src/gep/policyCheck.js +1 -1
- package/src/gep/prompt.js +1 -1
- package/src/gep/recallVerifier.js +1 -1
- package/src/gep/reflection.js +1 -1
- package/src/gep/sanitize.js +2 -1
- package/src/gep/schemas/gene.js +70 -1
- package/src/gep/schemas/protocol.js +9 -1
- package/src/gep/selector.js +1 -1
- package/src/gep/selfPR.js +62 -34
- package/src/gep/skillDistiller.js +1 -1
- package/src/gep/skillPublisher.js +3 -2
- package/src/gep/solidify.js +1 -1
- package/src/gep/strategy.js +1 -1
- package/src/gep/taskReceiver.js +6 -5
- package/src/gep/validator/index.js +10 -6
- package/src/gep/validator/reporter.js +2 -1
- package/src/gep/validator/stakeBootstrap.js +2 -1
- package/src/ops/health_check.js +1 -11
- package/src/ops/lifecycle.js +1 -3
- package/src/proxy/index.js +69 -0
- package/src/proxy/lifecycle/manager.js +3 -2
- package/src/proxy/router/cache_passthrough.js +26 -0
- package/src/proxy/router/features.js +84 -0
- package/src/proxy/router/messages_route.js +242 -0
- package/src/proxy/router/model_router.js +113 -0
- package/src/proxy/server/http.js +108 -6
- package/src/proxy/server/routes.js +12 -2
- package/src/proxy/server/settings.js +43 -10
- package/src/proxy/sync/inbound.js +3 -2
- package/src/proxy/sync/outbound.js +2 -1
- package/src/webui/observer/interactions.js +22 -16
- package/scripts/check_wrapper_compat.js +0 -113
- package/src/gep/.integrity +0 -0
- package/src/gep/integrityCheck.js +0 -1
- package/src/gep/shield.js +0 -1
|
@@ -0,0 +1,242 @@
|
|
|
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
|
+
|
|
22
|
+
const DEFAULT_TIER_MODELS = Object.freeze({
|
|
23
|
+
cheap: 'claude-haiku-4-5',
|
|
24
|
+
mid: 'claude-sonnet-4-6',
|
|
25
|
+
expensive: 'claude-opus-4-7',
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function resolveTierModels() {
|
|
29
|
+
return {
|
|
30
|
+
cheap: process.env.EVOMAP_MODEL_CHEAP || DEFAULT_TIER_MODELS.cheap,
|
|
31
|
+
mid: process.env.EVOMAP_MODEL_MID || DEFAULT_TIER_MODELS.mid,
|
|
32
|
+
expensive: process.env.EVOMAP_MODEL_EXPENSIVE || DEFAULT_TIER_MODELS.expensive,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function buildMessagesHandler({ anthropicProxy, logger, routerEnabled } = {}) {
|
|
37
|
+
if (typeof anthropicProxy !== 'function') {
|
|
38
|
+
throw new Error('buildMessagesHandler requires anthropicProxy(path, body, opts)');
|
|
39
|
+
}
|
|
40
|
+
const log = logger || console;
|
|
41
|
+
// Phase C slice 6: flag is read at handler construction (proxy start), not
|
|
42
|
+
// per-request — flipping the env var requires a proxy restart, fine for an
|
|
43
|
+
// MVP feature flag. Explicit boolean override wins so tests stay hermetic.
|
|
44
|
+
const enabled = typeof routerEnabled === 'boolean'
|
|
45
|
+
? routerEnabled
|
|
46
|
+
: process.env.EVOMAP_ROUTER_ENABLED === '1';
|
|
47
|
+
|
|
48
|
+
return async ({ body, headers }) => {
|
|
49
|
+
const inboundHeaders = headers || {};
|
|
50
|
+
// x-api-key is satisfied by either the inbound header OR a proxy-side
|
|
51
|
+
// ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN env (token mediation, see
|
|
52
|
+
// _proxyAnthropic). The proxy server itself has already auth-checked
|
|
53
|
+
// `Authorization: Bearer <proxy_token>` before reaching this handler.
|
|
54
|
+
const hasInboundKey = !!inboundHeaders['x-api-key'];
|
|
55
|
+
const hasProxyEnvCreds = !!(process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN);
|
|
56
|
+
if (!hasInboundKey && !hasProxyEnvCreds) {
|
|
57
|
+
throw Object.assign(new Error('x-api-key required'), { statusCode: 401 });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const originalModel = body && typeof body.model === 'string' ? body.model : null;
|
|
61
|
+
let chosenModel = originalModel;
|
|
62
|
+
let decisionTier = null;
|
|
63
|
+
let decisionReason = null;
|
|
64
|
+
let fallback = null;
|
|
65
|
+
|
|
66
|
+
if (enabled) {
|
|
67
|
+
try {
|
|
68
|
+
const features = extractFeatures(body);
|
|
69
|
+
const decision = pickForTurn({
|
|
70
|
+
features,
|
|
71
|
+
router_state: { history: [], pinned: null },
|
|
72
|
+
config: { default_tier: 'mid', disable: false, hard_pin_after_plan: false },
|
|
73
|
+
});
|
|
74
|
+
decisionTier = decision.tier;
|
|
75
|
+
decisionReason = decision.reason;
|
|
76
|
+
const tierModel = resolveTierModels()[decision.tier];
|
|
77
|
+
if (tierModel) chosenModel = tierModel;
|
|
78
|
+
} catch (err) {
|
|
79
|
+
fallback = 'classifier_error';
|
|
80
|
+
log.warn?.(JSON.stringify({
|
|
81
|
+
event: 'router_fallback',
|
|
82
|
+
reason: 'classifier_error',
|
|
83
|
+
original_model: originalModel,
|
|
84
|
+
would_have_been: null,
|
|
85
|
+
error: err.message,
|
|
86
|
+
}));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let outboundBody = body;
|
|
92
|
+
if (enabled && chosenModel && chosenModel !== originalModel) {
|
|
93
|
+
try {
|
|
94
|
+
outboundBody = rewriteModel(body, chosenModel);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
fallback = fallback || 'rewrite_error';
|
|
97
|
+
log.warn?.(JSON.stringify({
|
|
98
|
+
event: 'router_fallback',
|
|
99
|
+
reason: 'rewrite_error',
|
|
100
|
+
original_model: originalModel,
|
|
101
|
+
would_have_been: chosenModel,
|
|
102
|
+
error: err.message,
|
|
103
|
+
}));
|
|
104
|
+
outboundBody = body;
|
|
105
|
+
chosenModel = originalModel;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (enabled) {
|
|
110
|
+
log.log?.(JSON.stringify({
|
|
111
|
+
event: 'router_decision',
|
|
112
|
+
tier: decisionTier,
|
|
113
|
+
reason: decisionReason,
|
|
114
|
+
original_model: originalModel,
|
|
115
|
+
chosen_model: chosenModel,
|
|
116
|
+
escalation_skipped: false,
|
|
117
|
+
fallback,
|
|
118
|
+
}));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const upstream = await anthropicProxy('/v1/messages', outboundBody, {
|
|
122
|
+
inboundHeaders,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
if (upstream.stream) {
|
|
126
|
+
const forwardHeaders = {};
|
|
127
|
+
const ct = upstream.headers && upstream.headers['content-type'];
|
|
128
|
+
if (ct) forwardHeaders['Content-Type'] = ct;
|
|
129
|
+
return {
|
|
130
|
+
status: upstream.status,
|
|
131
|
+
stream: upstream.stream,
|
|
132
|
+
headers: forwardHeaders,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// First upstream returned non-stream. If it's a 5xx on a router-rewritten
|
|
137
|
+
// request, retry once with the client's original model. This covers the
|
|
138
|
+
// common one-hub/prism case where the chosen tier model has no channel
|
|
139
|
+
// configured — a hard 503 is worse for the caller than a slightly more
|
|
140
|
+
// expensive successful response. The retry may come back streaming when
|
|
141
|
+
// the client originally sent stream:true (the first attempt errored out
|
|
142
|
+
// as JSON before any SSE flowed, so streaming the second attempt is
|
|
143
|
+
// still safe). The result-shape branch below handles both cases.
|
|
144
|
+
let finalUpstream = upstream;
|
|
145
|
+
if (
|
|
146
|
+
enabled
|
|
147
|
+
&& upstream.status >= 500
|
|
148
|
+
&& chosenModel
|
|
149
|
+
&& chosenModel !== originalModel
|
|
150
|
+
) {
|
|
151
|
+
log.warn?.(JSON.stringify({
|
|
152
|
+
event: 'router_fallback',
|
|
153
|
+
reason: 'upstream_5xx_retry',
|
|
154
|
+
original_model: originalModel,
|
|
155
|
+
would_have_been: chosenModel,
|
|
156
|
+
upstream_status: upstream.status,
|
|
157
|
+
}));
|
|
158
|
+
// Drain the first upstream's body before retrying. fetch Response
|
|
159
|
+
// bodies are single-read streams; if the retry succeeds, finalUpstream
|
|
160
|
+
// moves to the retry response and the original `upstream` body is
|
|
161
|
+
// never consumed, so undici keeps the underlying TCP socket pinned
|
|
162
|
+
// in the awaiting-body state. Under sustained 5xx storms from the
|
|
163
|
+
// rewritten model (the exact scenario this branch targets), every
|
|
164
|
+
// successful retry leaks one socket out of the pool.
|
|
165
|
+
//
|
|
166
|
+
// We don't need the parsed body — text() reads the full body before
|
|
167
|
+
// returning, which is enough to release the socket. If the retry
|
|
168
|
+
// throws, finalUpstream stays pointing at the (now-drained) upstream
|
|
169
|
+
// and the .text() at line 195 short-circuits on the empty Response
|
|
170
|
+
// — but that loses the original 503 body, so cache it here too and
|
|
171
|
+
// restore it on the throw path.
|
|
172
|
+
let drainedFirst = '';
|
|
173
|
+
if (upstream.text) {
|
|
174
|
+
try { drainedFirst = await upstream.text(); } catch { /* socket already gone */ }
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
const retryBody = rewriteModel(body, originalModel);
|
|
178
|
+
finalUpstream = await anthropicProxy('/v1/messages', retryBody, {
|
|
179
|
+
inboundHeaders,
|
|
180
|
+
});
|
|
181
|
+
} catch (err) {
|
|
182
|
+
// Replay the drained first response so the caller still sees the
|
|
183
|
+
// original 503 + body, not an empty stream.
|
|
184
|
+
finalUpstream = {
|
|
185
|
+
status: upstream.status,
|
|
186
|
+
headers: upstream.headers,
|
|
187
|
+
stream: null,
|
|
188
|
+
text: () => drainedFirst,
|
|
189
|
+
};
|
|
190
|
+
log.warn?.(JSON.stringify({
|
|
191
|
+
event: 'router_fallback',
|
|
192
|
+
reason: 'upstream_5xx_retry_failed',
|
|
193
|
+
original_model: originalModel,
|
|
194
|
+
would_have_been: chosenModel,
|
|
195
|
+
error: err.message,
|
|
196
|
+
}));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (finalUpstream.stream) {
|
|
201
|
+
const forwardHeaders = {};
|
|
202
|
+
const ct = finalUpstream.headers && finalUpstream.headers['content-type'];
|
|
203
|
+
if (ct) forwardHeaders['Content-Type'] = ct;
|
|
204
|
+
return {
|
|
205
|
+
status: finalUpstream.status,
|
|
206
|
+
stream: finalUpstream.stream,
|
|
207
|
+
headers: forwardHeaders,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
// Upstream is normally JSON, but a misconfigured local gateway (prism
|
|
211
|
+
// without a route configured), a CDN 4xx page, or a load balancer 502
|
|
212
|
+
// can return text/plain or HTML. Read the body as text (single
|
|
213
|
+
// consumption — fetch Response bodies cannot be read twice) and parse
|
|
214
|
+
// ourselves; on parse failure, wrap the raw text in an {error} envelope
|
|
215
|
+
// so the client gets the real upstream status + a readable body
|
|
216
|
+
// instead of a 500 "Unexpected non-whitespace character".
|
|
217
|
+
// Default to {} not null: src/proxy/server/http.js wraps results as
|
|
218
|
+
// `result.body || result`, and a falsy body would serialize the entire
|
|
219
|
+
// internal {status, body} envelope to the client.
|
|
220
|
+
let respBody = {};
|
|
221
|
+
let raw = '';
|
|
222
|
+
if (finalUpstream.text) {
|
|
223
|
+
try { raw = await finalUpstream.text(); } catch { /* ignore */ }
|
|
224
|
+
}
|
|
225
|
+
if (raw.length > 0) {
|
|
226
|
+
try {
|
|
227
|
+
respBody = JSON.parse(raw);
|
|
228
|
+
} catch {
|
|
229
|
+
respBody = { error: raw };
|
|
230
|
+
log.warn?.(JSON.stringify({
|
|
231
|
+
event: 'router_fallback',
|
|
232
|
+
reason: 'upstream_non_json',
|
|
233
|
+
upstream_status: finalUpstream.status,
|
|
234
|
+
preview: raw.slice(0, 200),
|
|
235
|
+
}));
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return { status: finalUpstream.status, body: respBody };
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
module.exports = { buildMessagesHandler, DEFAULT_TIER_MODELS, resolveTierModels };
|
|
@@ -0,0 +1,113 @@
|
|
|
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 };
|
package/src/proxy/server/http.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const crypto = require('crypto');
|
|
3
4
|
const http = require('http');
|
|
4
5
|
const { writeSettings, readSettings, clearSettings, clearIfStale } = require('./settings');
|
|
5
6
|
|
|
@@ -89,10 +90,12 @@ class ProxyHttpServer {
|
|
|
89
90
|
this.actualPort = null;
|
|
90
91
|
this.logger = logger || console;
|
|
91
92
|
this.server = null;
|
|
93
|
+
this.token = null;
|
|
92
94
|
}
|
|
93
95
|
|
|
94
96
|
async start() {
|
|
95
97
|
clearIfStale();
|
|
98
|
+
this.token = crypto.randomBytes(32).toString('hex');
|
|
96
99
|
this.server = http.createServer((req, res) => this._handleRequest(req, res));
|
|
97
100
|
|
|
98
101
|
let port = this.basePort;
|
|
@@ -106,10 +109,11 @@ class ProxyHttpServer {
|
|
|
106
109
|
url,
|
|
107
110
|
pid: process.pid,
|
|
108
111
|
started_at: new Date().toISOString(),
|
|
112
|
+
token: this.token,
|
|
109
113
|
},
|
|
110
114
|
});
|
|
111
115
|
this.logger.log(`[proxy] HTTP server listening on ${url}`);
|
|
112
|
-
return { port, url };
|
|
116
|
+
return { port, url, token: this.token };
|
|
113
117
|
}
|
|
114
118
|
port++;
|
|
115
119
|
}
|
|
@@ -125,6 +129,17 @@ class ProxyHttpServer {
|
|
|
125
129
|
}
|
|
126
130
|
|
|
127
131
|
async _handleRequest(req, res) {
|
|
132
|
+
const authHeader = req.headers['authorization'] || '';
|
|
133
|
+
const provided = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
|
|
134
|
+
const expBuf = Buffer.from(this.token || '', 'utf8');
|
|
135
|
+
const provBuf = Buffer.from(provided, 'utf8');
|
|
136
|
+
const valid = this.token &&
|
|
137
|
+
provBuf.length === expBuf.length &&
|
|
138
|
+
crypto.timingSafeEqual(provBuf, expBuf);
|
|
139
|
+
if (!valid) {
|
|
140
|
+
return sendJson(res, 401, { error: 'Unauthorized' });
|
|
141
|
+
}
|
|
142
|
+
|
|
128
143
|
const url = new URL(req.url, `http://127.0.0.1:${this.actualPort}`);
|
|
129
144
|
const routeKey = `${req.method} ${url.pathname}`;
|
|
130
145
|
|
|
@@ -139,13 +154,100 @@ class ProxyHttpServer {
|
|
|
139
154
|
try {
|
|
140
155
|
const body = (req.method === 'POST' || req.method === 'PUT') ? await parseBody(req) : {};
|
|
141
156
|
const query = Object.fromEntries(url.searchParams);
|
|
142
|
-
const
|
|
143
|
-
|
|
157
|
+
const headers = req.headers;
|
|
158
|
+
const result = await handler({ body, query, params, headers });
|
|
159
|
+
if (result && result.stream) {
|
|
160
|
+
await this._streamResponse(res, result);
|
|
161
|
+
} else {
|
|
162
|
+
sendJson(res, result.status || 200, result.body || result);
|
|
163
|
+
}
|
|
144
164
|
} catch (err) {
|
|
145
165
|
this.logger.error(`[proxy] ${routeKey} error:`, err.message);
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
}
|
|
166
|
+
if (res.headersSent) {
|
|
167
|
+
try { res.end(); } catch { /* ignore */ }
|
|
168
|
+
} else {
|
|
169
|
+
sendJson(res, err.statusCode || 500, {
|
|
170
|
+
error: err.message || 'Internal error',
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// SSE / pass-through streaming path used by /v1/messages (slice 5).
|
|
177
|
+
// `result.stream` may be any async iterable yielding Buffer or string
|
|
178
|
+
// chunks (Web ReadableStream from fetch, or a Node Readable). Headers
|
|
179
|
+
// default to text/event-stream so Anthropic-style SSE bytes piped
|
|
180
|
+
// through reach the client unmodified. The caller owns producing
|
|
181
|
+
// correctly-framed SSE; this method only relays bytes.
|
|
182
|
+
async _streamResponse(res, result) {
|
|
183
|
+
const headers = Object.assign({
|
|
184
|
+
'Content-Type': 'text/event-stream',
|
|
185
|
+
'Cache-Control': 'no-cache',
|
|
186
|
+
'Connection': 'keep-alive',
|
|
187
|
+
}, result.headers || {});
|
|
188
|
+
res.writeHead(result.status || 200, headers);
|
|
189
|
+
|
|
190
|
+
// Browsers, network blips, and Ctrl-C all close the SSE socket mid-stream.
|
|
191
|
+
// If we await `drain` without watching for `close`, the drain event never
|
|
192
|
+
// fires on a destroyed socket and this coroutine hangs forever — which
|
|
193
|
+
// also pins the upstream fetch body open (real Anthropic socket leak, not
|
|
194
|
+
// just coroutine leak). For Web ReadableStream upstreams we read via an
|
|
195
|
+
// explicit reader so cancellation can go through the same lock; for sync
|
|
196
|
+
// generators / Node Readables we fall back to for-await.
|
|
197
|
+
const stream = result.stream;
|
|
198
|
+
const reader = stream && typeof stream.getReader === 'function' ? stream.getReader() : null;
|
|
199
|
+
|
|
200
|
+
let clientGone = false;
|
|
201
|
+
const onClose = () => {
|
|
202
|
+
clientGone = true;
|
|
203
|
+
if (reader) {
|
|
204
|
+
reader.cancel().catch(() => { /* upstream already settled */ });
|
|
205
|
+
} else if (stream && typeof stream.destroy === 'function') {
|
|
206
|
+
try { stream.destroy(); } catch { /* ignore */ }
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
res.once('close', onClose);
|
|
210
|
+
|
|
211
|
+
const awaitBackpressure = () => new Promise((resolve) => {
|
|
212
|
+
let settled = false;
|
|
213
|
+
const onDrain = () => {
|
|
214
|
+
if (settled) return;
|
|
215
|
+
settled = true;
|
|
216
|
+
res.off('close', onCloseInner);
|
|
217
|
+
resolve();
|
|
218
|
+
};
|
|
219
|
+
const onCloseInner = () => {
|
|
220
|
+
if (settled) return;
|
|
221
|
+
settled = true;
|
|
222
|
+
res.off('drain', onDrain);
|
|
223
|
+
resolve();
|
|
224
|
+
};
|
|
225
|
+
res.once('drain', onDrain);
|
|
226
|
+
res.once('close', onCloseInner);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
if (reader) {
|
|
231
|
+
for (;;) {
|
|
232
|
+
const { value, done } = await reader.read();
|
|
233
|
+
if (done || clientGone) break;
|
|
234
|
+
if (!res.write(value)) {
|
|
235
|
+
await awaitBackpressure();
|
|
236
|
+
if (clientGone) break;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
} else {
|
|
240
|
+
for await (const chunk of stream) {
|
|
241
|
+
if (clientGone) break;
|
|
242
|
+
if (!res.write(chunk)) {
|
|
243
|
+
await awaitBackpressure();
|
|
244
|
+
if (clientGone) break;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
} finally {
|
|
249
|
+
res.off('close', onClose);
|
|
250
|
+
try { res.end(); } catch { /* socket may already be destroyed */ }
|
|
149
251
|
}
|
|
150
252
|
}
|
|
151
253
|
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
const { PROXY_PROTOCOL_VERSION, SCHEMA_VERSION } = require('../mailbox/store');
|
|
4
4
|
|
|
5
5
|
function buildRoutes(store, proxyHandlers, taskMonitor, extensions) {
|
|
6
|
-
const { dmHandler, skillUpdater, getHubMailboxStatus, sessionHandler } = extensions || {};
|
|
7
|
-
|
|
6
|
+
const { dmHandler, skillUpdater, getHubMailboxStatus, sessionHandler, messagesHandler } = extensions || {};
|
|
7
|
+
const routes = {
|
|
8
8
|
// -- Mailbox --
|
|
9
9
|
'POST /mailbox/send': async ({ body }) => {
|
|
10
10
|
if (!body.type) throw Object.assign(new Error('type is required'), { statusCode: 400 });
|
|
@@ -458,6 +458,16 @@ function buildRoutes(store, proxyHandlers, taskMonitor, extensions) {
|
|
|
458
458
|
return { body: result };
|
|
459
459
|
},
|
|
460
460
|
};
|
|
461
|
+
|
|
462
|
+
// Phase C: only register /v1/messages when the proxy was built with a
|
|
463
|
+
// messages handler (i.e. _proxyAnthropic is available). Slice 5 wires it
|
|
464
|
+
// unconditionally so the route exists on every proxy startup; slice 6 adds
|
|
465
|
+
// EVOMAP_ROUTER_ENABLED behavior inside the handler.
|
|
466
|
+
if (messagesHandler) {
|
|
467
|
+
routes['POST /v1/messages'] = messagesHandler;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return routes;
|
|
461
471
|
}
|
|
462
472
|
|
|
463
473
|
module.exports = { buildRoutes };
|
|
@@ -4,34 +4,52 @@ const fs = require('fs');
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const os = require('os');
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
// Settings paths are resolved on every call rather than snapshotted at module
|
|
8
|
+
// load. Tests run in parallel by default (`node --test`); when multiple test
|
|
9
|
+
// files exercise the proxy server, each one starting writes
|
|
10
|
+
// `~/.evolver/settings.json` and the sibling webui observer would read those
|
|
11
|
+
// bytes back and report mode='proxy_only' instead of the expected 'idle'.
|
|
12
|
+
// Lazy resolution lets a test set EVOLVER_SETTINGS_DIR to a temp dir before
|
|
13
|
+
// calling start()/readSettings() and stay isolated from concurrent workers.
|
|
14
|
+
function getSettingsDir() {
|
|
15
|
+
return process.env.EVOLVER_SETTINGS_DIR || path.join(os.homedir(), '.evolver');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getSettingsFile() {
|
|
19
|
+
return path.join(getSettingsDir(), 'settings.json');
|
|
20
|
+
}
|
|
9
21
|
|
|
10
22
|
function readSettings() {
|
|
11
23
|
try {
|
|
12
|
-
|
|
13
|
-
|
|
24
|
+
const file = getSettingsFile();
|
|
25
|
+
if (fs.existsSync(file)) {
|
|
26
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
14
27
|
}
|
|
15
28
|
} catch {}
|
|
16
29
|
return {};
|
|
17
30
|
}
|
|
18
31
|
|
|
19
32
|
function writeSettings(data) {
|
|
20
|
-
|
|
21
|
-
|
|
33
|
+
const dir = getSettingsDir();
|
|
34
|
+
const file = getSettingsFile();
|
|
35
|
+
if (!fs.existsSync(dir)) {
|
|
36
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
22
37
|
}
|
|
23
38
|
const current = readSettings();
|
|
24
39
|
const merged = { ...current, ...data };
|
|
25
|
-
fs.writeFileSync(
|
|
40
|
+
fs.writeFileSync(file, JSON.stringify(merged, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
41
|
+
// mode: 0o600 only applies on creation; explicitly chmod to tighten pre-existing files
|
|
42
|
+
try { fs.chmodSync(file, 0o600); } catch {}
|
|
26
43
|
return merged;
|
|
27
44
|
}
|
|
28
45
|
|
|
29
46
|
function clearSettings() {
|
|
30
47
|
try {
|
|
31
|
-
|
|
48
|
+
const file = getSettingsFile();
|
|
49
|
+
if (fs.existsSync(file)) {
|
|
32
50
|
const current = readSettings();
|
|
33
51
|
delete current.proxy;
|
|
34
|
-
fs.writeFileSync(
|
|
52
|
+
fs.writeFileSync(file, JSON.stringify(current, null, 2), 'utf8');
|
|
35
53
|
}
|
|
36
54
|
} catch {}
|
|
37
55
|
}
|
|
@@ -61,4 +79,19 @@ function getProxyUrl() {
|
|
|
61
79
|
return settings.proxy?.url || null;
|
|
62
80
|
}
|
|
63
81
|
|
|
64
|
-
|
|
82
|
+
function getProxyToken() {
|
|
83
|
+
const settings = readSettings();
|
|
84
|
+
return settings.proxy?.token || null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = {
|
|
88
|
+
readSettings,
|
|
89
|
+
writeSettings,
|
|
90
|
+
clearSettings,
|
|
91
|
+
clearIfStale,
|
|
92
|
+
isStaleProxy,
|
|
93
|
+
getProxyUrl,
|
|
94
|
+
getProxyToken,
|
|
95
|
+
getSettingsDir,
|
|
96
|
+
getSettingsFile,
|
|
97
|
+
};
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const { PROXY_PROTOCOL_VERSION } = require('../mailbox/store');
|
|
4
4
|
const { AuthError } = require('../lifecycle/manager');
|
|
5
|
+
const { hubFetch } = require('../../gep/hubFetch');
|
|
5
6
|
|
|
6
7
|
const DEFAULT_POLL_INTERVAL_ACTIVE = 10_000;
|
|
7
8
|
const DEFAULT_POLL_INTERVAL_IDLE = 60_000;
|
|
@@ -22,7 +23,7 @@ class InboundSync {
|
|
|
22
23
|
|
|
23
24
|
try {
|
|
24
25
|
const senderId = this.store.getState('node_id');
|
|
25
|
-
const res = await
|
|
26
|
+
const res = await hubFetch(endpoint, {
|
|
26
27
|
method: 'POST',
|
|
27
28
|
headers: this.getHeaders(),
|
|
28
29
|
body: JSON.stringify({ sender_id: senderId, proxy_protocol_version: PROXY_PROTOCOL_VERSION, cursor, limit }),
|
|
@@ -82,7 +83,7 @@ class InboundSync {
|
|
|
82
83
|
|
|
83
84
|
try {
|
|
84
85
|
const senderId = this.store.getState('node_id');
|
|
85
|
-
await
|
|
86
|
+
await hubFetch(endpoint, {
|
|
86
87
|
method: 'POST',
|
|
87
88
|
headers: this.getHeaders(),
|
|
88
89
|
body: JSON.stringify({ sender_id: senderId, message_ids: delivered.map(m => m.id) }),
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const { PROXY_PROTOCOL_VERSION } = require('../mailbox/store');
|
|
4
4
|
const { AuthError } = require('../lifecycle/manager');
|
|
5
|
+
const { hubFetch } = require('../../gep/hubFetch');
|
|
5
6
|
|
|
6
7
|
const MAX_BATCH = 50;
|
|
7
8
|
const MAX_RETRIES = 10;
|
|
@@ -22,7 +23,7 @@ class OutboundSync {
|
|
|
22
23
|
|
|
23
24
|
try {
|
|
24
25
|
const senderId = this.store.getState('node_id');
|
|
25
|
-
const res = await
|
|
26
|
+
const res = await hubFetch(endpoint, {
|
|
26
27
|
method: 'POST',
|
|
27
28
|
headers: this.getHeaders(),
|
|
28
29
|
body: JSON.stringify({
|