@evomap/evolver 1.89.20 → 2.0.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -564
- package/bin/evolver-llm-proxy.js +3 -0
- package/bin/evolver-mcp.js +2 -0
- package/bin/evolver-proxy.js +3 -0
- package/bin/evolver.js +4 -0
- package/index.js +1 -3586
- package/package.json +32 -62
- package/CONTRIBUTING.md +0 -19
- package/LICENSE +0 -641
- package/README.ja-JP.md +0 -521
- package/README.ko-KR.md +0 -520
- package/README.zh-CN.md +0 -531
- package/SKILL.md +0 -365
- package/assets/cover.png +0 -0
- package/assets/gep/genes.seed.json +0 -496
- package/conformance/savings-core/constants.json +0 -30
- package/conformance/savings-core/golden-vectors.json +0 -333
- package/scripts/a2a_export.js +0 -63
- package/scripts/a2a_ingest.js +0 -79
- package/scripts/a2a_promote.js +0 -118
- package/scripts/analyze_by_skill.js +0 -121
- package/scripts/build_binaries.js +0 -479
- package/scripts/check-changelog.js +0 -166
- package/scripts/extract_log.js +0 -85
- package/scripts/generate_history.js +0 -75
- package/scripts/gep_append_event.js +0 -96
- package/scripts/gep_personality_report.js +0 -234
- package/scripts/human_report.js +0 -147
- package/scripts/recall-verify-report.js +0 -234
- package/scripts/recover_loop.js +0 -61
- package/scripts/refresh_stars_badge.js +0 -168
- package/scripts/seed-merchants.js +0 -91
- package/scripts/skill2recipes.js +0 -118
- package/scripts/suggest_version.js +0 -89
- package/scripts/validate-modules.js +0 -38
- package/scripts/validate-suite.js +0 -78
- package/skills/_meta/SKILL.md +0 -41
- package/skills/index.json +0 -14
- package/src/adapters/claudeCode.js +0 -194
- package/src/adapters/codex.js +0 -216
- package/src/adapters/cursor.js +0 -91
- package/src/adapters/hookAdapter.js +0 -469
- package/src/adapters/kiro.js +0 -195
- package/src/adapters/opencode.js +0 -326
- package/src/adapters/scripts/_lockPaths.js +0 -74
- package/src/adapters/scripts/_memoryFiltering.js +0 -35
- package/src/adapters/scripts/_runtimePaths.js +0 -440
- package/src/adapters/scripts/evolver-session-end.js +0 -321
- package/src/adapters/scripts/evolver-session-start.js +0 -587
- package/src/adapters/scripts/evolver-signal-detect.js +0 -98
- package/src/adapters/scripts/evolver-task-recall.js +0 -173
- package/src/atp/atpExecute.js +0 -283
- package/src/atp/atpTaskPickup.js +0 -233
- package/src/atp/autoBuyer.js +0 -382
- package/src/atp/autoDeliver.js +0 -215
- package/src/atp/cli.js +0 -354
- package/src/atp/cliAutobuyPrompt.js +0 -154
- package/src/atp/consumerAgent.js +0 -157
- package/src/atp/defaultHandler.js +0 -69
- package/src/atp/heartbeatSignalsHandler.js +0 -254
- package/src/atp/hubClient.js +0 -317
- package/src/atp/index.js +0 -38
- package/src/atp/merchantAgent.js +0 -118
- package/src/atp/protocol.js +0 -41
- package/src/atp/questionComposer.js +0 -133
- package/src/atp/serviceHelper.js +0 -92
- package/src/canary.js +0 -13
- package/src/config.js +0 -351
- package/src/evolve/guards.js +0 -1
- package/src/evolve/pipeline/collect.js +0 -1
- package/src/evolve/pipeline/dispatch.js +0 -1
- package/src/evolve/pipeline/enrich.js +0 -1
- package/src/evolve/pipeline/hub.js +0 -1
- package/src/evolve/pipeline/select.js +0 -1
- package/src/evolve/pipeline/signals.js +0 -1
- package/src/evolve/utils.js +0 -1
- package/src/evolve.js +0 -1
- package/src/experiment/agentRunner.js +0 -229
- package/src/experiment/cli.js +0 -159
- package/src/experiment/comparison.js +0 -233
- package/src/experiment/metrics.js +0 -75
- package/src/forceUpdate.js +0 -991
- package/src/gep/a2a.js +0 -173
- package/src/gep/a2aProtocol.js +0 -1
- package/src/gep/analyzer.js +0 -35
- package/src/gep/antiAbuseTelemetry.js +0 -1
- package/src/gep/assetCallLog.js +0 -197
- package/src/gep/assetStore.js +0 -723
- package/src/gep/assets.js +0 -36
- package/src/gep/autoDistillConv.js +0 -1
- package/src/gep/autoDistillLlm.js +0 -1
- package/src/gep/bridge.js +0 -138
- package/src/gep/candidateEval.js +0 -1
- package/src/gep/candidates.js +0 -1
- package/src/gep/claimNudge.js +0 -121
- package/src/gep/cliContracts.js +0 -1190
- package/src/gep/contentHash.js +0 -1
- package/src/gep/conversationDistiller.js +0 -1
- package/src/gep/conversationSniffer.js +0 -1
- package/src/gep/crypto.js +0 -1
- package/src/gep/curriculum.js +0 -1
- package/src/gep/deviceId.js +0 -1
- package/src/gep/directoryClient.js +0 -115
- package/src/gep/envFingerprint.js +0 -1
- package/src/gep/epigenetics.js +0 -1
- package/src/gep/execBridge.js +0 -1
- package/src/gep/executionTrace.js +0 -291
- package/src/gep/explore.js +0 -1
- package/src/gep/featureFlags.js +0 -121
- package/src/gep/gitOps.js +0 -265
- package/src/gep/hash.js +0 -1
- package/src/gep/hostErrorClassifier.js +0 -34
- package/src/gep/hubFetch.js +0 -1
- package/src/gep/hubReview.js +0 -1
- package/src/gep/hubSearch.js +0 -1
- package/src/gep/hubVerify.js +0 -1
- package/src/gep/idleScheduler.js +0 -400
- package/src/gep/issueReporter.js +0 -416
- package/src/gep/learningSignals.js +0 -1
- package/src/gep/llmReview.js +0 -92
- package/src/gep/localStateAwareness.js +0 -243
- package/src/gep/mailboxTransport.js +0 -119
- package/src/gep/memoryGraph.js +0 -1
- package/src/gep/memoryGraphAdapter.js +0 -1
- package/src/gep/mutation.js +0 -1
- package/src/gep/narrativeMemory.js +0 -1
- package/src/gep/oauthLogin.js +0 -181
- package/src/gep/openPRRegistry.js +0 -1
- package/src/gep/paths.js +0 -522
- package/src/gep/personality.js +0 -1
- package/src/gep/policyCheck.js +0 -1
- package/src/gep/portable.js +0 -103
- package/src/gep/privacyClient.js +0 -235
- package/src/gep/prompt.js +0 -1
- package/src/gep/questionGenerator.js +0 -518
- package/src/gep/recallInject.js +0 -1
- package/src/gep/recallVerifier.js +0 -1
- package/src/gep/reflection.js +0 -1
- package/src/gep/sanitize.js +0 -264
- package/src/gep/savingsCore.js +0 -1
- package/src/gep/schemas/capsule.js +0 -170
- package/src/gep/schemas/gene.js +0 -154
- package/src/gep/schemas/index.js +0 -8
- package/src/gep/schemas/protocol.js +0 -51
- package/src/gep/schemas/task.js +0 -74
- package/src/gep/selector.js +0 -1
- package/src/gep/selfPR.js +0 -469
- package/src/gep/signals.js +0 -776
- package/src/gep/skill2gep.js +0 -1056
- package/src/gep/skill2gepAudit.js +0 -303
- package/src/gep/skill2recipes.js +0 -511
- package/src/gep/skillDistiller.js +0 -1
- package/src/gep/skillPublisher.js +0 -358
- package/src/gep/solidify.js +0 -1
- package/src/gep/strategy.js +0 -1
- package/src/gep/taskReceiver.js +0 -575
- package/src/gep/tokenSavings.js +0 -1
- package/src/gep/trajectoryExport.js +0 -1
- package/src/gep/validationReport.js +0 -55
- package/src/gep/validator/index.js +0 -411
- package/src/gep/validator/reporter.js +0 -210
- package/src/gep/validator/sandboxExecutor.js +0 -480
- package/src/gep/validator/stakeBootstrap.js +0 -357
- package/src/gep/workspaceKeychain.js +0 -1
- package/src/ops/cleanup.js +0 -80
- package/src/ops/commentary.js +0 -60
- package/src/ops/health_check.js +0 -104
- package/src/ops/index.js +0 -11
- package/src/ops/innovation.js +0 -67
- package/src/ops/lifecycle.js +0 -798
- package/src/ops/self_repair.js +0 -76
- package/src/ops/skills_monitor.js +0 -147
- package/src/ops/trigger.js +0 -33
- package/src/proxy/clientSettings.js +0 -405
- package/src/proxy/envelope.js +0 -59
- package/src/proxy/extensions/dmHandler.js +0 -45
- package/src/proxy/extensions/sessionHandler.js +0 -141
- package/src/proxy/extensions/skillUpdater.js +0 -64
- package/src/proxy/extensions/traceControl.js +0 -1
- package/src/proxy/index.js +0 -1395
- package/src/proxy/inject.js +0 -1
- package/src/proxy/lifecycle/manager.js +0 -1568
- package/src/proxy/mailbox/state.js +0 -207
- package/src/proxy/mailbox/store.js +0 -590
- package/src/proxy/router/cache_passthrough.js +0 -26
- package/src/proxy/router/features.js +0 -84
- package/src/proxy/router/gemini_route.js +0 -154
- package/src/proxy/router/messages_route.js +0 -535
- package/src/proxy/router/model_router.js +0 -113
- package/src/proxy/router/models_route.js +0 -52
- package/src/proxy/router/ollama_route.js +0 -103
- package/src/proxy/router/responses_route.js +0 -170
- package/src/proxy/router/vertex_route.js +0 -110
- package/src/proxy/server/http.js +0 -363
- package/src/proxy/server/routes.js +0 -558
- package/src/proxy/server/settings.js +0 -115
- package/src/proxy/sync/engine.js +0 -179
- package/src/proxy/sync/inbound.js +0 -211
- package/src/proxy/sync/outbound.js +0 -320
- package/src/proxy/task/monitor.js +0 -131
- package/src/proxy/trace/extractor.js +0 -1
- package/src/proxy/trace/usage.js +0 -1
- package/src/webui/client/clientJs/assets.js +0 -111
- package/src/webui/client/clientJs/bootstrap.js +0 -92
- package/src/webui/client/clientJs/common.js +0 -77
- package/src/webui/client/clientJs/i18n.js +0 -366
- package/src/webui/client/clientJs/index.js +0 -35
- package/src/webui/client/clientJs/interactions.js +0 -351
- package/src/webui/client/clientJs/overview.js +0 -152
- package/src/webui/client/clientJs/personality.js +0 -285
- package/src/webui/client/clientJs/pipelines.js +0 -330
- package/src/webui/client/indexHtml.js +0 -221
- package/src/webui/client/static.js +0 -23
- package/src/webui/client/stylesCss.js +0 -639
- package/src/webui/client/vendor/README.md +0 -15
- package/src/webui/client/vendor/echarts.min.js +0 -45
- package/src/webui/index.js +0 -14
- package/src/webui/observer/assets.js +0 -146
- package/src/webui/observer/index.js +0 -37
- package/src/webui/observer/interactions.js +0 -127
- package/src/webui/observer/jsonl.js +0 -75
- package/src/webui/observer/paths.js +0 -46
- package/src/webui/observer/personality.js +0 -43
- package/src/webui/observer/pipelineEvents.js +0 -58
- package/src/webui/observer/redact.js +0 -63
- package/src/webui/observer/runs.js +0 -356
- package/src/webui/observer/safety.js +0 -57
- package/src/webui/observer/skills.js +0 -70
- package/src/webui/observer/status.js +0 -71
- package/src/webui/server/http.js +0 -138
- package/src/webui/server/routes.js +0 -41
package/src/proxy/index.js
DELETED
|
@@ -1,1395 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const { getEvomapPath } = require('../gep/paths');
|
|
4
|
-
const { MailboxStore } = require('./mailbox/store');
|
|
5
|
-
const { ProxyHttpServer } = require('./server/http');
|
|
6
|
-
const { buildRoutes } = require('./server/routes');
|
|
7
|
-
const { buildMessagesHandler, canonicalizeForBedrock, supportsAdaptiveThinking } = require('./router/messages_route');
|
|
8
|
-
const { ensureEnvelope } = require('./envelope');
|
|
9
|
-
const { buildResponsesHandler, buildChatCompletionsHandler } = require('./router/responses_route');
|
|
10
|
-
const { buildGeminiHandler } = require('./router/gemini_route');
|
|
11
|
-
const { buildModelsHandler } = require('./router/models_route');
|
|
12
|
-
const { buildOllamaHandler } = require('./router/ollama_route');
|
|
13
|
-
const { buildVertexHandler } = require('./router/vertex_route');
|
|
14
|
-
const { SyncEngine } = require('./sync/engine');
|
|
15
|
-
const { LifecycleManager } = require('./lifecycle/manager');
|
|
16
|
-
const { TaskMonitor } = require('./task/monitor');
|
|
17
|
-
const { SkillUpdater } = require('./extensions/skillUpdater');
|
|
18
|
-
const { DmHandler } = require('./extensions/dmHandler');
|
|
19
|
-
const { SessionHandler } = require('./extensions/sessionHandler');
|
|
20
|
-
const { TraceControl } = require('./extensions/traceControl');
|
|
21
|
-
const { backfillProxyTraceUploads } = require('./trace/extractor');
|
|
22
|
-
const { hubFetch, sanitizeHubResponseForLog } = require('../gep/hubFetch');
|
|
23
|
-
|
|
24
|
-
const TRACE_BACKFILL_DRAIN_MAX_PASSES = 8;
|
|
25
|
-
const TRACE_BACKFILL_STARTUP_DRAIN_MAX_MS = 250;
|
|
26
|
-
const TRACE_BACKFILL_RUNTIME_DRAIN_MAX_MS = 50;
|
|
27
|
-
|
|
28
|
-
// Lazy via paths.getEvomapPath() — honors EVOLVER_HOME (#114).
|
|
29
|
-
function _defaultDataDir() { return getEvomapPath('mailbox'); }
|
|
30
|
-
|
|
31
|
-
const DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1';
|
|
32
|
-
const DEFAULT_GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com';
|
|
33
|
-
const DEFAULT_OLLAMA_BASE_URL = 'http://127.0.0.1:11434';
|
|
34
|
-
|
|
35
|
-
function isAllowedOpenAIHostname(hostname) {
|
|
36
|
-
const h = String(hostname || '').toLowerCase();
|
|
37
|
-
return h === 'api.openai.com' || h.endsWith('.api.openai.com');
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function resolveOpenAIBaseUrl(raw, { trustedOverride = false } = {}) {
|
|
41
|
-
const value = String(raw || DEFAULT_OPENAI_BASE_URL).replace(/\/+$/, '');
|
|
42
|
-
if (trustedOverride) return value;
|
|
43
|
-
|
|
44
|
-
let parsed;
|
|
45
|
-
try {
|
|
46
|
-
parsed = new URL(value);
|
|
47
|
-
} catch {
|
|
48
|
-
throw new Error('[proxy] EVOMAP_OPENAI_BASE_URL is not a valid URL');
|
|
49
|
-
}
|
|
50
|
-
if (
|
|
51
|
-
parsed.protocol !== 'https:'
|
|
52
|
-
|| !isAllowedOpenAIHostname(parsed.hostname)
|
|
53
|
-
|| parsed.pathname !== '/v1'
|
|
54
|
-
|| parsed.username
|
|
55
|
-
|| parsed.password
|
|
56
|
-
|| parsed.search
|
|
57
|
-
|| parsed.hash
|
|
58
|
-
) {
|
|
59
|
-
throw new Error('[proxy] EVOMAP_OPENAI_BASE_URL must be an OpenAI https://*.api.openai.com/v1 endpoint');
|
|
60
|
-
}
|
|
61
|
-
return value;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function makeOpenAIGatewayError(err, fallbackStatus = 502) {
|
|
65
|
-
const name = err && err.name ? String(err.name) : '';
|
|
66
|
-
const isTimeout = name === 'TimeoutError' || name === 'AbortError';
|
|
67
|
-
const out = new Error(isTimeout ? 'openai upstream timed out' : 'openai upstream request failed');
|
|
68
|
-
out.statusCode = isTimeout ? 504 : fallbackStatus;
|
|
69
|
-
out.cause = err;
|
|
70
|
-
return out;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function makeGeminiGatewayError(err, fallbackStatus = 502) {
|
|
74
|
-
const name = err && err.name ? String(err.name) : '';
|
|
75
|
-
const isTimeout = name === 'TimeoutError' || name === 'AbortError';
|
|
76
|
-
const out = new Error(isTimeout ? 'gemini upstream timed out' : 'gemini upstream request failed');
|
|
77
|
-
out.statusCode = isTimeout ? 504 : fallbackStatus;
|
|
78
|
-
out.cause = err;
|
|
79
|
-
return out;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function makeOllamaGatewayError(err, fallbackStatus = 502) {
|
|
83
|
-
const name = err && err.name ? String(err.name) : '';
|
|
84
|
-
const isTimeout = name === 'TimeoutError' || name === 'AbortError';
|
|
85
|
-
const out = new Error(isTimeout ? 'ollama upstream timed out' : 'ollama upstream request failed');
|
|
86
|
-
out.statusCode = isTimeout ? 504 : fallbackStatus;
|
|
87
|
-
out.cause = err;
|
|
88
|
-
return out;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function makeVertexGatewayError(err, fallbackStatus = 502) {
|
|
92
|
-
const name = err && err.name ? String(err.name) : '';
|
|
93
|
-
const isTimeout = name === 'TimeoutError' || name === 'AbortError';
|
|
94
|
-
const out = new Error(isTimeout ? 'vertex upstream timed out' : 'vertex upstream request failed');
|
|
95
|
-
out.statusCode = isTimeout ? 504 : fallbackStatus;
|
|
96
|
-
out.cause = err;
|
|
97
|
-
return out;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// The hub serves asset signal-search as `GET /a2a/assets/search` with query
|
|
101
|
-
// params (signals, status, limit, fields, domain); `signals`/`fields` are
|
|
102
|
-
// comma-separated lists. The proxy's public contract stays `POST /asset/search`
|
|
103
|
-
// with a JSON body, so we translate that body into the hub's query string here.
|
|
104
|
-
// Historically assetSearch forwarded as `POST /a2a/assets/search`, which the
|
|
105
|
-
// current hub rejects with `route_not_found` (it only matches the GET form).
|
|
106
|
-
function buildAssetSearchQuery(body = {}) {
|
|
107
|
-
const query = {};
|
|
108
|
-
const csv = (v) => (Array.isArray(v) ? v.join(',') : v);
|
|
109
|
-
if (body.signals != null) query.signals = csv(body.signals);
|
|
110
|
-
if (body.fields != null) query.fields = csv(body.fields);
|
|
111
|
-
if (body.status != null) query.status = body.status;
|
|
112
|
-
if (body.domain != null) query.domain = body.domain;
|
|
113
|
-
if (body.limit != null) query.limit = body.limit;
|
|
114
|
-
return query;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// Free-text path: `GET /a2a/assets/semantic-search?q=...` is the hub's vector
|
|
118
|
-
// similarity search. Unlike signal-search it takes ONE natural-language query
|
|
119
|
-
// string (the hub sanitizes it to <=200 chars) rather than a signal-keyword
|
|
120
|
-
// list, so a caller can ask "what asset fits my current situation?" in prose.
|
|
121
|
-
// The situation text rides in `q`; type / limit / fields forward the same way.
|
|
122
|
-
function buildSemanticSearchQuery(body = {}) {
|
|
123
|
-
const query = { q: body.query };
|
|
124
|
-
const csv = (v) => (Array.isArray(v) ? v.join(',') : v);
|
|
125
|
-
if (body.fields != null) query.fields = csv(body.fields);
|
|
126
|
-
if (body.type != null) query.type = body.type;
|
|
127
|
-
if (body.limit != null) query.limit = body.limit;
|
|
128
|
-
return query;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// Pick the hub endpoint for the proxy's `POST /asset/search` contract. A
|
|
132
|
-
// non-empty free-text `query` selects semantic-search (natural-language context
|
|
133
|
-
// match); anything else keeps the signal-keyword path byte-for-byte, so every
|
|
134
|
-
// existing signals-only caller is unaffected.
|
|
135
|
-
function planAssetSearch(body = {}) {
|
|
136
|
-
const q = typeof body.query === 'string' ? body.query.trim() : '';
|
|
137
|
-
if (q) {
|
|
138
|
-
return {
|
|
139
|
-
path: '/a2a/assets/semantic-search',
|
|
140
|
-
query: buildSemanticSearchQuery({ ...body, query: q }),
|
|
141
|
-
};
|
|
142
|
-
}
|
|
143
|
-
return { path: '/a2a/assets/search', query: buildAssetSearchQuery(body) };
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// Asset-search client-side relief. The hub meters /a2a/assets/search per client
|
|
147
|
-
// IP, so an entire proxy fleet sharing one egress (plus the operator hitting it
|
|
148
|
-
// manually) collapses into a single bucket and 429s itself. We cache identical
|
|
149
|
-
// signal searches briefly, collapse concurrent duplicates into one request, and
|
|
150
|
-
// honour the hub's Retry-After so we stop hammering — and stop consuming the
|
|
151
|
-
// shared bucket — during a rate-limit window. Tunable via env for ops.
|
|
152
|
-
const ASSET_SEARCH_CACHE_TTL_MS = Number(process.env.EVOMAP_ASSET_SEARCH_CACHE_TTL_MS) || 30_000;
|
|
153
|
-
const ASSET_SEARCH_CACHE_MAX = Number(process.env.EVOMAP_ASSET_SEARCH_CACHE_MAX) || 256;
|
|
154
|
-
// How long a cached result may still be served as "stale" while we are in a
|
|
155
|
-
// rate-limit cooldown (better to return slightly-old discovery results than to
|
|
156
|
-
// fail the caller and fire a doomed request).
|
|
157
|
-
const ASSET_SEARCH_STALE_GRACE_MS = 5 * 60_000;
|
|
158
|
-
|
|
159
|
-
// Extract a retry delay (ms) from a hub 429 response: prefer the JSON body's
|
|
160
|
-
// ms-precision retry_after_ms (buildRateLimitBody), fall back to the RFC
|
|
161
|
-
// Retry-After header (seconds). Returns 0 when neither is present.
|
|
162
|
-
function parseRetryAfterMs(res, bodyText) {
|
|
163
|
-
try {
|
|
164
|
-
const body = bodyText ? JSON.parse(bodyText) : null;
|
|
165
|
-
const ms = Number(body && body.retry_after_ms);
|
|
166
|
-
if (Number.isFinite(ms) && ms > 0) return Math.ceil(ms);
|
|
167
|
-
} catch { /* body is not JSON; fall through to the header */ }
|
|
168
|
-
const secs = Number(res && res.headers && res.headers.get && res.headers.get('retry-after'));
|
|
169
|
-
if (Number.isFinite(secs) && secs > 0) return secs * 1000;
|
|
170
|
-
return 0;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
class EvoMapProxy {
|
|
174
|
-
constructor(opts = {}) {
|
|
175
|
-
// evolver#567: default to the canonical Hub URL (config.resolveHubUrl →
|
|
176
|
-
// https://evomap.ai, honouring the A2A_HUB_URL / EVOMAP_HUB_URL /
|
|
177
|
-
// EVOLVER_DEFAULT_HUB_URL precedence + https enforcement) instead of '',
|
|
178
|
-
// so a freshly-launched proxy is Hub-connected out of the box after
|
|
179
|
-
// `evolver login` rather than silently staying hub-less/offline (which
|
|
180
|
-
// surfaced as 503 "Hub not configured" and node_id: null over MCP).
|
|
181
|
-
// opts.hubUrl still overrides everything.
|
|
182
|
-
const { resolveHubUrl } = require('../config');
|
|
183
|
-
this.hubUrl = (opts.hubUrl || resolveHubUrl()).replace(/\/+$/, '');
|
|
184
|
-
this.dataDir = opts.dataDir || opts.dbPath || _defaultDataDir();
|
|
185
|
-
this.port = opts.port;
|
|
186
|
-
this.logger = opts.logger || console;
|
|
187
|
-
this.clientSettings = opts.clientSettings || null;
|
|
188
|
-
this._skillPath = opts.skillPath || null;
|
|
189
|
-
this._anthropicBaseUrl = (opts.anthropicBaseUrl || process.env.EVOMAP_ANTHROPIC_BASE_URL || 'https://api.anthropic.com').replace(/\/+$/, '');
|
|
190
|
-
this._openaiBaseUrl = String(opts.openaiBaseUrl || process.env.EVOMAP_OPENAI_BASE_URL || DEFAULT_OPENAI_BASE_URL).replace(/\/+$/, '');
|
|
191
|
-
this._geminiBaseUrl = String(opts.geminiBaseUrl || process.env.EVOMAP_GEMINI_BASE_URL || DEFAULT_GEMINI_BASE_URL).replace(/\/+$/, '');
|
|
192
|
-
this._ollamaBaseUrl = String(opts.ollamaBaseUrl || process.env.EVOMAP_OLLAMA_BASE_URL || DEFAULT_OLLAMA_BASE_URL).replace(/\/+$/, '');
|
|
193
|
-
this._openaiBaseUrlTrusted = !!opts.openaiBaseUrl;
|
|
194
|
-
|
|
195
|
-
this.store = null;
|
|
196
|
-
this.server = null;
|
|
197
|
-
this.sync = null;
|
|
198
|
-
this.lifecycle = null;
|
|
199
|
-
this.taskMonitor = null;
|
|
200
|
-
this.skillUpdater = null;
|
|
201
|
-
this.dmHandler = null;
|
|
202
|
-
this.sessionHandler = null;
|
|
203
|
-
this.traceControl = null;
|
|
204
|
-
this._traceBackfillDraining = false;
|
|
205
|
-
this._started = false;
|
|
206
|
-
|
|
207
|
-
// Asset-search relief state (see ASSET_SEARCH_* constants above).
|
|
208
|
-
this._searchCache = new Map(); // key -> { value, expiresAt, staleUntil }
|
|
209
|
-
this._searchInflight = new Map(); // key -> Promise (concurrent dedup)
|
|
210
|
-
this._searchCooldownUntil = 0; // epoch ms; >now means hub rate-limited us
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
async start() {
|
|
214
|
-
if (this._started) throw new Error('Proxy already started');
|
|
215
|
-
|
|
216
|
-
this.store = new MailboxStore(this.dataDir);
|
|
217
|
-
|
|
218
|
-
this.lifecycle = new LifecycleManager({
|
|
219
|
-
hubUrl: this.hubUrl,
|
|
220
|
-
store: this.store,
|
|
221
|
-
logger: this.logger,
|
|
222
|
-
getTaskMeta: () => this.taskMonitor ? this.taskMonitor.getHeartbeatMeta() : {},
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
this.taskMonitor = new TaskMonitor({
|
|
226
|
-
store: this.store,
|
|
227
|
-
logger: this.logger,
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
this.skillUpdater = new SkillUpdater({
|
|
231
|
-
store: this.store,
|
|
232
|
-
skillPath: this._skillPath,
|
|
233
|
-
logger: this.logger,
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
this.dmHandler = new DmHandler({
|
|
237
|
-
store: this.store,
|
|
238
|
-
logger: this.logger,
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
this.sessionHandler = new SessionHandler({
|
|
242
|
-
store: this.store,
|
|
243
|
-
logger: this.logger,
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
this.traceControl = new TraceControl({
|
|
247
|
-
store: this.store,
|
|
248
|
-
logger: this.logger,
|
|
249
|
-
});
|
|
250
|
-
try { this.traceControl.pollAndApply(); } catch (e) {
|
|
251
|
-
this.logger?.warn?.('[proxy] traceControl initial poll failed:', e.message);
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
const getHeaders = () => this.lifecycle._buildHeaders();
|
|
255
|
-
const taskMonitor = this.taskMonitor;
|
|
256
|
-
|
|
257
|
-
this.sync = new SyncEngine({
|
|
258
|
-
store: this.store,
|
|
259
|
-
hubUrl: this.hubUrl,
|
|
260
|
-
getHeaders,
|
|
261
|
-
logger: this.logger,
|
|
262
|
-
onAuthError: () => this.lifecycle.reAuthenticate(),
|
|
263
|
-
onOutboundFlushed: () => this._drainProxyTraceBackfill({
|
|
264
|
-
maxMs: TRACE_BACKFILL_RUNTIME_DRAIN_MAX_MS,
|
|
265
|
-
}),
|
|
266
|
-
onInboundReceived: () => {
|
|
267
|
-
try { this.skillUpdater?.pollAndApply(); } catch (e) {
|
|
268
|
-
this.logger?.warn?.('[proxy] skillUpdater.pollAndApply failed:', e.message);
|
|
269
|
-
}
|
|
270
|
-
try { this.traceControl?.pollAndApply(); } catch (e) {
|
|
271
|
-
this.logger?.warn?.('[proxy] traceControl.pollAndApply failed:', e.message);
|
|
272
|
-
}
|
|
273
|
-
},
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
const proxyHandlers = {
|
|
277
|
-
// /a2a/fetch and /a2a/validate are strict GEP-A2A protocol endpoints:
|
|
278
|
-
// the hub runs isValidProtocolMessage and rejects bare bodies
|
|
279
|
-
// ({asset_ids: [...]}) with 400 invalid_protocol_message, so wrap them
|
|
280
|
-
// in an envelope first. The GET search endpoints below are lenient REST
|
|
281
|
-
// and take plain query params -- no envelope there.
|
|
282
|
-
assetFetch: (body) => this._proxyHttp('/a2a/fetch', this._wrapA2a('fetch', body)),
|
|
283
|
-
// GET (not POST). planAssetSearch() picks signal-search vs semantic-search
|
|
284
|
-
// by whether the body carries a free-text `query` or a `signals` list.
|
|
285
|
-
assetSearch: (body) => this._assetSearch(body),
|
|
286
|
-
assetValidate: (body) => this._proxyHttp('/a2a/validate', this._wrapA2a('validate', body)),
|
|
287
|
-
assetPublish: (body) => this._assetPublish(body),
|
|
288
|
-
// Reuse-attribution report -> hub /a2a/memory/record. FLAT body (not an
|
|
289
|
-
// envelope): the record endpoint reads sender_id/signals/used_asset_ids at
|
|
290
|
-
// the top level, so this goes through _reportReuse (like the lenient REST
|
|
291
|
-
// search path), not _wrapA2a.
|
|
292
|
-
reportReuse: (body) => this._reportReuse(body),
|
|
293
|
-
// ATP passthrough (#460 Bug 2): merchant/consumer flows that used to call
|
|
294
|
-
// hub directly via src/atp/hubClient.js must route through the proxy when
|
|
295
|
-
// EVOMAP_PROXY=1 so proxy sees the transaction (for audit + offline queue).
|
|
296
|
-
atpPost: (endpoint, body) => this._proxyHttp(endpoint, body),
|
|
297
|
-
atpGet: (endpoint, query) => this._proxyHttp(endpoint, null, { method: 'GET', query }),
|
|
298
|
-
};
|
|
299
|
-
|
|
300
|
-
const messagesHandler = buildMessagesHandler({
|
|
301
|
-
// Provider dispatch: EVOMAP_UPSTREAM read per-request (matches the
|
|
302
|
-
// hot-swap policy used for ANTHROPIC_API_KEY at line 266 below).
|
|
303
|
-
// Default 'anthropic' keeps the existing path byte-for-byte; 'bedrock'
|
|
304
|
-
// forwards via AWS Bedrock InvokeModel/InvokeModelWithResponseStream
|
|
305
|
-
// and re-emits standard SSE so the client contract is unchanged.
|
|
306
|
-
anthropicProxy: (reqPath, body, opts) => {
|
|
307
|
-
// Mode is decided once per request in messages_route.js (the same
|
|
308
|
-
// place the auth gate reads it), then passed in via opts.upstreamMode.
|
|
309
|
-
// This makes the gate decision and the routing decision share one
|
|
310
|
-
// env read, so a hot-swap of EVOMAP_UPSTREAM mid-request can't make
|
|
311
|
-
// them disagree (e.g. gate skipped but request still hits Anthropic).
|
|
312
|
-
const mode = opts?.upstreamMode || 'anthropic';
|
|
313
|
-
return mode === 'bedrock'
|
|
314
|
-
? this._proxyBedrock(reqPath, body, opts)
|
|
315
|
-
: this._proxyAnthropic(reqPath, body, opts);
|
|
316
|
-
},
|
|
317
|
-
logger: this.logger,
|
|
318
|
-
traceStore: this.store,
|
|
319
|
-
onTraceQueued: () => this.sync?.notifyNewOutbound(),
|
|
320
|
-
});
|
|
321
|
-
const responsesHandler = buildResponsesHandler({
|
|
322
|
-
openAIProxy: (reqPath, body, opts) => this._proxyOpenAIResponses(reqPath, body, opts),
|
|
323
|
-
logger: this.logger,
|
|
324
|
-
traceStore: this.store,
|
|
325
|
-
onTraceQueued: () => this.sync?.notifyNewOutbound(),
|
|
326
|
-
});
|
|
327
|
-
const geminiHandler = buildGeminiHandler({
|
|
328
|
-
geminiProxy: (reqPath, body, opts) => this._proxyGemini(reqPath, body, opts),
|
|
329
|
-
logger: this.logger,
|
|
330
|
-
traceStore: this.store,
|
|
331
|
-
onTraceQueued: () => this.sync?.notifyNewOutbound(),
|
|
332
|
-
});
|
|
333
|
-
const chatCompletionsHandler = buildChatCompletionsHandler({
|
|
334
|
-
openAIProxy: (reqPath, body, opts) => this._proxyOpenAIResponses(reqPath, body, opts),
|
|
335
|
-
logger: this.logger,
|
|
336
|
-
traceStore: this.store,
|
|
337
|
-
onTraceQueued: () => this.sync?.notifyNewOutbound(),
|
|
338
|
-
});
|
|
339
|
-
const modelsHandler = buildModelsHandler({
|
|
340
|
-
// Models list goes straight to the native upstream (never bedrock — /v1/models is an Anthropic/OpenAI
|
|
341
|
-
// API concept), so call the provider methods directly rather than the EVOMAP_UPSTREAM-aware closure.
|
|
342
|
-
anthropicProxy: (reqPath, body, opts) => this._proxyAnthropic(reqPath, body, opts),
|
|
343
|
-
openAIProxy: (reqPath, body, opts) => this._proxyOpenAIResponses(reqPath, body, opts),
|
|
344
|
-
logger: this.logger,
|
|
345
|
-
});
|
|
346
|
-
const ollamaProxy = (reqPath, body, opts) => this._proxyOllama(reqPath, body, opts);
|
|
347
|
-
const ollamaTraceOpts = { logger: this.logger, traceStore: this.store, onTraceQueued: () => this.sync?.notifyNewOutbound() };
|
|
348
|
-
const ollamaChatHandler = buildOllamaHandler({ ollamaProxy, apiPath: '/api/chat', ...ollamaTraceOpts });
|
|
349
|
-
const ollamaGenerateHandler = buildOllamaHandler({ ollamaProxy, apiPath: '/api/generate', ...ollamaTraceOpts });
|
|
350
|
-
const vertexHandler = buildVertexHandler({
|
|
351
|
-
vertexProxy: (reqPath, body, opts) => this._proxyVertex(reqPath, body, opts),
|
|
352
|
-
logger: this.logger,
|
|
353
|
-
traceStore: this.store,
|
|
354
|
-
onTraceQueued: () => this.sync?.notifyNewOutbound(),
|
|
355
|
-
});
|
|
356
|
-
|
|
357
|
-
const routes = buildRoutes(this.store, proxyHandlers, this.taskMonitor, {
|
|
358
|
-
dmHandler: this.dmHandler,
|
|
359
|
-
skillUpdater: this.skillUpdater,
|
|
360
|
-
sessionHandler: this.sessionHandler,
|
|
361
|
-
getHubMailboxStatus: () => this._getHubMailboxStatus(),
|
|
362
|
-
messagesHandler,
|
|
363
|
-
responsesHandler,
|
|
364
|
-
geminiHandler,
|
|
365
|
-
chatCompletionsHandler,
|
|
366
|
-
modelsHandler,
|
|
367
|
-
ollamaChatHandler,
|
|
368
|
-
ollamaGenerateHandler,
|
|
369
|
-
vertexHandler,
|
|
370
|
-
});
|
|
371
|
-
|
|
372
|
-
const OUTBOUND_ROUTES = [
|
|
373
|
-
'POST /mailbox/send',
|
|
374
|
-
'POST /asset/submit',
|
|
375
|
-
'POST /task/claim',
|
|
376
|
-
'POST /task/complete',
|
|
377
|
-
'POST /task/subscribe',
|
|
378
|
-
'POST /task/unsubscribe',
|
|
379
|
-
'POST /dm/send',
|
|
380
|
-
'POST /session/create',
|
|
381
|
-
'POST /session/join',
|
|
382
|
-
'POST /session/leave',
|
|
383
|
-
'POST /session/message',
|
|
384
|
-
'POST /session/delegate',
|
|
385
|
-
'POST /session/submit',
|
|
386
|
-
];
|
|
387
|
-
for (const key of OUTBOUND_ROUTES) {
|
|
388
|
-
const original = routes[key];
|
|
389
|
-
if (!original) continue;
|
|
390
|
-
routes[key] = async (ctx) => {
|
|
391
|
-
const result = await original(ctx);
|
|
392
|
-
this.sync.notifyNewOutbound();
|
|
393
|
-
return result;
|
|
394
|
-
};
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
this.server = new ProxyHttpServer(routes, {
|
|
398
|
-
port: this.port,
|
|
399
|
-
logger: this.logger,
|
|
400
|
-
clientSettings: this.clientSettings,
|
|
401
|
-
});
|
|
402
|
-
|
|
403
|
-
const serverInfo = await this.server.start();
|
|
404
|
-
|
|
405
|
-
if (this.hubUrl) {
|
|
406
|
-
await this.lifecycle.hello();
|
|
407
|
-
this.lifecycle.startHeartbeatLoop();
|
|
408
|
-
this.sync.start();
|
|
409
|
-
} else {
|
|
410
|
-
this.logger.warn('[proxy] No A2A_HUB_URL set, running in offline/local mode');
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
this._drainProxyTraceBackfill({ maxMs: TRACE_BACKFILL_STARTUP_DRAIN_MAX_MS });
|
|
414
|
-
|
|
415
|
-
this._started = true;
|
|
416
|
-
|
|
417
|
-
return {
|
|
418
|
-
url: serverInfo.url,
|
|
419
|
-
port: serverInfo.port,
|
|
420
|
-
nodeId: this.lifecycle.nodeId,
|
|
421
|
-
};
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
_runProxyTraceBackfillPass() {
|
|
425
|
-
try {
|
|
426
|
-
return backfillProxyTraceUploads({
|
|
427
|
-
store: this.store,
|
|
428
|
-
logger: this.logger,
|
|
429
|
-
});
|
|
430
|
-
} catch (e) {
|
|
431
|
-
this.logger.warn('[proxy] trace backfill failed:', e && e.message ? e.message : e);
|
|
432
|
-
return { queued: 0, reasons: { thrown: 1 } };
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
_drainProxyTraceBackfill({
|
|
437
|
-
maxPasses = TRACE_BACKFILL_DRAIN_MAX_PASSES,
|
|
438
|
-
maxMs = TRACE_BACKFILL_RUNTIME_DRAIN_MAX_MS,
|
|
439
|
-
} = {}) {
|
|
440
|
-
if (this._traceBackfillDraining) return { queued: 0, passes: 0, deferred: true };
|
|
441
|
-
this._traceBackfillDraining = true;
|
|
442
|
-
const started = Date.now();
|
|
443
|
-
const total = {
|
|
444
|
-
queued: 0,
|
|
445
|
-
scanned: 0,
|
|
446
|
-
skipped: 0,
|
|
447
|
-
duplicates: 0,
|
|
448
|
-
passes: 0,
|
|
449
|
-
reasons: {},
|
|
450
|
-
};
|
|
451
|
-
try {
|
|
452
|
-
for (let i = 0; i < maxPasses; i++) {
|
|
453
|
-
const stats = this._runProxyTraceBackfillPass();
|
|
454
|
-
total.passes += 1;
|
|
455
|
-
total.queued += stats.queued || 0;
|
|
456
|
-
total.scanned += stats.scanned || 0;
|
|
457
|
-
total.skipped += stats.skipped || 0;
|
|
458
|
-
total.duplicates += stats.duplicates || 0;
|
|
459
|
-
for (const [reason, count] of Object.entries(stats.reasons || {})) {
|
|
460
|
-
total.reasons[reason] = (total.reasons[reason] || 0) + count;
|
|
461
|
-
}
|
|
462
|
-
const madeProgress = (stats.scanned || 0) > 0
|
|
463
|
-
|| (stats.queued || 0) > 0
|
|
464
|
-
|| (stats.skipped || 0) > 0
|
|
465
|
-
|| (stats.duplicates || 0) > 0;
|
|
466
|
-
if (!madeProgress) break;
|
|
467
|
-
if (stats.reasons?.max_pending_uploads || stats.reasons?.max_enqueue_bytes
|
|
468
|
-
|| stats.reasons?.collection_disabled
|
|
469
|
-
|| stats.reasons?.missing_file || stats.reasons?.missing_store
|
|
470
|
-
|| stats.reasons?.read_failed || stats.reasons?.thrown) {
|
|
471
|
-
break;
|
|
472
|
-
}
|
|
473
|
-
if (Date.now() - started >= maxMs) break;
|
|
474
|
-
}
|
|
475
|
-
} finally {
|
|
476
|
-
this._traceBackfillDraining = false;
|
|
477
|
-
}
|
|
478
|
-
if (total.queued > 0) {
|
|
479
|
-
this.logger.log('[proxy] queued ' + total.queued + ' existing proxy trace upload(s)');
|
|
480
|
-
this.sync?.notifyNewOutbound();
|
|
481
|
-
}
|
|
482
|
-
return total;
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
async stop() {
|
|
486
|
-
if (!this._started) return;
|
|
487
|
-
// Tear down in deliberate reverse-of-start order, but don't let one
|
|
488
|
-
// failing step abort the rest: a thrown sync.stop() must not leave the
|
|
489
|
-
// HTTP server and store leaked. Each step is isolated; failures are
|
|
490
|
-
// warned and collected so shutdown always completes.
|
|
491
|
-
const steps = [
|
|
492
|
-
['sync', () => this.sync?.stop()],
|
|
493
|
-
['heartbeat', () => this.lifecycle?.stopHeartbeatLoop()],
|
|
494
|
-
['server', () => this.server?.stop()],
|
|
495
|
-
['store', () => this.store?.close()],
|
|
496
|
-
];
|
|
497
|
-
const errors = [];
|
|
498
|
-
for (const [name, fn] of steps) {
|
|
499
|
-
try {
|
|
500
|
-
await fn();
|
|
501
|
-
} catch (err) {
|
|
502
|
-
errors.push(err);
|
|
503
|
-
this.logger.warn('[proxy] error stopping ' + name + ': ' + (err && err.message ? err.message : err));
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
this._started = false;
|
|
507
|
-
if (errors.length) {
|
|
508
|
-
this.logger.log('[proxy] stopped with ' + errors.length + ' teardown error(s)');
|
|
509
|
-
} else {
|
|
510
|
-
this.logger.log('[proxy] stopped');
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
get mailbox() {
|
|
515
|
-
return this.store;
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
// Wrap a bare body in a GEP-A2A envelope (pass-through if already one),
|
|
519
|
-
// signing it with this proxy's node_id as sender_id so callers cannot
|
|
520
|
-
// impersonate another node through the proxy.
|
|
521
|
-
_wrapA2a(messageType, body) {
|
|
522
|
-
return ensureEnvelope(messageType, body, this.store.getState('node_id'));
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
async _proxyHttp(path, body, opts = {}) {
|
|
526
|
-
if (!this.hubUrl) throw Object.assign(new Error('Hub not configured'), { statusCode: 503 });
|
|
527
|
-
|
|
528
|
-
const method = (opts.method || 'POST').toUpperCase();
|
|
529
|
-
const query = opts.query && typeof opts.query === 'object' ? opts.query : null;
|
|
530
|
-
const timeoutMs = opts.timeoutMs || 30_000;
|
|
531
|
-
|
|
532
|
-
let fullPath = path;
|
|
533
|
-
if (query) {
|
|
534
|
-
const qs = new URLSearchParams();
|
|
535
|
-
for (const [k, v] of Object.entries(query)) {
|
|
536
|
-
if (v !== undefined && v !== null) qs.set(k, String(v));
|
|
537
|
-
}
|
|
538
|
-
const qsString = qs.toString();
|
|
539
|
-
if (qsString) fullPath += (path.includes('?') ? '&' : '?') + qsString;
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
const endpoint = `${this.hubUrl}${fullPath}`;
|
|
543
|
-
const init = {
|
|
544
|
-
method,
|
|
545
|
-
headers: this.lifecycle._buildHeaders(),
|
|
546
|
-
signal: AbortSignal.timeout(timeoutMs),
|
|
547
|
-
};
|
|
548
|
-
if (method !== 'GET' && method !== 'HEAD') {
|
|
549
|
-
init.body = JSON.stringify(body || {});
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
const res = await hubFetch(endpoint, init);
|
|
553
|
-
|
|
554
|
-
if (res.status === 403 || res.status === 401) {
|
|
555
|
-
const recovered = await this.lifecycle.reAuthenticate();
|
|
556
|
-
if (recovered) {
|
|
557
|
-
const retryInit = {
|
|
558
|
-
method,
|
|
559
|
-
headers: this.lifecycle._buildHeaders(),
|
|
560
|
-
signal: AbortSignal.timeout(timeoutMs),
|
|
561
|
-
};
|
|
562
|
-
if (method !== 'GET' && method !== 'HEAD') {
|
|
563
|
-
retryInit.body = JSON.stringify(body || {});
|
|
564
|
-
}
|
|
565
|
-
const retry = await hubFetch(endpoint, retryInit);
|
|
566
|
-
if (!retry.ok) {
|
|
567
|
-
const text = await retry.text().catch(() => '');
|
|
568
|
-
throw Object.assign(new Error(`Hub ${retry.status}: ${sanitizeHubResponseForLog(text)}`), { statusCode: retry.status });
|
|
569
|
-
}
|
|
570
|
-
return retry.json();
|
|
571
|
-
}
|
|
572
|
-
const text = await res.text().catch(() => '');
|
|
573
|
-
throw Object.assign(new Error(`Hub ${res.status} (re-auth failed): ${sanitizeHubResponseForLog(text)}`), { statusCode: res.status });
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
if (!res.ok) {
|
|
577
|
-
const text = await res.text().catch(() => '');
|
|
578
|
-
const err = Object.assign(new Error(`Hub ${res.status}: ${sanitizeHubResponseForLog(text)}`), { statusCode: res.status });
|
|
579
|
-
if (res.status === 429) err.retryAfterMs = parseRetryAfterMs(res, text);
|
|
580
|
-
throw err;
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
return res.json();
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
// Build the hub asset-search plan with this node's identity attached, so the
|
|
587
|
-
// hub's bulkFetchGuard / per-node metering attributes the call to the right
|
|
588
|
-
// node instead of treating every fleet member as one anonymous IP. As with
|
|
589
|
-
// _wrapA2a, we always stamp the proxy's OWN node_id (never a caller-supplied
|
|
590
|
-
// one) so a client cannot attribute its searches to another node through the
|
|
591
|
-
// proxy. node_id is advisory; the hub still gates on the Bearer node_secret.
|
|
592
|
-
_planAssetSearchWithNode(body) {
|
|
593
|
-
const plan = planAssetSearch(body);
|
|
594
|
-
const nodeId = this.store && this.store.getState && this.store.getState('node_id');
|
|
595
|
-
if (nodeId) plan.query = { ...plan.query, node_id: nodeId };
|
|
596
|
-
return plan;
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
// Stable cache key: same path + same (order-independent) query params.
|
|
600
|
-
_assetSearchCacheKey(plan) {
|
|
601
|
-
const q = plan.query || {};
|
|
602
|
-
const stable = Object.keys(q).sort().map((k) => `${k}=${q[k]}`).join('&');
|
|
603
|
-
return `${plan.path}?${stable}`;
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
_cacheSearchResult(key, value, now) {
|
|
607
|
-
// Bound memory: Map preserves insertion order, so the first key is oldest.
|
|
608
|
-
if (this._searchCache.size >= ASSET_SEARCH_CACHE_MAX && !this._searchCache.has(key)) {
|
|
609
|
-
const oldest = this._searchCache.keys().next().value;
|
|
610
|
-
if (oldest !== undefined) this._searchCache.delete(oldest);
|
|
611
|
-
}
|
|
612
|
-
this._searchCache.delete(key); // re-insert to refresh recency
|
|
613
|
-
this._searchCache.set(key, {
|
|
614
|
-
value,
|
|
615
|
-
expiresAt: now + ASSET_SEARCH_CACHE_TTL_MS,
|
|
616
|
-
staleUntil: now + ASSET_SEARCH_CACHE_TTL_MS + ASSET_SEARCH_STALE_GRACE_MS,
|
|
617
|
-
});
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
// Asset search with client-side relief: fresh-cache short-circuit, concurrent
|
|
621
|
-
// dedup, and Retry-After-aware cooldown. Preserves the original return shape
|
|
622
|
-
// and the 429 error contract so existing callers (and their "proceed on local
|
|
623
|
-
// evidence" fallback) are unaffected.
|
|
624
|
-
// Report which fetched assets the agent reused, so the hub credits their
|
|
625
|
-
// authors (reuse-reward attribution). Unlike fetch/validate (strict GEP-A2A
|
|
626
|
-
// envelopes), the hub's /a2a/memory/record reads a FLAT top-level body
|
|
627
|
-
// ({sender_id, signals, status, used_asset_ids}); envelope-wrapping would bury
|
|
628
|
-
// those under .payload and the record would 400. The proxy reports as its OWN
|
|
629
|
-
// node -- the same node that fetched the asset -- so the hub's buildAttribution
|
|
630
|
-
// finds the matching AssetFetcher row (cross-owner + GDI verified hub-side).
|
|
631
|
-
// Declaration model, never server-inferred. Best-effort: a report failure
|
|
632
|
-
// (insufficient credits, hub error) must never break the agent's session.
|
|
633
|
-
// Kill-switch: EVOLVER_PROXY_REPORT_REUSE=0.
|
|
634
|
-
async _reportReuse(body) {
|
|
635
|
-
if (process.env.EVOLVER_PROXY_REPORT_REUSE === '0') {
|
|
636
|
-
return { ok: false, reason: 'report_reuse_disabled' };
|
|
637
|
-
}
|
|
638
|
-
const nodeId = this.store.getState('node_id');
|
|
639
|
-
if (!nodeId) return { ok: false, reason: 'no_node_id' };
|
|
640
|
-
|
|
641
|
-
const b = body || {};
|
|
642
|
-
const used = Array.isArray(b.used_asset_ids)
|
|
643
|
-
? b.used_asset_ids.filter((x) => typeof x === 'string' && x.length > 0 && x.length <= 200).slice(0, 50)
|
|
644
|
-
: [];
|
|
645
|
-
if (used.length === 0) return { ok: false, reason: 'no_used_asset_ids' };
|
|
646
|
-
|
|
647
|
-
let signals = Array.isArray(b.signals)
|
|
648
|
-
? b.signals.filter((s) => typeof s === 'string' && s.length > 0).slice(0, 32)
|
|
649
|
-
: [];
|
|
650
|
-
if (signals.length === 0) signals = ['reused_via_mcp'];
|
|
651
|
-
|
|
652
|
-
const flat = {
|
|
653
|
-
sender_id: nodeId,
|
|
654
|
-
signals,
|
|
655
|
-
status: b.status === 'failed' ? 'failed' : 'success',
|
|
656
|
-
used_asset_ids: used,
|
|
657
|
-
...(typeof b.score === 'number' ? { score: b.score } : {}),
|
|
658
|
-
};
|
|
659
|
-
|
|
660
|
-
try {
|
|
661
|
-
const res = await this._proxyHttp('/a2a/memory/record', flat);
|
|
662
|
-
return { ok: true, recorded: res && res.recorded, used_asset_ids: used };
|
|
663
|
-
} catch (err) {
|
|
664
|
-
this.logger?.warn?.(`[proxy] report-reuse failed: ${err.message}`);
|
|
665
|
-
return { ok: false, reason: err.message, statusCode: err.statusCode };
|
|
666
|
-
}
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
async _assetSearch(body) {
|
|
670
|
-
const plan = this._planAssetSearchWithNode(body);
|
|
671
|
-
const key = this._assetSearchCacheKey(plan);
|
|
672
|
-
const now = Date.now();
|
|
673
|
-
|
|
674
|
-
// Fresh cache hit — skip the network entirely.
|
|
675
|
-
const cached = this._searchCache.get(key);
|
|
676
|
-
if (cached && cached.expiresAt > now) return cached.value;
|
|
677
|
-
|
|
678
|
-
// Cooldown set by a prior 429: don't fire (it would only fail and burn more
|
|
679
|
-
// of the shared bucket). Serve stale within grace if we have it; otherwise
|
|
680
|
-
// surface a 429-shaped error so the caller's fallback kicks in with no
|
|
681
|
-
// wasted round-trip.
|
|
682
|
-
if (now < this._searchCooldownUntil) {
|
|
683
|
-
if (cached && cached.staleUntil > now) return cached.value;
|
|
684
|
-
throw Object.assign(new Error('Hub 429: rate_limited (client cooldown)'), {
|
|
685
|
-
statusCode: 429,
|
|
686
|
-
retryAfterMs: this._searchCooldownUntil - now,
|
|
687
|
-
fromCooldown: true,
|
|
688
|
-
});
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
// Collapse concurrent identical searches into one in-flight request.
|
|
692
|
-
const inflight = this._searchInflight.get(key);
|
|
693
|
-
if (inflight) return inflight;
|
|
694
|
-
|
|
695
|
-
const p = (async () => {
|
|
696
|
-
try {
|
|
697
|
-
const value = await this._proxyHttp(plan.path, null, { method: 'GET', query: plan.query });
|
|
698
|
-
this._cacheSearchResult(key, value, Date.now());
|
|
699
|
-
return value;
|
|
700
|
-
} catch (err) {
|
|
701
|
-
if (err && err.statusCode === 429) {
|
|
702
|
-
const retryAfterMs = Number(err.retryAfterMs) > 0 ? Number(err.retryAfterMs) : ASSET_SEARCH_CACHE_TTL_MS;
|
|
703
|
-
this._searchCooldownUntil = Date.now() + retryAfterMs;
|
|
704
|
-
if (this.logger && this.logger.warn) {
|
|
705
|
-
this.logger.warn(`[proxy] asset search rate-limited by hub; cooling down ${Math.ceil(retryAfterMs / 1000)}s`);
|
|
706
|
-
}
|
|
707
|
-
// Prefer serving stale over failing the caller during cooldown.
|
|
708
|
-
const stale = this._searchCache.get(key);
|
|
709
|
-
if (stale && stale.staleUntil > Date.now()) return stale.value;
|
|
710
|
-
}
|
|
711
|
-
throw err;
|
|
712
|
-
} finally {
|
|
713
|
-
this._searchInflight.delete(key);
|
|
714
|
-
}
|
|
715
|
-
})();
|
|
716
|
-
|
|
717
|
-
this._searchInflight.set(key, p);
|
|
718
|
-
return p;
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
// Cross-agent / MCP publish. The legacy `evolver_publish_asset` path queued
|
|
722
|
-
// an `asset_submit` mailbox message, but the Hub gated that off
|
|
723
|
-
// (A2A_MAILBOX_ASSET_SUBMIT_ENABLED) and now enforces signed Gene+Capsule
|
|
724
|
-
// bundles on POST /a2a/publish. Route loose asset submits through
|
|
725
|
-
// buildPublishBundle so publishing actually reaches the Hub (single-asset
|
|
726
|
-
// publish is rejected; the Hub quarantines new bundles for safety review).
|
|
727
|
-
async _assetPublish(body) {
|
|
728
|
-
const a2a = require('../gep/a2aProtocol');
|
|
729
|
-
const nodeId = this.store && this.store.getState && this.store.getState('node_id');
|
|
730
|
-
const rawAssets = Array.isArray(body.assets) ? body.assets : (body.asset ? [body.asset] : []);
|
|
731
|
-
if (rawAssets.length === 0) {
|
|
732
|
-
throw Object.assign(new Error('assets is required'), { statusCode: 400 });
|
|
733
|
-
}
|
|
734
|
-
const results = [];
|
|
735
|
-
for (const raw of rawAssets) {
|
|
736
|
-
try {
|
|
737
|
-
const { gene, capsule } = this._buildBundleFromLooseAsset(raw);
|
|
738
|
-
const msg = a2a.buildPublishBundle({ gene, capsule, nodeId });
|
|
739
|
-
const res = await this._proxyHttp('/a2a/publish', msg);
|
|
740
|
-
results.push({ ok: true, gene_asset_id: gene.asset_id, capsule_asset_id: capsule.asset_id, response: res });
|
|
741
|
-
} catch (err) {
|
|
742
|
-
results.push({ ok: false, error: err.message, statusCode: err.statusCode });
|
|
743
|
-
}
|
|
744
|
-
}
|
|
745
|
-
return { published: results.filter(r => r.ok).length, total: results.length, results };
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
// Build a Hub-valid Gene+Capsule bundle from a loose MCP asset
|
|
749
|
-
// ({type, content, summary, signals}). The Hub quality-gates genes (strategy
|
|
750
|
-
// >=2 steps >=15 chars; sandboxable `node -e` validation) and requires a
|
|
751
|
-
// companion capsule carrying substantive content; fill the structural
|
|
752
|
-
// defaults and map the caller's content into strategy/content. Caller-
|
|
753
|
-
// supplied strategy/validation/category/outcome win when present.
|
|
754
|
-
_buildBundleFromLooseAsset(raw) {
|
|
755
|
-
const crypto = require('crypto');
|
|
756
|
-
const { SCHEMA_VERSION } = require('../gep/contentHash');
|
|
757
|
-
const r = raw || {};
|
|
758
|
-
const text = String(r.content || r.summary || '').trim();
|
|
759
|
-
const signals = (Array.isArray(r.signals) && r.signals.length) ? r.signals
|
|
760
|
-
: (Array.isArray(r.signals_match) && r.signals_match.length) ? r.signals_match : ['user_request'];
|
|
761
|
-
// Gene strategy: the Hub requires >=2 actionable steps, each >=15 chars.
|
|
762
|
-
// Enforce upfront (clean 400) rather than letting the Hub reject the publish.
|
|
763
|
-
// Bugbot #256: caller-supplied short steps and a sub-50-char capsule content
|
|
764
|
-
// previously slipped past the proxy and got rejected at the Hub instead.
|
|
765
|
-
let strategy;
|
|
766
|
-
if (Array.isArray(r.strategy) && r.strategy.length) {
|
|
767
|
-
strategy = r.strategy.map((s) => String(s).trim()).filter((s) => s.length >= 15);
|
|
768
|
-
if (strategy.length < 2) {
|
|
769
|
-
throw Object.assign(new Error('publish: `strategy` needs >=2 steps, each >=15 chars (Hub quality gate).'), { statusCode: 400 });
|
|
770
|
-
}
|
|
771
|
-
} else {
|
|
772
|
-
const steps = text.split(/[.\n;]+/).map((s) => s.trim()).filter((s) => s.length >= 15);
|
|
773
|
-
if (steps.length >= 2) {
|
|
774
|
-
strategy = steps.slice(0, 8);
|
|
775
|
-
} else if (text.length >= 50) {
|
|
776
|
-
strategy = [text.slice(0, 200), 'Validate the result before adopting the change'];
|
|
777
|
-
} else {
|
|
778
|
-
throw Object.assign(new Error('publish: provide `content` (>=50 chars) or a `strategy` of >=2 steps (each >=15 chars); the Hub quality-gates published genes.'), { statusCode: 400 });
|
|
779
|
-
}
|
|
780
|
-
}
|
|
781
|
-
const VALID_CATEGORIES = ['repair', 'optimize', 'innovate', 'explore'];
|
|
782
|
-
const schemaVersion = r.schema_version || SCHEMA_VERSION;
|
|
783
|
-
const gid = r.gene_id || ('mcp_g_' + crypto.randomBytes(6).toString('hex'));
|
|
784
|
-
const summary = r.summary || text.slice(0, 120) || 'manually published asset';
|
|
785
|
-
// Capsule needs >=50 chars of substance (Hub gate); guarantee it upfront.
|
|
786
|
-
const capsuleContent = text.length >= 50 ? text : (summary + ' — ' + strategy.join(' ')).trim();
|
|
787
|
-
if (capsuleContent.length < 50) {
|
|
788
|
-
throw Object.assign(new Error('publish: capsule content resolves to <50 chars; provide a longer `content` or `summary`.'), { statusCode: 400 });
|
|
789
|
-
}
|
|
790
|
-
const gene = {
|
|
791
|
-
type: 'Gene', schema_version: schemaVersion, id: gid,
|
|
792
|
-
category: VALID_CATEGORIES.includes(r.category) ? r.category : 'explore',
|
|
793
|
-
summary,
|
|
794
|
-
signals_match: signals,
|
|
795
|
-
strategy,
|
|
796
|
-
constraints: (r.constraints && typeof r.constraints === 'object') ? r.constraints : { max_files: 50, forbidden_paths: [] },
|
|
797
|
-
validation: (Array.isArray(r.validation) && r.validation.length) ? r.validation : ['node -e "if (![1].length) process.exit(1)"'],
|
|
798
|
-
};
|
|
799
|
-
const capsule = {
|
|
800
|
-
type: 'Capsule', schema_version: schemaVersion, id: 'mcp_c_' + crypto.randomBytes(6).toString('hex'),
|
|
801
|
-
trigger: signals, gene: gid, summary,
|
|
802
|
-
confidence: typeof r.confidence === 'number' ? r.confidence : 0.5,
|
|
803
|
-
blast_radius: { files: 1, lines: 1 },
|
|
804
|
-
env_fingerprint: { platform: process.platform, arch: process.arch },
|
|
805
|
-
outcome: (r.outcome && typeof r.outcome === 'object' && r.outcome.status) ? r.outcome : { status: 'success', score: 0.5 },
|
|
806
|
-
content: capsuleContent,
|
|
807
|
-
};
|
|
808
|
-
// Redact PII/secrets before publish (Bugbot #256 High). Without client-side
|
|
809
|
-
// sanitize, the Hub's server-side redaction rewrites the body and recomputes
|
|
810
|
-
// a divergent asset_id, causing persistent roundtrip_missing. Sanitize before
|
|
811
|
-
// buildPublishBundle stamps asset_id. Mirrors skill2gep's publish path.
|
|
812
|
-
const { sanitizePayload } = require('../gep/sanitize');
|
|
813
|
-
return { gene: sanitizePayload(gene), capsule: sanitizePayload(capsule) };
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
// Phase C slice 4 + token mediation: relay to api.anthropic.com. The
|
|
817
|
-
// route layer applies router rewrite and decides stream vs. JSON; this
|
|
818
|
-
// method forwards the request and exposes the response shape.
|
|
819
|
-
//
|
|
820
|
-
// Allowed forward headers (lowercased): x-api-key, anthropic-version,
|
|
821
|
-
// and anything matching anthropic-* (anthropic-beta, etc.). Everything
|
|
822
|
-
// else (host, authorization, cookie, content-length, ...) is dropped
|
|
823
|
-
// so the inbound proxy-auth header never leaks upstream.
|
|
824
|
-
//
|
|
825
|
-
// Token mediation: the proxy server's `Authorization: Bearer <token>`
|
|
826
|
-
// header is consumed by ProxyHttpServer for self-auth and stripped
|
|
827
|
-
// here, so clients (e.g. Claude Code) can authenticate to the proxy
|
|
828
|
-
// with `ANTHROPIC_AUTH_TOKEN=<proxy_token>` without losing the ability
|
|
829
|
-
// to reach Anthropic upstream. When the client did not pass x-api-key,
|
|
830
|
-
// the proxy substitutes its own EVOMAP_ANTHROPIC_API_KEY /
|
|
831
|
-
// ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN env var on the upstream request.
|
|
832
|
-
// Env is read per-request so creds
|
|
833
|
-
// can be hot-swapped without restart, matching the EVOMAP_MODEL_*
|
|
834
|
-
// policy in README.
|
|
835
|
-
async _proxyAnthropic(reqPath, body, opts = {}) {
|
|
836
|
-
const injectedUpstreamBaseUrl = process.env.EVOMAP_PROXY_AUTO_INJECTED === '1'
|
|
837
|
-
? process.env.EVOMAP_ANTHROPIC_BASE_URL
|
|
838
|
-
: '';
|
|
839
|
-
const baseUrl = (opts.baseUrl || injectedUpstreamBaseUrl || this._anthropicBaseUrl || '').replace(/\/+$/, '');
|
|
840
|
-
const inbound = opts.inboundHeaders || {};
|
|
841
|
-
const timeoutMs = opts.timeoutMs || 60_000;
|
|
842
|
-
|
|
843
|
-
const fwd = { 'content-type': 'application/json' };
|
|
844
|
-
for (const [k, v] of Object.entries(inbound)) {
|
|
845
|
-
if (v === undefined || v === null) continue;
|
|
846
|
-
const lk = k.toLowerCase();
|
|
847
|
-
if (lk === 'x-api-key' || lk === 'anthropic-version' || lk.startsWith('anthropic-')) {
|
|
848
|
-
fwd[lk] = Array.isArray(v) ? v.join(', ') : String(v);
|
|
849
|
-
}
|
|
850
|
-
}
|
|
851
|
-
|
|
852
|
-
if (!fwd['x-api-key']) {
|
|
853
|
-
const upstreamApiKey = process.env.EVOMAP_ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY;
|
|
854
|
-
if (upstreamApiKey) {
|
|
855
|
-
fwd['x-api-key'] = upstreamApiKey;
|
|
856
|
-
} else {
|
|
857
|
-
const upstreamAuthToken = process.env.EVOMAP_ANTHROPIC_AUTH_TOKEN
|
|
858
|
-
|| (process.env.EVOMAP_PROXY_AUTO_INJECTED === '1' ? '' : process.env.ANTHROPIC_AUTH_TOKEN);
|
|
859
|
-
if (upstreamAuthToken) {
|
|
860
|
-
fwd['authorization'] = `Bearer ${upstreamAuthToken}`;
|
|
861
|
-
}
|
|
862
|
-
}
|
|
863
|
-
}
|
|
864
|
-
|
|
865
|
-
const endpoint = `${baseUrl}${reqPath}`;
|
|
866
|
-
const method = (opts.method || 'POST').toUpperCase();
|
|
867
|
-
const init = { method, headers: fwd, signal: AbortSignal.timeout(timeoutMs) };
|
|
868
|
-
if (method !== 'GET' && method !== 'HEAD') init.body = JSON.stringify(body || {}); // GET (e.g. /v1/models) sends no body
|
|
869
|
-
const res = await fetch(endpoint, init);
|
|
870
|
-
|
|
871
|
-
const headers = Object.fromEntries(res.headers.entries());
|
|
872
|
-
const contentType = (headers['content-type'] || '').toLowerCase();
|
|
873
|
-
const isStream = contentType.includes('text/event-stream');
|
|
874
|
-
|
|
875
|
-
return {
|
|
876
|
-
status: res.status,
|
|
877
|
-
headers,
|
|
878
|
-
stream: isStream ? res.body : null,
|
|
879
|
-
json: isStream ? null : () => res.json(),
|
|
880
|
-
text: () => res.text(),
|
|
881
|
-
};
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
// OpenAI Responses-compatible passthrough for Codex custom providers. The
|
|
885
|
-
// proxy token is consumed by ProxyHttpServer and must never be forwarded as
|
|
886
|
-
// upstream auth; the daemon supplies the real upstream key from env.
|
|
887
|
-
async _proxyOpenAIResponses(reqPath, body, opts = {}) {
|
|
888
|
-
const baseUrl = resolveOpenAIBaseUrl(opts.baseUrl || this._openaiBaseUrl || DEFAULT_OPENAI_BASE_URL, {
|
|
889
|
-
trustedOverride: !!opts.baseUrl || this._openaiBaseUrlTrusted,
|
|
890
|
-
});
|
|
891
|
-
const inbound = opts.inboundHeaders || {};
|
|
892
|
-
const timeoutMs = opts.timeoutMs || 60_000;
|
|
893
|
-
|
|
894
|
-
const fwd = { 'content-type': 'application/json' };
|
|
895
|
-
for (const [k, v] of Object.entries(inbound)) {
|
|
896
|
-
if (v === undefined || v === null) continue;
|
|
897
|
-
const lk = k.toLowerCase();
|
|
898
|
-
if (
|
|
899
|
-
lk === 'openai-organization'
|
|
900
|
-
|| lk === 'openai-project'
|
|
901
|
-
|| lk === 'openai-beta'
|
|
902
|
-
|| lk.startsWith('x-stainless-')
|
|
903
|
-
) {
|
|
904
|
-
fwd[lk] = Array.isArray(v) ? v.join(', ') : String(v);
|
|
905
|
-
}
|
|
906
|
-
}
|
|
907
|
-
|
|
908
|
-
const upstreamKey = process.env.EVOMAP_OPENAI_API_KEY || process.env.OPENAI_API_KEY || '';
|
|
909
|
-
if (!upstreamKey) {
|
|
910
|
-
const err = new Error('openai api key required');
|
|
911
|
-
err.statusCode = 401;
|
|
912
|
-
throw err;
|
|
913
|
-
}
|
|
914
|
-
if (upstreamKey) {
|
|
915
|
-
fwd.authorization = `Bearer ${upstreamKey}`;
|
|
916
|
-
}
|
|
917
|
-
|
|
918
|
-
const endpoint = `${baseUrl}${reqPath}`;
|
|
919
|
-
const abortController = new AbortController();
|
|
920
|
-
const timeoutErr = new Error('openai upstream timed out');
|
|
921
|
-
timeoutErr.name = 'TimeoutError';
|
|
922
|
-
const abortTimer = setTimeout(() => abortController.abort(timeoutErr), timeoutMs);
|
|
923
|
-
abortTimer.unref?.();
|
|
924
|
-
const method = (opts.method || 'POST').toUpperCase();
|
|
925
|
-
const init = { method, headers: fwd, signal: abortController.signal };
|
|
926
|
-
if (method !== 'GET' && method !== 'HEAD') init.body = JSON.stringify(body || {}); // GET (e.g. /models) sends no body
|
|
927
|
-
let res;
|
|
928
|
-
try {
|
|
929
|
-
res = await fetch(endpoint, init);
|
|
930
|
-
} catch (err) {
|
|
931
|
-
clearTimeout(abortTimer);
|
|
932
|
-
throw makeOpenAIGatewayError(err);
|
|
933
|
-
}
|
|
934
|
-
|
|
935
|
-
const headers = Object.fromEntries(res.headers.entries());
|
|
936
|
-
const contentType = (headers['content-type'] || '').toLowerCase();
|
|
937
|
-
const isStream = contentType.includes('text/event-stream');
|
|
938
|
-
if (isStream) clearTimeout(abortTimer);
|
|
939
|
-
|
|
940
|
-
const readText = async () => {
|
|
941
|
-
try {
|
|
942
|
-
return await res.text();
|
|
943
|
-
} catch (err) {
|
|
944
|
-
throw makeOpenAIGatewayError(err);
|
|
945
|
-
} finally {
|
|
946
|
-
clearTimeout(abortTimer);
|
|
947
|
-
}
|
|
948
|
-
};
|
|
949
|
-
|
|
950
|
-
return {
|
|
951
|
-
status: res.status,
|
|
952
|
-
headers,
|
|
953
|
-
stream: isStream ? res.body : null,
|
|
954
|
-
json: isStream ? null : async () => JSON.parse(await readText()),
|
|
955
|
-
text: isStream ? null : readText,
|
|
956
|
-
};
|
|
957
|
-
}
|
|
958
|
-
|
|
959
|
-
// Gemini upstream (Google Generative Language API). Native passthrough — the model + action live in the path
|
|
960
|
-
// (`/v1beta/models/<model>:generateContent` | `:streamGenerateContent`), not the body, so we forward reqPath
|
|
961
|
-
// (incl. query like ?alt=sse) verbatim. Auth is the `x-goog-api-key` header (proxy-mediated). No translation:
|
|
962
|
-
// a Gemini-shaped request goes to a Gemini upstream, same return contract as the other providers.
|
|
963
|
-
async _proxyGemini(reqPath, body, opts = {}) {
|
|
964
|
-
const baseUrl = (opts.baseUrl || this._geminiBaseUrl || DEFAULT_GEMINI_BASE_URL).replace(/\/+$/, '');
|
|
965
|
-
const inbound = opts.inboundHeaders || {};
|
|
966
|
-
const timeoutMs = opts.timeoutMs || 60_000;
|
|
967
|
-
|
|
968
|
-
const fwd = { 'content-type': 'application/json' };
|
|
969
|
-
for (const [k, v] of Object.entries(inbound)) {
|
|
970
|
-
if (v === undefined || v === null) continue;
|
|
971
|
-
const lk = k.toLowerCase();
|
|
972
|
-
// Forward Gemini metadata headers; the api key is injected below (never trust the inbound one).
|
|
973
|
-
if (lk === 'x-goog-user-project' || lk === 'x-goog-api-client' || lk.startsWith('x-goog-request-')) {
|
|
974
|
-
fwd[lk] = Array.isArray(v) ? v.join(', ') : String(v);
|
|
975
|
-
}
|
|
976
|
-
}
|
|
977
|
-
|
|
978
|
-
const upstreamKey = process.env.EVOMAP_GEMINI_API_KEY || process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY || '';
|
|
979
|
-
if (!upstreamKey) {
|
|
980
|
-
const err = new Error('gemini api key required');
|
|
981
|
-
err.statusCode = 401;
|
|
982
|
-
throw err;
|
|
983
|
-
}
|
|
984
|
-
fwd['x-goog-api-key'] = upstreamKey;
|
|
985
|
-
|
|
986
|
-
const endpoint = `${baseUrl}${reqPath}`;
|
|
987
|
-
const abortController = new AbortController();
|
|
988
|
-
const timeoutErr = new Error('gemini upstream timed out');
|
|
989
|
-
timeoutErr.name = 'TimeoutError';
|
|
990
|
-
const abortTimer = setTimeout(() => abortController.abort(timeoutErr), timeoutMs);
|
|
991
|
-
abortTimer.unref?.();
|
|
992
|
-
let res;
|
|
993
|
-
try {
|
|
994
|
-
res = await fetch(endpoint, {
|
|
995
|
-
method: 'POST',
|
|
996
|
-
headers: fwd,
|
|
997
|
-
body: JSON.stringify(body || {}),
|
|
998
|
-
signal: abortController.signal,
|
|
999
|
-
});
|
|
1000
|
-
} catch (err) {
|
|
1001
|
-
clearTimeout(abortTimer);
|
|
1002
|
-
throw makeGeminiGatewayError(err);
|
|
1003
|
-
}
|
|
1004
|
-
|
|
1005
|
-
const headers = Object.fromEntries(res.headers.entries());
|
|
1006
|
-
const contentType = (headers['content-type'] || '').toLowerCase();
|
|
1007
|
-
// `:streamGenerateContent` IS a stream regardless of content-type: with ?alt=sse it is text/event-stream,
|
|
1008
|
-
// but the DEFAULT (no alt=sse) is a chunked JSON-array stream served as application/json. Detecting only by
|
|
1009
|
-
// content-type would buffer + JSON.parse that array stream and hand the client a broken {error:...} wrapper
|
|
1010
|
-
// instead of a live stream. Forward the body as a stream whenever the action is streamGenerateContent.
|
|
1011
|
-
const isStream = contentType.includes('text/event-stream') || /:streamGenerateContent(\b|\?|$)/.test(reqPath);
|
|
1012
|
-
if (isStream) clearTimeout(abortTimer);
|
|
1013
|
-
|
|
1014
|
-
const readText = async () => {
|
|
1015
|
-
try {
|
|
1016
|
-
return await res.text();
|
|
1017
|
-
} catch (err) {
|
|
1018
|
-
throw makeGeminiGatewayError(err);
|
|
1019
|
-
} finally {
|
|
1020
|
-
clearTimeout(abortTimer);
|
|
1021
|
-
}
|
|
1022
|
-
};
|
|
1023
|
-
|
|
1024
|
-
return {
|
|
1025
|
-
status: res.status,
|
|
1026
|
-
headers,
|
|
1027
|
-
stream: isStream ? res.body : null,
|
|
1028
|
-
json: isStream ? null : async () => JSON.parse(await readText()),
|
|
1029
|
-
text: isStream ? null : readText,
|
|
1030
|
-
};
|
|
1031
|
-
}
|
|
1032
|
-
|
|
1033
|
-
// Ollama native passthrough (local model server). Native paths /api/chat | /api/generate, body carries the
|
|
1034
|
-
// model + `stream` flag. Ollama is typically local with no auth; forward verbatim to EVOMAP_OLLAMA_BASE_URL
|
|
1035
|
-
// (default 127.0.0.1:11434). Optional bearer for a remote/protected Ollama via EVOMAP_OLLAMA_API_KEY. Streaming
|
|
1036
|
-
// is newline-delimited JSON (NDJSON), not SSE — content-type application/json + chunked. No translation.
|
|
1037
|
-
async _proxyOllama(reqPath, body, opts = {}) {
|
|
1038
|
-
const baseUrl = (opts.baseUrl || this._ollamaBaseUrl || DEFAULT_OLLAMA_BASE_URL).replace(/\/+$/, '');
|
|
1039
|
-
const timeoutMs = opts.timeoutMs || 60_000;
|
|
1040
|
-
const fwd = { 'content-type': 'application/json' };
|
|
1041
|
-
const upstreamKey = process.env.EVOMAP_OLLAMA_API_KEY || '';
|
|
1042
|
-
if (upstreamKey) fwd.authorization = `Bearer ${upstreamKey}`;
|
|
1043
|
-
|
|
1044
|
-
const endpoint = `${baseUrl}${reqPath}`;
|
|
1045
|
-
const abortController = new AbortController();
|
|
1046
|
-
const timeoutErr = new Error('ollama upstream timed out');
|
|
1047
|
-
timeoutErr.name = 'TimeoutError';
|
|
1048
|
-
const abortTimer = setTimeout(() => abortController.abort(timeoutErr), timeoutMs);
|
|
1049
|
-
abortTimer.unref?.();
|
|
1050
|
-
let res;
|
|
1051
|
-
try {
|
|
1052
|
-
res = await fetch(endpoint, { method: 'POST', headers: fwd, body: JSON.stringify(body || {}), signal: abortController.signal });
|
|
1053
|
-
} catch (err) {
|
|
1054
|
-
clearTimeout(abortTimer);
|
|
1055
|
-
throw makeOllamaGatewayError(err);
|
|
1056
|
-
}
|
|
1057
|
-
|
|
1058
|
-
const headers = Object.fromEntries(res.headers.entries());
|
|
1059
|
-
// Ollama streams when the request body has stream:true (default true). The reply is chunked NDJSON
|
|
1060
|
-
// (application/json), not SSE, so detect by the request flag, not content-type — buffering a stream would
|
|
1061
|
-
// break the client. Non-stream (stream:false) returns a single JSON object.
|
|
1062
|
-
const isStream = !(body && body.stream === false);
|
|
1063
|
-
if (isStream) clearTimeout(abortTimer);
|
|
1064
|
-
|
|
1065
|
-
const readText = async () => {
|
|
1066
|
-
try { return await res.text(); } catch (err) { throw makeOllamaGatewayError(err); } finally { clearTimeout(abortTimer); }
|
|
1067
|
-
};
|
|
1068
|
-
|
|
1069
|
-
return {
|
|
1070
|
-
status: res.status,
|
|
1071
|
-
headers,
|
|
1072
|
-
stream: isStream ? res.body : null,
|
|
1073
|
-
json: isStream ? null : async () => JSON.parse(await readText()),
|
|
1074
|
-
text: isStream ? null : readText,
|
|
1075
|
-
};
|
|
1076
|
-
}
|
|
1077
|
-
|
|
1078
|
-
// Vertex AI Gemini passthrough. Same Gemini body as the AI Studio route, but a Vertex path
|
|
1079
|
-
// (/v1/projects/<p>/locations/<l>/publishers/google/models/<model>:generateContent), a region-specific base
|
|
1080
|
-
// (<location>-aiplatform.googleapis.com, computed by the route and passed via opts.baseUrl), and OAuth Bearer
|
|
1081
|
-
// auth. The access token comes from EVOMAP_VERTEX_ACCESS_TOKEN (provisioned/refreshed by the daemon via
|
|
1082
|
-
// `gcloud auth print-access-token` or a token sidecar); SA-key auto-minting is a follow-up. No translation.
|
|
1083
|
-
async _proxyVertex(reqPath, body, opts = {}) {
|
|
1084
|
-
const baseUrl = String(opts.baseUrl || '').replace(/\/+$/, '');
|
|
1085
|
-
if (!baseUrl) { const e = new Error('vertex base url required'); e.statusCode = 500; throw e; }
|
|
1086
|
-
const token = process.env.EVOMAP_VERTEX_ACCESS_TOKEN || '';
|
|
1087
|
-
if (!token) { const e = new Error('vertex access token required'); e.statusCode = 401; throw e; }
|
|
1088
|
-
const timeoutMs = opts.timeoutMs || 60_000;
|
|
1089
|
-
const fwd = { 'content-type': 'application/json', authorization: `Bearer ${token}` };
|
|
1090
|
-
|
|
1091
|
-
const endpoint = `${baseUrl}${reqPath}`;
|
|
1092
|
-
const abortController = new AbortController();
|
|
1093
|
-
const timeoutErr = new Error('vertex upstream timed out');
|
|
1094
|
-
timeoutErr.name = 'TimeoutError';
|
|
1095
|
-
const abortTimer = setTimeout(() => abortController.abort(timeoutErr), timeoutMs);
|
|
1096
|
-
abortTimer.unref?.();
|
|
1097
|
-
let res;
|
|
1098
|
-
try {
|
|
1099
|
-
res = await fetch(endpoint, { method: 'POST', headers: fwd, body: JSON.stringify(body || {}), signal: abortController.signal });
|
|
1100
|
-
} catch (err) {
|
|
1101
|
-
clearTimeout(abortTimer);
|
|
1102
|
-
throw makeVertexGatewayError(err);
|
|
1103
|
-
}
|
|
1104
|
-
|
|
1105
|
-
const headers = Object.fromEntries(res.headers.entries());
|
|
1106
|
-
const contentType = (headers['content-type'] || '').toLowerCase();
|
|
1107
|
-
const isStream = contentType.includes('text/event-stream') || /:streamGenerateContent(\b|\?|$)/.test(reqPath);
|
|
1108
|
-
if (isStream) clearTimeout(abortTimer);
|
|
1109
|
-
|
|
1110
|
-
const readText = async () => {
|
|
1111
|
-
try { return await res.text(); } catch (err) { throw makeVertexGatewayError(err); } finally { clearTimeout(abortTimer); }
|
|
1112
|
-
};
|
|
1113
|
-
|
|
1114
|
-
return {
|
|
1115
|
-
status: res.status,
|
|
1116
|
-
headers,
|
|
1117
|
-
stream: isStream ? res.body : null,
|
|
1118
|
-
json: isStream ? null : async () => JSON.parse(await readText()),
|
|
1119
|
-
text: isStream ? null : readText,
|
|
1120
|
-
};
|
|
1121
|
-
}
|
|
1122
|
-
|
|
1123
|
-
// Bedrock upstream mode: same return contract as _proxyAnthropic so
|
|
1124
|
-
// messages_route.js and ProxyHttpServer._streamResponse don't change.
|
|
1125
|
-
// Body transformation: model -> URL path; inject anthropic_version;
|
|
1126
|
-
// strip top-level model so Bedrock InvokeModel doesn't 400. SDK owns
|
|
1127
|
-
// SigV4 signing (creds via AWS_* env or opts.bedrockCredentials for
|
|
1128
|
-
// tests) and AWS event-stream binary decoding; we only re-emit each
|
|
1129
|
-
// chunk as standard SSE so clients remain Anthropic-compatible.
|
|
1130
|
-
async _proxyBedrock(reqPath, body, opts = {}) {
|
|
1131
|
-
if (!this._bedrockSdk) {
|
|
1132
|
-
this._bedrockSdk = require('@aws-sdk/client-bedrock-runtime');
|
|
1133
|
-
}
|
|
1134
|
-
const {
|
|
1135
|
-
BedrockRuntimeClient,
|
|
1136
|
-
InvokeModelCommand,
|
|
1137
|
-
InvokeModelWithResponseStreamCommand,
|
|
1138
|
-
} = this._bedrockSdk;
|
|
1139
|
-
|
|
1140
|
-
// Defense-in-depth: when router is disabled (EVOMAP_ROUTER_ENABLED!=1)
|
|
1141
|
-
// the router handler skips the body-rewrite step, so a short inbound ID
|
|
1142
|
-
// would otherwise reach Bedrock InvokeModel and trigger ValidationException.
|
|
1143
|
-
// Re-canonicalize here; idempotent for already-canonical IDs from the
|
|
1144
|
-
// router-enabled path.
|
|
1145
|
-
const rawModel = body && typeof body.model === 'string' ? body.model : null;
|
|
1146
|
-
const modelId = rawModel ? canonicalizeForBedrock(rawModel) : null;
|
|
1147
|
-
if (!modelId) {
|
|
1148
|
-
const errBody = JSON.stringify({
|
|
1149
|
-
type: 'error',
|
|
1150
|
-
error: { type: 'invalid_request_error', message: 'body.model required for Bedrock upstream' },
|
|
1151
|
-
});
|
|
1152
|
-
return {
|
|
1153
|
-
status: 400,
|
|
1154
|
-
headers: { 'content-type': 'application/json' },
|
|
1155
|
-
stream: null,
|
|
1156
|
-
json: () => JSON.parse(errBody),
|
|
1157
|
-
text: () => errBody,
|
|
1158
|
-
};
|
|
1159
|
-
}
|
|
1160
|
-
|
|
1161
|
-
const upstreamBody = { ...body };
|
|
1162
|
-
delete upstreamBody.model;
|
|
1163
|
-
if (!upstreamBody.anthropic_version) {
|
|
1164
|
-
upstreamBody.anthropic_version = 'bedrock-2023-05-31';
|
|
1165
|
-
}
|
|
1166
|
-
const wantsStream = upstreamBody.stream === true;
|
|
1167
|
-
// Bedrock infers stream-vs-not from the command, not the body field.
|
|
1168
|
-
delete upstreamBody.stream;
|
|
1169
|
-
|
|
1170
|
-
// Claude Code v2.1.150+ sends `thinking: { type: 'adaptive' }` plus
|
|
1171
|
-
// `output_config.effort` for Opus 4.7+. Keep that shape for those models:
|
|
1172
|
-
// folding it to `enabled` makes current 4.7+ endpoints reject compaction
|
|
1173
|
-
// with: "thinking.type.enabled is not supported for this model".
|
|
1174
|
-
//
|
|
1175
|
-
// Older Bedrock-deployed 4.5/4.1 generation models only accept
|
|
1176
|
-
// 'enabled' | 'disabled'. Fold 'adaptive' for those older models:
|
|
1177
|
-
//
|
|
1178
|
-
// Two hard constraints collide:
|
|
1179
|
-
// - Anthropic: budget_tokens >= 1024 when thinking is enabled
|
|
1180
|
-
// - Bedrock: budget_tokens < max_tokens (strictly)
|
|
1181
|
-
//
|
|
1182
|
-
// For max_tokens <= 1024 there's no valid budget at all (1024 floor
|
|
1183
|
-
// would fail Bedrock's strict-less-than check), so we have to drop
|
|
1184
|
-
// thinking entirely on those calls — fold to 'disabled'. For larger
|
|
1185
|
-
// max_tokens we default to max_tokens/2 (the model picks budget in
|
|
1186
|
-
// adaptive mode, but Bedrock 'enabled' requires the field).
|
|
1187
|
-
const modelSupportsAdaptiveThinking = supportsAdaptiveThinking(modelId);
|
|
1188
|
-
if (
|
|
1189
|
-
!modelSupportsAdaptiveThinking
|
|
1190
|
-
&& upstreamBody.thinking
|
|
1191
|
-
&& upstreamBody.thinking.type === 'adaptive'
|
|
1192
|
-
) {
|
|
1193
|
-
const maxTokens = typeof upstreamBody.max_tokens === 'number' ? upstreamBody.max_tokens : 8192;
|
|
1194
|
-
const haveBudget = typeof upstreamBody.thinking.budget_tokens === 'number';
|
|
1195
|
-
if (!haveBudget && maxTokens <= 1024) {
|
|
1196
|
-
upstreamBody.thinking = { type: 'disabled' };
|
|
1197
|
-
} else {
|
|
1198
|
-
upstreamBody.thinking = {
|
|
1199
|
-
...upstreamBody.thinking,
|
|
1200
|
-
type: 'enabled',
|
|
1201
|
-
budget_tokens: haveBudget ? upstreamBody.thinking.budget_tokens : Math.max(1024, Math.floor(maxTokens / 2)),
|
|
1202
|
-
};
|
|
1203
|
-
}
|
|
1204
|
-
}
|
|
1205
|
-
|
|
1206
|
-
// Claude Code v2.1.150+ adds top-level fields. Keep output_config for
|
|
1207
|
-
// 4.7 adaptive thinking, where it controls effort; older Bedrock schemas
|
|
1208
|
-
// reject it as an extra input.
|
|
1209
|
-
//
|
|
1210
|
-
// - output_config: { effort } (when effortLevel is set)
|
|
1211
|
-
// - context_management: { ... } (auto context window management)
|
|
1212
|
-
// Bedrock's strict schema means any unknown top-level field 400s the
|
|
1213
|
-
// whole call, so strip the known CC additions before forwarding. New CC
|
|
1214
|
-
// fields will surface as 400s and need to be added here.
|
|
1215
|
-
for (const k of ['context_management']) {
|
|
1216
|
-
if (k in upstreamBody) delete upstreamBody[k];
|
|
1217
|
-
}
|
|
1218
|
-
if (!modelSupportsAdaptiveThinking && 'output_config' in upstreamBody) {
|
|
1219
|
-
delete upstreamBody.output_config;
|
|
1220
|
-
}
|
|
1221
|
-
|
|
1222
|
-
// Cache the BedrockRuntimeClient across requests so its connection
|
|
1223
|
-
// pool, DNS cache, and credential-chain resolution amortize. Reusing
|
|
1224
|
-
// a single client matches what _proxyAnthropic does with the global
|
|
1225
|
-
// fetch + Agent. Cache key includes the SDK module identity so test
|
|
1226
|
-
// SDK injection (proxy._bedrockSdk = mock) invalidates correctly.
|
|
1227
|
-
const clientArgs = {
|
|
1228
|
-
region: opts.bedrockRegion || process.env.AWS_REGION || 'us-east-1',
|
|
1229
|
-
...(opts.bedrockEndpoint || process.env.EVOMAP_BEDROCK_ENDPOINT
|
|
1230
|
-
? { endpoint: opts.bedrockEndpoint || process.env.EVOMAP_BEDROCK_ENDPOINT }
|
|
1231
|
-
: {}),
|
|
1232
|
-
...(opts.bedrockCredentials ? { credentials: opts.bedrockCredentials } : {}),
|
|
1233
|
-
};
|
|
1234
|
-
const cacheKey = JSON.stringify(clientArgs);
|
|
1235
|
-
if (
|
|
1236
|
-
!this._bedrockClient
|
|
1237
|
-
|| this._bedrockClientKey !== cacheKey
|
|
1238
|
-
|| this._bedrockClientSdk !== this._bedrockSdk
|
|
1239
|
-
) {
|
|
1240
|
-
this._bedrockClient = new BedrockRuntimeClient(clientArgs);
|
|
1241
|
-
this._bedrockClientKey = cacheKey;
|
|
1242
|
-
this._bedrockClientSdk = this._bedrockSdk;
|
|
1243
|
-
}
|
|
1244
|
-
const client = this._bedrockClient;
|
|
1245
|
-
|
|
1246
|
-
// Match _proxyAnthropic's per-request timeout boundary so a hung
|
|
1247
|
-
// upstream can't pin a Bedrock connection forever. AWS SDK v3
|
|
1248
|
-
// commands accept abortSignal in the second arg.
|
|
1249
|
-
const timeoutMs = opts.timeoutMs || 60_000;
|
|
1250
|
-
const abortController = new AbortController();
|
|
1251
|
-
const abortTimer = setTimeout(() => abortController.abort(), timeoutMs);
|
|
1252
|
-
|
|
1253
|
-
try {
|
|
1254
|
-
if (wantsStream) {
|
|
1255
|
-
const out = await client.send(new InvokeModelWithResponseStreamCommand({
|
|
1256
|
-
modelId,
|
|
1257
|
-
contentType: 'application/json',
|
|
1258
|
-
accept: 'application/json',
|
|
1259
|
-
body: JSON.stringify(upstreamBody),
|
|
1260
|
-
}), { abortSignal: abortController.signal });
|
|
1261
|
-
// The timeout that bounds the initial send must not apply to the
|
|
1262
|
-
// streaming body — chunks arrive over many seconds. Clear it now;
|
|
1263
|
-
// the readable-stream's cancel() handler is what closes the
|
|
1264
|
-
// upstream when the client disconnects mid-stream.
|
|
1265
|
-
clearTimeout(abortTimer);
|
|
1266
|
-
const stream = new ReadableStream({
|
|
1267
|
-
async start(controller) {
|
|
1268
|
-
const enc = new TextEncoder();
|
|
1269
|
-
try {
|
|
1270
|
-
for await (const event of out.body) {
|
|
1271
|
-
if (event.chunk?.bytes) {
|
|
1272
|
-
const json = Buffer.from(event.chunk.bytes).toString('utf8');
|
|
1273
|
-
controller.enqueue(enc.encode(`data: ${json}\n\n`));
|
|
1274
|
-
continue;
|
|
1275
|
-
}
|
|
1276
|
-
// Bedrock InvokeModelWithResponseStream may emit any of these
|
|
1277
|
-
// exception envelopes mid-stream; missing one silently drops
|
|
1278
|
-
// it and closes the stream without an error frame, so the
|
|
1279
|
-
// client sees a truncated-but-clean response.
|
|
1280
|
-
const ex = event.internalServerException
|
|
1281
|
-
|| event.modelStreamErrorException
|
|
1282
|
-
|| event.throttlingException
|
|
1283
|
-
|| event.validationException
|
|
1284
|
-
|| event.modelTimeoutException
|
|
1285
|
-
|| event.serviceUnavailableException;
|
|
1286
|
-
if (ex) {
|
|
1287
|
-
const errFrame = JSON.stringify({
|
|
1288
|
-
type: 'error',
|
|
1289
|
-
error: { type: ex.name || 'upstream_error', message: ex.message || String(ex) },
|
|
1290
|
-
});
|
|
1291
|
-
controller.enqueue(enc.encode(`event: error\ndata: ${errFrame}\n\n`));
|
|
1292
|
-
}
|
|
1293
|
-
}
|
|
1294
|
-
controller.close();
|
|
1295
|
-
} catch (err) {
|
|
1296
|
-
controller.error(err);
|
|
1297
|
-
}
|
|
1298
|
-
},
|
|
1299
|
-
// ProxyHttpServer._streamResponse calls reader.cancel() when the
|
|
1300
|
-
// downstream HTTP client disconnects. Without this, the AWS
|
|
1301
|
-
// event-stream AsyncIterable keeps pulling frames into a
|
|
1302
|
-
// discarded ReadableStream, leaking the underlying HTTP/2
|
|
1303
|
-
// stream + socket out of the SDK's pool.
|
|
1304
|
-
cancel() {
|
|
1305
|
-
try {
|
|
1306
|
-
if (typeof out.body?.return === 'function') {
|
|
1307
|
-
out.body.return();
|
|
1308
|
-
}
|
|
1309
|
-
} catch { /* AsyncIterable already closed */ }
|
|
1310
|
-
},
|
|
1311
|
-
});
|
|
1312
|
-
return {
|
|
1313
|
-
status: 200,
|
|
1314
|
-
headers: { 'content-type': 'text/event-stream' },
|
|
1315
|
-
stream,
|
|
1316
|
-
json: null,
|
|
1317
|
-
text: null,
|
|
1318
|
-
traceRequestBody: upstreamBody,
|
|
1319
|
-
};
|
|
1320
|
-
}
|
|
1321
|
-
|
|
1322
|
-
const out = await client.send(new InvokeModelCommand({
|
|
1323
|
-
modelId,
|
|
1324
|
-
contentType: 'application/json',
|
|
1325
|
-
accept: 'application/json',
|
|
1326
|
-
body: JSON.stringify(upstreamBody),
|
|
1327
|
-
}), { abortSignal: abortController.signal });
|
|
1328
|
-
clearTimeout(abortTimer);
|
|
1329
|
-
const text = Buffer.from(out.body).toString('utf8');
|
|
1330
|
-
return {
|
|
1331
|
-
status: 200,
|
|
1332
|
-
headers: { 'content-type': 'application/json' },
|
|
1333
|
-
stream: null,
|
|
1334
|
-
json: () => JSON.parse(text),
|
|
1335
|
-
text: () => text,
|
|
1336
|
-
traceRequestBody: upstreamBody,
|
|
1337
|
-
};
|
|
1338
|
-
} catch (err) {
|
|
1339
|
-
clearTimeout(abortTimer);
|
|
1340
|
-
const status = err.$metadata?.httpStatusCode || 500;
|
|
1341
|
-
const errBody = JSON.stringify({
|
|
1342
|
-
type: 'error',
|
|
1343
|
-
error: { type: err.name || 'upstream_error', message: err.message || String(err) },
|
|
1344
|
-
});
|
|
1345
|
-
return {
|
|
1346
|
-
status,
|
|
1347
|
-
headers: { 'content-type': 'application/json' },
|
|
1348
|
-
stream: null,
|
|
1349
|
-
json: () => JSON.parse(errBody),
|
|
1350
|
-
text: () => errBody,
|
|
1351
|
-
traceRequestBody: upstreamBody,
|
|
1352
|
-
};
|
|
1353
|
-
}
|
|
1354
|
-
}
|
|
1355
|
-
|
|
1356
|
-
async _getHubMailboxStatus() {
|
|
1357
|
-
if (!this.hubUrl) return { error: 'Hub not configured' };
|
|
1358
|
-
const nodeId = this.lifecycle.nodeId;
|
|
1359
|
-
if (!nodeId) return { error: 'No node_id yet' };
|
|
1360
|
-
const endpoint = `${this.hubUrl}/a2a/mailbox/status?node_id=${encodeURIComponent(nodeId)}`;
|
|
1361
|
-
try {
|
|
1362
|
-
const res = await hubFetch(endpoint, {
|
|
1363
|
-
method: 'GET',
|
|
1364
|
-
headers: this.lifecycle._buildHeaders(),
|
|
1365
|
-
signal: AbortSignal.timeout(10_000),
|
|
1366
|
-
});
|
|
1367
|
-
if (!res.ok) {
|
|
1368
|
-
// Drain body so undici can recycle the socket back to the pool.
|
|
1369
|
-
// Without this, repeated non-ok responses leak pool slots and
|
|
1370
|
-
// eventually starve the dispatcher.
|
|
1371
|
-
try { res.body?.cancel?.().catch(() => {}); } catch {}
|
|
1372
|
-
return { error: `Hub ${res.status}` };
|
|
1373
|
-
}
|
|
1374
|
-
return res.json();
|
|
1375
|
-
} catch (err) {
|
|
1376
|
-
return { error: err.message };
|
|
1377
|
-
}
|
|
1378
|
-
}
|
|
1379
|
-
}
|
|
1380
|
-
|
|
1381
|
-
async function startProxy(opts = {}) {
|
|
1382
|
-
const proxy = new EvoMapProxy(opts);
|
|
1383
|
-
const info = await proxy.start();
|
|
1384
|
-
return { proxy, ...info };
|
|
1385
|
-
}
|
|
1386
|
-
|
|
1387
|
-
module.exports = {
|
|
1388
|
-
EvoMapProxy,
|
|
1389
|
-
startProxy,
|
|
1390
|
-
buildAssetSearchQuery,
|
|
1391
|
-
buildSemanticSearchQuery,
|
|
1392
|
-
planAssetSearch,
|
|
1393
|
-
parseRetryAfterMs,
|
|
1394
|
-
resolveOpenAIBaseUrl,
|
|
1395
|
-
};
|