@antseed/cli 0.1.61 → 0.1.62
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/dist/cli/commands/agent.d.ts +3 -0
- package/dist/cli/commands/agent.d.ts.map +1 -0
- package/dist/cli/commands/agent.js +165 -0
- package/dist/cli/commands/agent.js.map +1 -0
- package/dist/cli/commands/balance.d.ts +3 -0
- package/dist/cli/commands/balance.d.ts.map +1 -0
- package/dist/cli/commands/balance.js +62 -0
- package/dist/cli/commands/balance.js.map +1 -0
- package/dist/cli/commands/bootstrap.d.ts +3 -0
- package/dist/cli/commands/bootstrap.d.ts.map +1 -0
- package/dist/cli/commands/bootstrap.js +46 -0
- package/dist/cli/commands/bootstrap.js.map +1 -0
- package/dist/cli/commands/browse.d.ts +7 -0
- package/dist/cli/commands/browse.d.ts.map +1 -0
- package/dist/cli/commands/browse.js +103 -0
- package/dist/cli/commands/browse.js.map +1 -0
- package/dist/cli/commands/channels.d.ts +3 -0
- package/dist/cli/commands/channels.d.ts.map +1 -0
- package/dist/cli/commands/channels.js +79 -0
- package/dist/cli/commands/channels.js.map +1 -0
- package/dist/cli/commands/config.d.ts +20 -0
- package/dist/cli/commands/config.d.ts.map +1 -0
- package/dist/cli/commands/config.js +239 -0
- package/dist/cli/commands/config.js.map +1 -0
- package/dist/cli/commands/connect.d.ts +13 -0
- package/dist/cli/commands/connect.d.ts.map +1 -0
- package/dist/cli/commands/connect.js +402 -0
- package/dist/cli/commands/connect.js.map +1 -0
- package/dist/cli/commands/connect.test.d.ts +2 -0
- package/dist/cli/commands/connect.test.d.ts.map +1 -0
- package/dist/cli/commands/connect.test.js +52 -0
- package/dist/cli/commands/connect.test.js.map +1 -0
- package/dist/cli/commands/connection.d.ts +3 -0
- package/dist/cli/commands/connection.d.ts.map +1 -0
- package/dist/cli/commands/connection.js +128 -0
- package/dist/cli/commands/connection.js.map +1 -0
- package/dist/cli/commands/dashboard.d.ts +6 -0
- package/dist/cli/commands/dashboard.d.ts.map +1 -0
- package/dist/cli/commands/dashboard.js +48 -0
- package/dist/cli/commands/dashboard.js.map +1 -0
- package/dist/cli/commands/deposit.d.ts +3 -0
- package/dist/cli/commands/deposit.d.ts.map +1 -0
- package/dist/cli/commands/deposit.js +38 -0
- package/dist/cli/commands/deposit.js.map +1 -0
- package/dist/cli/commands/dev.d.ts +3 -0
- package/dist/cli/commands/dev.d.ts.map +1 -0
- package/dist/cli/commands/dev.js +94 -0
- package/dist/cli/commands/dev.js.map +1 -0
- package/dist/cli/commands/emissions.d.ts +3 -0
- package/dist/cli/commands/emissions.d.ts.map +1 -0
- package/dist/cli/commands/emissions.js +80 -0
- package/dist/cli/commands/emissions.js.map +1 -0
- package/dist/cli/commands/init.d.ts +3 -0
- package/dist/cli/commands/init.d.ts.map +1 -0
- package/dist/cli/commands/init.js +90 -0
- package/dist/cli/commands/init.js.map +1 -0
- package/dist/cli/commands/payments.d.ts +3 -0
- package/dist/cli/commands/payments.d.ts.map +1 -0
- package/dist/cli/commands/payments.js +26 -0
- package/dist/cli/commands/payments.js.map +1 -0
- package/dist/cli/commands/plugin-create.d.ts +11 -0
- package/dist/cli/commands/plugin-create.d.ts.map +1 -0
- package/dist/cli/commands/plugin-create.js +201 -0
- package/dist/cli/commands/plugin-create.js.map +1 -0
- package/dist/cli/commands/plugin-create.test.d.ts +2 -0
- package/dist/cli/commands/plugin-create.test.d.ts.map +1 -0
- package/dist/cli/commands/plugin-create.test.js +53 -0
- package/dist/cli/commands/plugin-create.test.js.map +1 -0
- package/dist/cli/commands/plugin.d.ts +3 -0
- package/dist/cli/commands/plugin.d.ts.map +1 -0
- package/dist/cli/commands/plugin.js +279 -0
- package/dist/cli/commands/plugin.js.map +1 -0
- package/dist/cli/commands/plugin.test.d.ts +2 -0
- package/dist/cli/commands/plugin.test.d.ts.map +1 -0
- package/dist/cli/commands/plugin.test.js +54 -0
- package/dist/cli/commands/plugin.test.js.map +1 -0
- package/dist/cli/commands/profile.d.ts +10 -0
- package/dist/cli/commands/profile.d.ts.map +1 -0
- package/dist/cli/commands/profile.js +89 -0
- package/dist/cli/commands/profile.js.map +1 -0
- package/dist/cli/commands/register.d.ts +3 -0
- package/dist/cli/commands/register.d.ts.map +1 -0
- package/dist/cli/commands/register.js +35 -0
- package/dist/cli/commands/register.js.map +1 -0
- package/dist/cli/commands/seed.d.ts +14 -0
- package/dist/cli/commands/seed.d.ts.map +1 -0
- package/dist/cli/commands/seed.js +500 -0
- package/dist/cli/commands/seed.js.map +1 -0
- package/dist/cli/commands/seed.test.d.ts +2 -0
- package/dist/cli/commands/seed.test.d.ts.map +1 -0
- package/dist/cli/commands/seed.test.js +92 -0
- package/dist/cli/commands/seed.test.js.map +1 -0
- package/dist/cli/commands/setup.d.ts +3 -0
- package/dist/cli/commands/setup.d.ts.map +1 -0
- package/dist/cli/commands/setup.js +84 -0
- package/dist/cli/commands/setup.js.map +1 -0
- package/dist/cli/commands/stake.d.ts +3 -0
- package/dist/cli/commands/stake.d.ts.map +1 -0
- package/dist/cli/commands/stake.js +83 -0
- package/dist/cli/commands/stake.js.map +1 -0
- package/dist/cli/commands/status.d.ts +8 -0
- package/dist/cli/commands/status.d.ts.map +1 -0
- package/dist/cli/commands/status.js +55 -0
- package/dist/cli/commands/status.js.map +1 -0
- package/dist/cli/commands/subscribe.d.ts +3 -0
- package/dist/cli/commands/subscribe.d.ts.map +1 -0
- package/dist/cli/commands/subscribe.js +96 -0
- package/dist/cli/commands/subscribe.js.map +1 -0
- package/dist/cli/commands/types.d.ts +14 -0
- package/dist/cli/commands/types.d.ts.map +1 -0
- package/dist/cli/commands/types.js +41 -0
- package/dist/cli/commands/types.js.map +1 -0
- package/dist/cli/commands/withdraw.d.ts +3 -0
- package/dist/cli/commands/withdraw.d.ts.map +1 -0
- package/dist/cli/commands/withdraw.js +48 -0
- package/dist/cli/commands/withdraw.js.map +1 -0
- package/dist/cli/formatters.d.ts +29 -0
- package/dist/cli/formatters.d.ts.map +1 -0
- package/dist/cli/formatters.js +67 -0
- package/dist/cli/formatters.js.map +1 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +63 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/payment-utils.d.ts +53 -0
- package/dist/cli/payment-utils.d.ts.map +1 -0
- package/dist/cli/payment-utils.js +137 -0
- package/dist/cli/payment-utils.js.map +1 -0
- package/dist/cli/shutdown.d.ts +11 -0
- package/dist/cli/shutdown.d.ts.map +1 -0
- package/dist/cli/shutdown.js +34 -0
- package/dist/cli/shutdown.js.map +1 -0
- package/dist/config/defaults.d.ts +6 -0
- package/dist/config/defaults.d.ts.map +1 -0
- package/dist/config/defaults.js +53 -0
- package/dist/config/defaults.js.map +1 -0
- package/dist/config/effective.d.ts +25 -0
- package/dist/config/effective.d.ts.map +1 -0
- package/dist/config/effective.js +67 -0
- package/dist/config/effective.js.map +1 -0
- package/dist/config/effective.test.d.ts +2 -0
- package/dist/config/effective.test.d.ts.map +1 -0
- package/dist/config/effective.test.js +62 -0
- package/dist/config/effective.test.js.map +1 -0
- package/dist/config/loader.d.ts +12 -0
- package/dist/config/loader.d.ts.map +1 -0
- package/dist/config/loader.js +269 -0
- package/dist/config/loader.js.map +1 -0
- package/dist/config/loader.test.d.ts +2 -0
- package/dist/config/loader.test.d.ts.map +1 -0
- package/dist/config/loader.test.js +124 -0
- package/dist/config/loader.test.js.map +1 -0
- package/dist/config/types.d.ts +167 -0
- package/dist/config/types.d.ts.map +1 -0
- package/dist/config/types.js +2 -0
- package/dist/config/types.js.map +1 -0
- package/dist/config/validation.d.ts +10 -0
- package/dist/config/validation.d.ts.map +1 -0
- package/dist/config/validation.js +130 -0
- package/dist/config/validation.js.map +1 -0
- package/dist/env/load-env.d.ts +6 -0
- package/dist/env/load-env.d.ts.map +1 -0
- package/dist/env/load-env.js +18 -0
- package/dist/env/load-env.js.map +1 -0
- package/dist/plugins/loader.d.ts +12 -0
- package/dist/plugins/loader.d.ts.map +1 -0
- package/dist/plugins/loader.js +111 -0
- package/dist/plugins/loader.js.map +1 -0
- package/dist/plugins/manager.d.ts +11 -0
- package/dist/plugins/manager.d.ts.map +1 -0
- package/dist/plugins/manager.js +52 -0
- package/dist/plugins/manager.js.map +1 -0
- package/dist/plugins/registry.d.ts +8 -0
- package/dist/plugins/registry.d.ts.map +1 -0
- package/dist/plugins/registry.js +45 -0
- package/dist/plugins/registry.js.map +1 -0
- package/dist/proxy/buyer-proxy.d.ts +82 -0
- package/dist/proxy/buyer-proxy.d.ts.map +1 -0
- package/dist/proxy/buyer-proxy.js +1056 -0
- package/dist/proxy/buyer-proxy.js.map +1 -0
- package/dist/proxy/buyer-proxy.test.d.ts +2 -0
- package/dist/proxy/buyer-proxy.test.d.ts.map +1 -0
- package/dist/proxy/buyer-proxy.test.js +124 -0
- package/dist/proxy/buyer-proxy.test.js.map +1 -0
- package/dist/proxy/request-utils.d.ts +21 -0
- package/dist/proxy/request-utils.d.ts.map +1 -0
- package/dist/proxy/request-utils.js +237 -0
- package/dist/proxy/request-utils.js.map +1 -0
- package/dist/proxy/routing.d.ts +17 -0
- package/dist/proxy/routing.d.ts.map +1 -0
- package/dist/proxy/routing.js +115 -0
- package/dist/proxy/routing.js.map +1 -0
- package/dist/proxy/service-api-adapter.d.ts +3 -0
- package/dist/proxy/service-api-adapter.d.ts.map +1 -0
- package/dist/proxy/service-api-adapter.js +2 -0
- package/dist/proxy/service-api-adapter.js.map +1 -0
- package/dist/proxy/service-api-adapter.test.d.ts +2 -0
- package/dist/proxy/service-api-adapter.test.d.ts.map +1 -0
- package/dist/proxy/service-api-adapter.test.js +121 -0
- package/dist/proxy/service-api-adapter.test.js.map +1 -0
- package/dist/proxy/telemetry.d.ts +23 -0
- package/dist/proxy/telemetry.d.ts.map +1 -0
- package/dist/proxy/telemetry.js +227 -0
- package/dist/proxy/telemetry.js.map +1 -0
- package/dist/status/node-status.d.ts +22 -0
- package/dist/status/node-status.d.ts.map +1 -0
- package/dist/status/node-status.js +83 -0
- package/dist/status/node-status.js.map +1 -0
- package/package.json +3 -3
|
@@ -0,0 +1,1056 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { watch } from 'node:fs';
|
|
4
|
+
import { readFile, writeFile, rename, mkdir } from 'node:fs/promises';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { createOpenAIChatToAnthropicStreamingAdapter, createOpenAIChatToResponsesStreamingAdapter, createOpenAIResponsesToChatStreamingAdapter, detectRequestServiceApiProtocol, transformAnthropicMessagesRequestToOpenAIChat, transformOpenAIChatRequestToOpenAIResponses, transformOpenAIChatResponseToAnthropicMessage, transformOpenAIChatResponseToOpenAIResponses, transformOpenAIResponsesRequestToOpenAIChat, transformOpenAIResponsesResponseToOpenAIChat, } from './service-api-adapter.js';
|
|
8
|
+
import { DEBUG, log, extractRequestedService, summarizeRequestShape, summarizeErrorResponse, requestWantsStreaming, rewriteServiceInBody, isConnectionChurnError, isConnectionHealthy, isLoopbackPeer, } from './request-utils.js';
|
|
9
|
+
import { getExplicitProviderOverride, getExplicitPeerIdOverride, getPreferredPeerIdHint, resolvePeerRoutePlan, selectCandidatePeersForRouting, } from './routing.js';
|
|
10
|
+
import { computeResponseTelemetry, attachAntseedTelemetryHeaders, attachStreamingAntseedHeaders, } from './telemetry.js';
|
|
11
|
+
// Re-export for backward compatibility (used by tests and other consumers)
|
|
12
|
+
export { selectCandidatePeersForRouting } from './routing.js';
|
|
13
|
+
export { rewriteServiceInBody } from './request-utils.js';
|
|
14
|
+
const BUYER_STATE_FILE = join(homedir(), '.antseed', 'buyer.state.json');
|
|
15
|
+
const RETRYABLE_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504]);
|
|
16
|
+
/** Max age for carrying forward peers not seen in the latest DHT scan. */
|
|
17
|
+
const CARRY_FORWARD_TTL_MS = 30 * 60_000;
|
|
18
|
+
function adaptOpenAICompatibleErrorResponse(response, requestProtocol) {
|
|
19
|
+
if (response.statusCode !== 402) {
|
|
20
|
+
return response;
|
|
21
|
+
}
|
|
22
|
+
if (requestProtocol !== 'openai-responses' && requestProtocol !== 'openai-chat-completions') {
|
|
23
|
+
return response;
|
|
24
|
+
}
|
|
25
|
+
let parsed = null;
|
|
26
|
+
try {
|
|
27
|
+
parsed = JSON.parse(Buffer.from(response.body).toString('utf-8'));
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return response;
|
|
31
|
+
}
|
|
32
|
+
if (!parsed || parsed.error !== 'payment_required') {
|
|
33
|
+
return response;
|
|
34
|
+
}
|
|
35
|
+
const wrappedError = {
|
|
36
|
+
error: {
|
|
37
|
+
...parsed,
|
|
38
|
+
type: 'payment_required',
|
|
39
|
+
message: JSON.stringify(parsed),
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
return {
|
|
43
|
+
...response,
|
|
44
|
+
headers: {
|
|
45
|
+
...response.headers,
|
|
46
|
+
'content-type': 'application/json',
|
|
47
|
+
},
|
|
48
|
+
body: Buffer.from(JSON.stringify(wrappedError)),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Inject the buyer-known peerId into a 402 payment_required JSON body.
|
|
53
|
+
* The seller doesn't include its own peerId (and shouldn't — self-reported
|
|
54
|
+
* identity is untrusted). The buyer proxy knows which peer it connected to,
|
|
55
|
+
* so it stamps the peerId into the body before forwarding to the client.
|
|
56
|
+
*/
|
|
57
|
+
function inject402PeerId(response, peerId) {
|
|
58
|
+
let parsed = null;
|
|
59
|
+
try {
|
|
60
|
+
parsed = JSON.parse(Buffer.from(response.body).toString('utf-8'));
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return response;
|
|
64
|
+
}
|
|
65
|
+
if (!parsed)
|
|
66
|
+
return response;
|
|
67
|
+
// Handle both flat { error: 'payment_required', ... } and
|
|
68
|
+
// wrapped { error: { type: 'payment_required', ... } } formats.
|
|
69
|
+
if (parsed.error === 'payment_required') {
|
|
70
|
+
parsed.peerId = peerId;
|
|
71
|
+
}
|
|
72
|
+
else if (typeof parsed.error === 'object' &&
|
|
73
|
+
parsed.error !== null &&
|
|
74
|
+
parsed.error.type === 'payment_required') {
|
|
75
|
+
parsed.error.peerId = peerId;
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
return response;
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
...response,
|
|
82
|
+
body: Buffer.from(JSON.stringify(parsed)),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
const PROTOCOL_TRANSFORMS = {
|
|
86
|
+
'anthropic-messages→openai-chat-completions': {
|
|
87
|
+
transformRequest: transformAnthropicMessagesRequestToOpenAIChat,
|
|
88
|
+
adaptResponse: (res, meta) => transformOpenAIChatResponseToAnthropicMessage(res, meta),
|
|
89
|
+
createStreamAdapter: createOpenAIChatToAnthropicStreamingAdapter,
|
|
90
|
+
},
|
|
91
|
+
'openai-responses→openai-chat-completions': {
|
|
92
|
+
transformRequest: transformOpenAIResponsesRequestToOpenAIChat,
|
|
93
|
+
adaptResponse: (res, meta) => transformOpenAIChatResponseToOpenAIResponses(res, meta),
|
|
94
|
+
createStreamAdapter: createOpenAIChatToResponsesStreamingAdapter,
|
|
95
|
+
},
|
|
96
|
+
'openai-chat-completions→openai-responses': {
|
|
97
|
+
transformRequest: transformOpenAIChatRequestToOpenAIResponses,
|
|
98
|
+
adaptResponse: (res, meta) => transformOpenAIResponsesResponseToOpenAIChat(res, meta),
|
|
99
|
+
createStreamAdapter: createOpenAIResponsesToChatStreamingAdapter,
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
/**
|
|
103
|
+
* Local HTTP proxy that forwards requests to P2P sellers.
|
|
104
|
+
*
|
|
105
|
+
* Tools like Claude CLI set ANTHROPIC_BASE_URL=http://localhost:8377
|
|
106
|
+
* and the proxy transparently routes their API calls through the
|
|
107
|
+
* Antseed P2P network.
|
|
108
|
+
*/
|
|
109
|
+
export class BuyerProxy {
|
|
110
|
+
_server;
|
|
111
|
+
_node;
|
|
112
|
+
_port;
|
|
113
|
+
_bgRefreshIntervalMs;
|
|
114
|
+
_peerCacheTtlMs;
|
|
115
|
+
_pinnedPeer;
|
|
116
|
+
_pinnedService;
|
|
117
|
+
_stateFileWatcher = null;
|
|
118
|
+
_stateWatchDebounce = null;
|
|
119
|
+
_stateWriteChain = Promise.resolve();
|
|
120
|
+
_cachedPeers = [];
|
|
121
|
+
_cacheLastUpdatedAtMs = 0;
|
|
122
|
+
_cacheMutationEpoch = 0;
|
|
123
|
+
_peerRefreshPromise = null;
|
|
124
|
+
_lastStaleCacheLogAtMs = 0;
|
|
125
|
+
_bgRefreshHandle = null;
|
|
126
|
+
_lastSuccessfulPeerId = null;
|
|
127
|
+
_lastSuccessfulPeerByRouteKey = new Map();
|
|
128
|
+
constructor(config) {
|
|
129
|
+
this._node = config.node;
|
|
130
|
+
this._port = config.port;
|
|
131
|
+
this._bgRefreshIntervalMs = config.backgroundRefreshIntervalMs ?? 5 * 60_000;
|
|
132
|
+
this._peerCacheTtlMs = Math.max(0, config.peerCacheTtlMs ?? 30_000);
|
|
133
|
+
this._pinnedPeer = config.pinnedPeerId?.toLowerCase() ?? null;
|
|
134
|
+
this._pinnedService = config.pinnedService?.trim() ?? null;
|
|
135
|
+
this._server = createServer((req, res) => {
|
|
136
|
+
this._handleRequest(req, res).catch((err) => {
|
|
137
|
+
log('Unhandled error:', err);
|
|
138
|
+
if (!res.headersSent) {
|
|
139
|
+
res.writeHead(502, { 'content-type': 'text/plain' });
|
|
140
|
+
}
|
|
141
|
+
res.end(`Proxy error: ${err instanceof Error ? err.message : String(err)}`);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
async start() {
|
|
146
|
+
await new Promise((resolve, reject) => {
|
|
147
|
+
this._server.once('error', reject);
|
|
148
|
+
this._server.listen(this._port, '127.0.0.1', () => {
|
|
149
|
+
this._server.removeListener('error', reject);
|
|
150
|
+
resolve();
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
this._startBackgroundRefresh();
|
|
154
|
+
// Trigger initial peer discovery immediately so the desktop can show
|
|
155
|
+
// services without waiting for the first request or 5-minute interval.
|
|
156
|
+
void this._refreshPeersNow().catch(() => { });
|
|
157
|
+
await this._writeStateFile('connected');
|
|
158
|
+
this._watchStateFile();
|
|
159
|
+
}
|
|
160
|
+
async stop() {
|
|
161
|
+
if (this._stateWatchDebounce) {
|
|
162
|
+
clearTimeout(this._stateWatchDebounce);
|
|
163
|
+
this._stateWatchDebounce = null;
|
|
164
|
+
}
|
|
165
|
+
if (this._stateFileWatcher) {
|
|
166
|
+
this._stateFileWatcher.close();
|
|
167
|
+
this._stateFileWatcher = null;
|
|
168
|
+
}
|
|
169
|
+
if (this._bgRefreshHandle) {
|
|
170
|
+
clearInterval(this._bgRefreshHandle);
|
|
171
|
+
this._bgRefreshHandle = null;
|
|
172
|
+
}
|
|
173
|
+
await this._writeStateFile('stopped');
|
|
174
|
+
return new Promise((resolve) => {
|
|
175
|
+
this._server.close(() => resolve());
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
_watchStateFile() {
|
|
179
|
+
try {
|
|
180
|
+
this._stateFileWatcher = watch(BUYER_STATE_FILE, { persistent: false }, () => {
|
|
181
|
+
if (this._stateWatchDebounce)
|
|
182
|
+
clearTimeout(this._stateWatchDebounce);
|
|
183
|
+
this._stateWatchDebounce = setTimeout(() => {
|
|
184
|
+
this._stateWatchDebounce = null;
|
|
185
|
+
void this._reloadSessionOverrides().catch(() => { });
|
|
186
|
+
}, 50);
|
|
187
|
+
});
|
|
188
|
+
this._stateFileWatcher.on('error', () => {
|
|
189
|
+
// watcher error is non-fatal
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
// watcher setup failed; non-fatal
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
async _reloadSessionOverrides() {
|
|
197
|
+
try {
|
|
198
|
+
const raw = await readFile(BUYER_STATE_FILE, 'utf-8');
|
|
199
|
+
const parsed = JSON.parse(raw);
|
|
200
|
+
const pinnedService = typeof parsed.pinnedService === 'string' && parsed.pinnedService.trim().length > 0
|
|
201
|
+
? parsed.pinnedService.trim()
|
|
202
|
+
: null;
|
|
203
|
+
const pinnedPeer = typeof parsed.pinnedPeerId === 'string' && parsed.pinnedPeerId.trim().length > 0
|
|
204
|
+
? parsed.pinnedPeerId.trim().toLowerCase()
|
|
205
|
+
: null;
|
|
206
|
+
this._pinnedService = pinnedService;
|
|
207
|
+
this._pinnedPeer = pinnedPeer;
|
|
208
|
+
log(`Session overrides reloaded: service=${pinnedService ?? 'none'} peer=${pinnedPeer ?? 'none'}`);
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
// state file unreadable; keep current values
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/** Serialised read-modify-write to buyer.state.json. Returns the queued write promise. */
|
|
215
|
+
_mergeStateFile(patch) {
|
|
216
|
+
this._stateWriteChain = this._stateWriteChain.then(async () => {
|
|
217
|
+
try {
|
|
218
|
+
const dir = join(homedir(), '.antseed');
|
|
219
|
+
await mkdir(dir, { recursive: true });
|
|
220
|
+
let existing = {};
|
|
221
|
+
try {
|
|
222
|
+
const raw = await readFile(BUYER_STATE_FILE, 'utf-8');
|
|
223
|
+
existing = JSON.parse(raw);
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
// file doesn't exist yet
|
|
227
|
+
}
|
|
228
|
+
const data = { ...existing, ...patch };
|
|
229
|
+
const tmp = join(homedir(), '.antseed', `.buyer.state.${randomUUID()}.json.tmp`);
|
|
230
|
+
await writeFile(tmp, JSON.stringify(data, null, 2));
|
|
231
|
+
await rename(tmp, BUYER_STATE_FILE);
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
// non-fatal
|
|
235
|
+
}
|
|
236
|
+
}).catch(() => { });
|
|
237
|
+
return this._stateWriteChain;
|
|
238
|
+
}
|
|
239
|
+
async _writeStateFile(state) {
|
|
240
|
+
// When stopping, preserve whatever pinnedService/pinnedPeerId is already
|
|
241
|
+
// in the file — the debounce may have been cancelled before
|
|
242
|
+
// _reloadSessionOverrides could commit the latest CLI-written values.
|
|
243
|
+
const sessionOverrides = state === 'connected'
|
|
244
|
+
? { pinnedService: this._pinnedService, pinnedPeerId: this._pinnedPeer }
|
|
245
|
+
: {};
|
|
246
|
+
await this._mergeStateFile({
|
|
247
|
+
state,
|
|
248
|
+
pid: process.pid,
|
|
249
|
+
port: this._port,
|
|
250
|
+
...sessionOverrides,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
_startBackgroundRefresh() {
|
|
254
|
+
this._bgRefreshHandle = setInterval(() => {
|
|
255
|
+
void this._refreshPeersNow().catch(() => {
|
|
256
|
+
// background refresh failure is non-fatal
|
|
257
|
+
});
|
|
258
|
+
}, this._bgRefreshIntervalMs);
|
|
259
|
+
}
|
|
260
|
+
_replacePeers(incoming) {
|
|
261
|
+
const incomingById = new Map(incoming.map((p) => [p.peerId, p]));
|
|
262
|
+
const now = Date.now();
|
|
263
|
+
// Carry forward previously known peers that are missing from this scan,
|
|
264
|
+
// preserving their lastSeen timestamp. A missed DHT scan doesn't mean
|
|
265
|
+
// the peer is unavailable — it just wasn't discovered this time.
|
|
266
|
+
const merged = [...incoming];
|
|
267
|
+
for (const prev of this._cachedPeers) {
|
|
268
|
+
if (!incomingById.has(prev.peerId) && now - prev.lastSeen < CARRY_FORWARD_TTL_MS) {
|
|
269
|
+
merged.push({ ...prev });
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
this._cachedPeers = merged;
|
|
273
|
+
this._cacheLastUpdatedAtMs = Date.now();
|
|
274
|
+
this._cacheMutationEpoch += 1;
|
|
275
|
+
this._persistPeersToState();
|
|
276
|
+
}
|
|
277
|
+
_persistPeersToState() {
|
|
278
|
+
// Write discovered peers to buyer.state.json so the dashboard can read them
|
|
279
|
+
// without running its own DHT node.
|
|
280
|
+
const peers = this._cachedPeers.map((p) => {
|
|
281
|
+
// Extract service names from providerPricing entries.
|
|
282
|
+
const services = [];
|
|
283
|
+
if (p.providerPricing) {
|
|
284
|
+
for (const entry of Object.values(p.providerPricing)) {
|
|
285
|
+
if (entry.services) {
|
|
286
|
+
services.push(...Object.keys(entry.services));
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
peerId: p.peerId,
|
|
292
|
+
displayName: p.displayName ?? null,
|
|
293
|
+
publicAddress: p.publicAddress ?? null,
|
|
294
|
+
providers: p.providers,
|
|
295
|
+
services,
|
|
296
|
+
providerPricing: p.providerPricing ?? null,
|
|
297
|
+
providerServiceApiProtocols: p.providerServiceApiProtocols ?? null,
|
|
298
|
+
defaultInputUsdPerMillion: p.defaultInputUsdPerMillion ?? 0,
|
|
299
|
+
defaultOutputUsdPerMillion: p.defaultOutputUsdPerMillion ?? 0,
|
|
300
|
+
maxConcurrency: p.maxConcurrency ?? 0,
|
|
301
|
+
lastSeen: p.lastSeen,
|
|
302
|
+
};
|
|
303
|
+
});
|
|
304
|
+
this._mergeStateFile({ discoveredPeers: peers, peersUpdatedAt: Date.now() });
|
|
305
|
+
}
|
|
306
|
+
_evictPeer(peerId) {
|
|
307
|
+
const before = this._cachedPeers.length;
|
|
308
|
+
this._cachedPeers = this._cachedPeers.filter((p) => p.peerId !== peerId);
|
|
309
|
+
if (this._cachedPeers.length < before) {
|
|
310
|
+
this._cacheLastUpdatedAtMs = Date.now();
|
|
311
|
+
this._cacheMutationEpoch += 1;
|
|
312
|
+
this._persistPeersToState();
|
|
313
|
+
log(`Evicted failing peer ${peerId.slice(0, 12)}... from cache (${this._cachedPeers.length} remaining)`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
_rememberSuccessfulPeer(routeKey, peerId) {
|
|
317
|
+
this._lastSuccessfulPeerId = peerId;
|
|
318
|
+
this._lastSuccessfulPeerByRouteKey.set(routeKey, peerId);
|
|
319
|
+
// Keep map bounded to prevent unbounded growth from long-running channels.
|
|
320
|
+
const MAX_ROUTE_HISTORY = 200;
|
|
321
|
+
if (this._lastSuccessfulPeerByRouteKey.size > MAX_ROUTE_HISTORY) {
|
|
322
|
+
const oldestKey = this._lastSuccessfulPeerByRouteKey.keys().next().value;
|
|
323
|
+
if (typeof oldestKey === 'string') {
|
|
324
|
+
this._lastSuccessfulPeerByRouteKey.delete(oldestKey);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
_forgetSuccessfulPeer(routeKey, peerId) {
|
|
329
|
+
const rememberedForRoute = this._lastSuccessfulPeerByRouteKey.get(routeKey);
|
|
330
|
+
if (rememberedForRoute === peerId) {
|
|
331
|
+
this._lastSuccessfulPeerByRouteKey.delete(routeKey);
|
|
332
|
+
}
|
|
333
|
+
if (this._lastSuccessfulPeerId === peerId) {
|
|
334
|
+
const stillUsedByOtherRoute = Array.from(this._lastSuccessfulPeerByRouteKey.values())
|
|
335
|
+
.some((rememberedPeerId) => rememberedPeerId === peerId);
|
|
336
|
+
if (!stillUsedByOtherRoute) {
|
|
337
|
+
this._lastSuccessfulPeerId = null;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
_buildRouteKey(path, requestProtocol, requestedService, explicitProvider) {
|
|
342
|
+
const normalizedPath = path.split('?')[0]?.trim().toLowerCase() ?? '/';
|
|
343
|
+
const pathGroup = (normalizedPath.startsWith('/v1/messages')
|
|
344
|
+
? '/v1/messages'
|
|
345
|
+
: normalizedPath.startsWith('/v1/chat/completions')
|
|
346
|
+
? '/v1/chat/completions'
|
|
347
|
+
: normalizedPath.startsWith('/v1/responses')
|
|
348
|
+
? '/v1/responses'
|
|
349
|
+
: normalizedPath.startsWith('/v1/models')
|
|
350
|
+
? '/v1/models'
|
|
351
|
+
: normalizedPath);
|
|
352
|
+
return [
|
|
353
|
+
pathGroup,
|
|
354
|
+
requestProtocol ?? 'unknown-protocol',
|
|
355
|
+
requestedService ?? 'unknown-service',
|
|
356
|
+
explicitProvider ?? 'auto-provider',
|
|
357
|
+
].join('|');
|
|
358
|
+
}
|
|
359
|
+
async _discoverPeersFromNetwork() {
|
|
360
|
+
log('Discovering peers via DHT...');
|
|
361
|
+
const peers = await this._node.discoverPeers();
|
|
362
|
+
if (peers.length > 0) {
|
|
363
|
+
log(`Found ${peers.length} peer(s)`);
|
|
364
|
+
}
|
|
365
|
+
return peers;
|
|
366
|
+
}
|
|
367
|
+
async _refreshPeersNow() {
|
|
368
|
+
if (this._peerRefreshPromise) {
|
|
369
|
+
return this._peerRefreshPromise;
|
|
370
|
+
}
|
|
371
|
+
const previousCachedPeers = [...this._cachedPeers];
|
|
372
|
+
const mutationEpochAtStart = this._cacheMutationEpoch;
|
|
373
|
+
this._peerRefreshPromise = (async () => {
|
|
374
|
+
const peers = await this._discoverPeersFromNetwork();
|
|
375
|
+
if (peers.length > 0) {
|
|
376
|
+
this._replacePeers(peers);
|
|
377
|
+
return peers;
|
|
378
|
+
}
|
|
379
|
+
const fallbackPeers = previousCachedPeers.length > 0 && this._cacheMutationEpoch === mutationEpochAtStart
|
|
380
|
+
? [...previousCachedPeers]
|
|
381
|
+
: [];
|
|
382
|
+
if (fallbackPeers.length > 0) {
|
|
383
|
+
// Preserve stale cache as fallback when discovery transiently fails.
|
|
384
|
+
log('Discovery returned 0 peers; preserving most-recent cached peers as fallback.');
|
|
385
|
+
this._replacePeers(fallbackPeers);
|
|
386
|
+
return fallbackPeers;
|
|
387
|
+
}
|
|
388
|
+
return peers;
|
|
389
|
+
})().finally(() => {
|
|
390
|
+
this._peerRefreshPromise = null;
|
|
391
|
+
});
|
|
392
|
+
return this._peerRefreshPromise;
|
|
393
|
+
}
|
|
394
|
+
async _getPeers(options) {
|
|
395
|
+
const forceRefresh = options?.forceRefresh === true;
|
|
396
|
+
const cacheAgeMs = Date.now() - this._cacheLastUpdatedAtMs;
|
|
397
|
+
const cacheFresh = this._cacheLastUpdatedAtMs > 0 && cacheAgeMs <= this._peerCacheTtlMs;
|
|
398
|
+
if (forceRefresh) {
|
|
399
|
+
log('Forcing peer refresh before routing.');
|
|
400
|
+
return this._refreshPeersNow();
|
|
401
|
+
}
|
|
402
|
+
if (this._cachedPeers.length > 0) {
|
|
403
|
+
if (cacheFresh) {
|
|
404
|
+
return this._cachedPeers;
|
|
405
|
+
}
|
|
406
|
+
const now = Date.now();
|
|
407
|
+
if (now - this._lastStaleCacheLogAtMs >= 10_000) {
|
|
408
|
+
this._lastStaleCacheLogAtMs = now;
|
|
409
|
+
log(`Peer cache stale (${cacheAgeMs}ms old); routing from cached peers.`);
|
|
410
|
+
}
|
|
411
|
+
return this._cachedPeers;
|
|
412
|
+
}
|
|
413
|
+
// No cached peers yet — block on initial discovery.
|
|
414
|
+
return this._refreshPeersNow();
|
|
415
|
+
}
|
|
416
|
+
_formatPeerSelectionDiagnostics(peers) {
|
|
417
|
+
if (peers.length === 0) {
|
|
418
|
+
return 'No peers discovered.';
|
|
419
|
+
}
|
|
420
|
+
const summarize = (peer) => {
|
|
421
|
+
const providers = peer.providers
|
|
422
|
+
.map((provider) => provider.trim())
|
|
423
|
+
.filter((provider) => provider.length > 0);
|
|
424
|
+
const trust = Number.isFinite(peer.trustScore) ? String(peer.trustScore) : 'n/a';
|
|
425
|
+
const rep = Number.isFinite(peer.reputationScore) ? String(peer.reputationScore) : 'n/a';
|
|
426
|
+
const onChain = Number.isFinite(peer.onChainChannelCount) ? String(peer.onChainChannelCount) : 'n/a';
|
|
427
|
+
const input = Number.isFinite(peer.defaultInputUsdPerMillion) ? String(peer.defaultInputUsdPerMillion) : 'n/a';
|
|
428
|
+
const output = Number.isFinite(peer.defaultOutputUsdPerMillion) ? String(peer.defaultOutputUsdPerMillion) : 'n/a';
|
|
429
|
+
return `${peer.peerId.slice(0, 8)} providers=[${providers.join(',') || 'none'}] trust=${trust} rep=${rep} onchain=${onChain} in=${input} out=${output}`;
|
|
430
|
+
};
|
|
431
|
+
const samples = peers.slice(0, 5).map((peer) => summarize(peer)).join(' | ');
|
|
432
|
+
const suffix = peers.length > 5 ? ` (+${peers.length - 5} more)` : '';
|
|
433
|
+
return `Discovered ${peers.length} peer(s): ${samples}${suffix}`;
|
|
434
|
+
}
|
|
435
|
+
async _handleControlPlane(req, res, method, path) {
|
|
436
|
+
const origin = req.headers.origin ?? '';
|
|
437
|
+
const isLocal = origin.startsWith('http://127.0.0.1') || origin.startsWith('http://localhost') || origin === 'file://';
|
|
438
|
+
if (isLocal)
|
|
439
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
440
|
+
if (method === 'OPTIONS') {
|
|
441
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
442
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
443
|
+
res.writeHead(204);
|
|
444
|
+
res.end();
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
if (path === '/_antseed/peers' && method === 'GET') {
|
|
448
|
+
const peers = await this._getPeers();
|
|
449
|
+
const payload = peers.map((p) => ({
|
|
450
|
+
peerId: p.peerId,
|
|
451
|
+
displayName: p.displayName,
|
|
452
|
+
publicAddress: p.publicAddress,
|
|
453
|
+
providers: p.providers,
|
|
454
|
+
providerPricing: p.providerPricing,
|
|
455
|
+
providerServiceCategories: p.providerServiceCategories,
|
|
456
|
+
providerServiceApiProtocols: p.providerServiceApiProtocols,
|
|
457
|
+
reputationScore: p.reputationScore,
|
|
458
|
+
trustScore: p.trustScore,
|
|
459
|
+
lastSeen: p.lastSeen,
|
|
460
|
+
}));
|
|
461
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
462
|
+
res.end(JSON.stringify({ ok: true, peers: payload }));
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
if (path === '/_antseed/connect' && method === 'POST') {
|
|
466
|
+
const chunks = [];
|
|
467
|
+
let totalSize = 0;
|
|
468
|
+
for await (const chunk of req) {
|
|
469
|
+
totalSize += chunk.length;
|
|
470
|
+
if (totalSize > 8192) {
|
|
471
|
+
res.writeHead(413, { 'content-type': 'application/json' });
|
|
472
|
+
res.end(JSON.stringify({ ok: false, error: 'Request body too large' }));
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
chunks.push(chunk);
|
|
476
|
+
}
|
|
477
|
+
let peerId;
|
|
478
|
+
try {
|
|
479
|
+
const body = JSON.parse(Buffer.concat(chunks).toString());
|
|
480
|
+
peerId = String(body.peerId ?? '');
|
|
481
|
+
}
|
|
482
|
+
catch {
|
|
483
|
+
res.writeHead(400, { 'content-type': 'application/json' });
|
|
484
|
+
res.end(JSON.stringify({ ok: false, error: 'Invalid JSON body' }));
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
if (!peerId) {
|
|
488
|
+
res.writeHead(400, { 'content-type': 'application/json' });
|
|
489
|
+
res.end(JSON.stringify({ ok: false, error: 'Missing peerId' }));
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
const peers = await this._getPeers();
|
|
493
|
+
const peer = peers.find((p) => p.peerId === peerId);
|
|
494
|
+
if (!peer) {
|
|
495
|
+
res.writeHead(404, { 'content-type': 'application/json' });
|
|
496
|
+
res.end(JSON.stringify({ ok: false, error: 'Peer not found in cache' }));
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
try {
|
|
500
|
+
await this._node.connectToPeer(peer);
|
|
501
|
+
log(`Eager connection established to ${peerId.slice(0, 12)}...`);
|
|
502
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
503
|
+
res.end(JSON.stringify({ ok: true }));
|
|
504
|
+
}
|
|
505
|
+
catch (err) {
|
|
506
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
507
|
+
log(`Eager connection failed for ${peerId.slice(0, 12)}...: ${message}`);
|
|
508
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
509
|
+
res.end(JSON.stringify({ ok: false, error: message }));
|
|
510
|
+
}
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
const meteringMatch = path.match(/^\/_antseed\/metering\/(.+)$/);
|
|
514
|
+
if (meteringMatch && method === 'GET') {
|
|
515
|
+
const sellerPeerId = decodeURIComponent(meteringMatch[1]);
|
|
516
|
+
const stats = this._node.getMeteringStatsByPeer(sellerPeerId);
|
|
517
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
518
|
+
res.end(JSON.stringify(stats));
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
res.writeHead(404, { 'content-type': 'application/json' });
|
|
522
|
+
res.end(JSON.stringify({ ok: false, error: 'Unknown control-plane endpoint' }));
|
|
523
|
+
}
|
|
524
|
+
async _handleRequest(req, res) {
|
|
525
|
+
const method = req.method ?? 'GET';
|
|
526
|
+
const path = req.url ?? '/';
|
|
527
|
+
log(`${method} ${path}`);
|
|
528
|
+
// Control-plane endpoints — handle before collecting proxy body
|
|
529
|
+
if (path.startsWith('/_antseed/')) {
|
|
530
|
+
return this._handleControlPlane(req, res, method, path);
|
|
531
|
+
}
|
|
532
|
+
// Only proxy known API paths — reject everything else with 404
|
|
533
|
+
const normalizedPath = path.split('?')[0]?.trim().toLowerCase() ?? '/';
|
|
534
|
+
const isKnownApiPath = normalizedPath.startsWith('/v1/messages') ||
|
|
535
|
+
normalizedPath.startsWith('/v1/chat/completions') ||
|
|
536
|
+
normalizedPath.startsWith('/v1/responses') ||
|
|
537
|
+
normalizedPath.startsWith('/v1/models');
|
|
538
|
+
if (!isKnownApiPath) {
|
|
539
|
+
res.writeHead(404, { 'content-type': 'application/json' });
|
|
540
|
+
res.end(JSON.stringify({ error: { message: 'Not found', type: 'invalid_request_error' } }));
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
// Collect request body
|
|
544
|
+
const chunks = [];
|
|
545
|
+
for await (const chunk of req) {
|
|
546
|
+
chunks.push(chunk);
|
|
547
|
+
}
|
|
548
|
+
const body = Buffer.concat(chunks);
|
|
549
|
+
// Build serialized request
|
|
550
|
+
const headers = {};
|
|
551
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
552
|
+
if (typeof value === 'string') {
|
|
553
|
+
headers[key] = value;
|
|
554
|
+
}
|
|
555
|
+
else if (Array.isArray(value)) {
|
|
556
|
+
headers[key] = value.join(', ');
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
// Remove host header (points to localhost, not the seller)
|
|
560
|
+
delete headers['host'];
|
|
561
|
+
let serializedReq = {
|
|
562
|
+
requestId: randomUUID(),
|
|
563
|
+
method,
|
|
564
|
+
path,
|
|
565
|
+
headers,
|
|
566
|
+
body: new Uint8Array(body),
|
|
567
|
+
};
|
|
568
|
+
// Snapshot both session overrides together before any await so a concurrent
|
|
569
|
+
// _reloadSessionOverrides() cannot produce a service/peer mismatch mid-request.
|
|
570
|
+
const effectivePinnedService = this._pinnedService;
|
|
571
|
+
const effectivePinnedPeer = this._pinnedPeer;
|
|
572
|
+
if (effectivePinnedService) {
|
|
573
|
+
const { body: rewrittenBody, headers: rewrittenHeaders } = rewriteServiceInBody(serializedReq.body, serializedReq.headers, effectivePinnedService);
|
|
574
|
+
if (rewrittenBody !== serializedReq.body) {
|
|
575
|
+
serializedReq = { ...serializedReq, body: rewrittenBody, headers: rewrittenHeaders };
|
|
576
|
+
log(`Service override applied: ${effectivePinnedService}`);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
const clientAbortController = new AbortController();
|
|
580
|
+
const onClientAbort = () => {
|
|
581
|
+
if (clientAbortController.signal.aborted) {
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
clientAbortController.abort();
|
|
585
|
+
log(`Client disconnected; aborting upstream request reqId=${serializedReq.requestId.slice(0, 8)}`);
|
|
586
|
+
};
|
|
587
|
+
req.once('close', () => {
|
|
588
|
+
if (!req.complete && !res.writableEnded) {
|
|
589
|
+
onClientAbort();
|
|
590
|
+
}
|
|
591
|
+
});
|
|
592
|
+
res.once('close', () => {
|
|
593
|
+
if (!res.writableEnded) {
|
|
594
|
+
onClientAbort();
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
// Discover peers
|
|
598
|
+
const peers = await this._getPeers();
|
|
599
|
+
if (peers.length === 0) {
|
|
600
|
+
log('No sellers available');
|
|
601
|
+
res.writeHead(502, { 'content-type': 'text/plain' });
|
|
602
|
+
res.end('No sellers available on the network. Is a seeder running?');
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
const requestProtocol = detectRequestServiceApiProtocol(serializedReq);
|
|
606
|
+
const requestedService = extractRequestedService(serializedReq);
|
|
607
|
+
log(`Routing: protocol=${requestProtocol ?? 'null'} service=${requestedService ?? 'null'}`);
|
|
608
|
+
const explicitProvider = getExplicitProviderOverride(serializedReq);
|
|
609
|
+
const explicitPeerId = getExplicitPeerIdOverride(serializedReq, effectivePinnedPeer ?? undefined);
|
|
610
|
+
const preferredPeerId = getPreferredPeerIdHint(serializedReq);
|
|
611
|
+
log(`Routing hints: provider=${explicitProvider ?? 'auto'} pin-peer=${explicitPeerId ?? 'none'} prefer-peer=${preferredPeerId ?? 'none'}`);
|
|
612
|
+
const routeKey = this._buildRouteKey(serializedReq.path, requestProtocol, requestedService, explicitProvider);
|
|
613
|
+
const selectPeers = (candidateSources) => selectCandidatePeersForRouting(candidateSources, requestProtocol, requestedService, explicitProvider);
|
|
614
|
+
let hasForcedRefresh = false;
|
|
615
|
+
const refreshPeerSelection = async (reason) => {
|
|
616
|
+
if (hasForcedRefresh) {
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
hasForcedRefresh = true;
|
|
620
|
+
log(`Forcing peer refresh before routing after ${reason}.`);
|
|
621
|
+
discoveredPeers = await this._getPeers({ forceRefresh: true });
|
|
622
|
+
({
|
|
623
|
+
candidatePeers: routingPeers,
|
|
624
|
+
routePlanByPeerId: routingPlans,
|
|
625
|
+
} = selectPeers(discoveredPeers));
|
|
626
|
+
};
|
|
627
|
+
let { candidatePeers, routePlanByPeerId, } = selectPeers(peers);
|
|
628
|
+
let routingPeers = candidatePeers;
|
|
629
|
+
let routingPlans = routePlanByPeerId;
|
|
630
|
+
let discoveredPeers = peers;
|
|
631
|
+
if (routingPeers.length === 0) {
|
|
632
|
+
await refreshPeerSelection('empty initial routing candidate set');
|
|
633
|
+
}
|
|
634
|
+
if (routingPeers.length === 0) {
|
|
635
|
+
const diagnostics = this._formatPeerSelectionDiagnostics(discoveredPeers);
|
|
636
|
+
const providerLabel = explicitProvider ? ` for provider "${explicitProvider}"` : '';
|
|
637
|
+
res.writeHead(502, { 'content-type': 'text/plain' });
|
|
638
|
+
res.end(`No peers support ${requestProtocol ?? 'this request'}${providerLabel}. ${diagnostics}`);
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
log(`Routing candidates: ${routingPeers.length} peer(s)`);
|
|
642
|
+
const router = this._node.router;
|
|
643
|
+
if (explicitPeerId) {
|
|
644
|
+
// Pinned peers must use fresh discovery data so IP changes are picked up.
|
|
645
|
+
// Safe with the hasForcedRefresh guard: if an earlier refresh already ran
|
|
646
|
+
// this request, the cache is already fresh and cacheAgeMs will be < TTL.
|
|
647
|
+
const cacheAgeMs = Date.now() - this._cacheLastUpdatedAtMs;
|
|
648
|
+
if (cacheAgeMs > this._peerCacheTtlMs) {
|
|
649
|
+
await refreshPeerSelection(`pinned peer with stale cache (${cacheAgeMs}ms old)`);
|
|
650
|
+
}
|
|
651
|
+
let pinnedRoutingPeers = routingPeers;
|
|
652
|
+
let pinnedRoutePlans = routingPlans;
|
|
653
|
+
let selectedPeer = pinnedRoutingPeers.find((p) => p.peerId.toLowerCase() === explicitPeerId) ?? null;
|
|
654
|
+
if (!selectedPeer) {
|
|
655
|
+
await refreshPeerSelection(`pinned peer ${explicitPeerId.slice(0, 12)}... not in candidate set`);
|
|
656
|
+
pinnedRoutingPeers = routingPeers;
|
|
657
|
+
pinnedRoutePlans = routingPlans;
|
|
658
|
+
selectedPeer = pinnedRoutingPeers.find((p) => p.peerId.toLowerCase() === explicitPeerId) ?? null;
|
|
659
|
+
}
|
|
660
|
+
if (!selectedPeer) {
|
|
661
|
+
const source = serializedReq.headers['x-antseed-pin-peer'] ? 'x-antseed-pin-peer header' : '--peer flag';
|
|
662
|
+
const peerDiscovered = discoveredPeers.some((peer) => peer.peerId.toLowerCase() === explicitPeerId);
|
|
663
|
+
const protocolLabel = requestProtocol ? `protocol=${requestProtocol}` : 'protocol=unknown';
|
|
664
|
+
const providerLabel = explicitProvider ? `provider=${explicitProvider}` : 'provider=auto';
|
|
665
|
+
const serviceLabel = requestedService ? `service=${requestedService}` : 'service=none';
|
|
666
|
+
const mismatchHint = peerDiscovered
|
|
667
|
+
? `Peer is discoverable but filtered as incompatible (${protocolLabel}, ${providerLabel}, ${serviceLabel}).`
|
|
668
|
+
: 'Peer is not discoverable right now.';
|
|
669
|
+
log(`Pinned peer ${explicitPeerId.slice(0, 12)}... not found in candidate list (${source})`);
|
|
670
|
+
res.writeHead(502, { 'content-type': 'text/plain' });
|
|
671
|
+
res.end(`Pinned peer ${explicitPeerId.slice(0, 12)}... is not available or does not support this request. ${mismatchHint}`);
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
log(`Using pinned peer ${selectedPeer.peerId.slice(0, 12)}...`);
|
|
675
|
+
const result = await this._dispatchToPeer(res, serializedReq, selectedPeer, routeKey, pinnedRoutePlans, requestProtocol, requestedService, explicitProvider, router, RETRYABLE_STATUS_CODES, clientAbortController.signal);
|
|
676
|
+
if (!result.done) {
|
|
677
|
+
this._forgetSuccessfulPeer(routeKey, selectedPeer.peerId);
|
|
678
|
+
// Pinned peer returned a retryable error, but we don't retry — send error to client
|
|
679
|
+
res.writeHead(result.statusCode, result.responseHeaders);
|
|
680
|
+
res.end(result.responseBody);
|
|
681
|
+
}
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
// Non-pinned: retry with failover on provider errors
|
|
685
|
+
const MAX_ATTEMPTS = 3;
|
|
686
|
+
const triedPeerIds = new Set();
|
|
687
|
+
let lastStatusCode = 502;
|
|
688
|
+
let lastResponseBody = null;
|
|
689
|
+
let lastResponseHeaders = { 'content-type': 'text/plain' };
|
|
690
|
+
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
|
691
|
+
const availableCandidates = routingPeers.filter((peer) => !triedPeerIds.has(peer.peerId));
|
|
692
|
+
if (availableCandidates.length === 0)
|
|
693
|
+
break;
|
|
694
|
+
let selectedPeer = null;
|
|
695
|
+
// Prefer a recently successful peer for the same request route.
|
|
696
|
+
if (attempt === 0) {
|
|
697
|
+
const routePreferredPeerId = this._lastSuccessfulPeerByRouteKey.get(routeKey);
|
|
698
|
+
if (routePreferredPeerId) {
|
|
699
|
+
const remembered = availableCandidates.find((peer) => peer.peerId === routePreferredPeerId) ?? null;
|
|
700
|
+
if (remembered) {
|
|
701
|
+
selectedPeer = remembered;
|
|
702
|
+
log(`Reusing last successful route peer ${selectedPeer.peerId.slice(0, 12)}...`);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
// Fallback to the latest globally successful peer.
|
|
707
|
+
if (!selectedPeer && attempt === 0 && this._lastSuccessfulPeerId && !requestedService) {
|
|
708
|
+
const remembered = availableCandidates.find((peer) => peer.peerId === this._lastSuccessfulPeerId) ?? null;
|
|
709
|
+
if (remembered) {
|
|
710
|
+
selectedPeer = remembered;
|
|
711
|
+
log(`Reusing last successful peer ${selectedPeer.peerId.slice(0, 12)}...`);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
// Soft peer affinity: try caller-preferred peer first, but allow normal fallback.
|
|
715
|
+
if (!selectedPeer && attempt === 0 && preferredPeerId) {
|
|
716
|
+
const preferred = availableCandidates.find((peer) => peer.peerId.toLowerCase() === preferredPeerId) ?? null;
|
|
717
|
+
if (preferred) {
|
|
718
|
+
selectedPeer = preferred;
|
|
719
|
+
log(`Preferring requested peer ${selectedPeer.peerId.slice(0, 12)}...`);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
// Prefer local peers on first attempt
|
|
723
|
+
if (!selectedPeer && attempt === 0) {
|
|
724
|
+
const localPeers = availableCandidates.filter((peer) => isLoopbackPeer(peer));
|
|
725
|
+
if (localPeers.length > 0) {
|
|
726
|
+
selectedPeer = router
|
|
727
|
+
? router.selectPeer(serializedReq, localPeers)
|
|
728
|
+
: localPeers[0] ?? null;
|
|
729
|
+
if (selectedPeer) {
|
|
730
|
+
log(`Preferring local peer ${selectedPeer.peerId.slice(0, 12)}... @ ${selectedPeer.publicAddress ?? 'unknown'}`);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
// Prefer peers that can serve the request protocol directly without adapter transform.
|
|
735
|
+
if (!selectedPeer && requestProtocol === 'anthropic-messages') {
|
|
736
|
+
const shouldPreferDirect = !requestedService || /claude|anthropic/i.test(requestedService);
|
|
737
|
+
if (shouldPreferDirect) {
|
|
738
|
+
const directPeers = availableCandidates.filter((peer) => {
|
|
739
|
+
const plan = routingPlans.get(peer.peerId);
|
|
740
|
+
if (!plan)
|
|
741
|
+
return false;
|
|
742
|
+
return !plan.selection || !plan.selection.requiresTransform;
|
|
743
|
+
});
|
|
744
|
+
if (directPeers.length > 0) {
|
|
745
|
+
selectedPeer = router
|
|
746
|
+
? router.selectPeer(serializedReq, directPeers)
|
|
747
|
+
: directPeers[0] ?? null;
|
|
748
|
+
if (selectedPeer) {
|
|
749
|
+
log(`Preferring direct protocol peer ${selectedPeer.peerId.slice(0, 12)}...`);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
if (!selectedPeer) {
|
|
755
|
+
selectedPeer = router
|
|
756
|
+
? (router.selectPeer(serializedReq, availableCandidates) ?? availableCandidates[0] ?? null)
|
|
757
|
+
: availableCandidates[0] ?? null;
|
|
758
|
+
}
|
|
759
|
+
if (!selectedPeer)
|
|
760
|
+
break;
|
|
761
|
+
triedPeerIds.add(selectedPeer.peerId);
|
|
762
|
+
const result = await this._dispatchToPeer(res, serializedReq, selectedPeer, routeKey, routingPlans, requestProtocol, requestedService, explicitProvider, router, RETRYABLE_STATUS_CODES, clientAbortController.signal);
|
|
763
|
+
if (result.done)
|
|
764
|
+
return;
|
|
765
|
+
this._forgetSuccessfulPeer(routeKey, selectedPeer.peerId);
|
|
766
|
+
// Request failed with a retryable error — try another peer
|
|
767
|
+
lastStatusCode = result.statusCode;
|
|
768
|
+
lastResponseBody = result.responseBody;
|
|
769
|
+
lastResponseHeaders = result.responseHeaders;
|
|
770
|
+
if (attempt < MAX_ATTEMPTS - 1) {
|
|
771
|
+
log(`Peer ${selectedPeer.peerId.slice(0, 12)}... returned ${result.statusCode}, retrying with another peer (attempt ${attempt + 2}/${MAX_ATTEMPTS})`);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
// All retries exhausted or no peers available
|
|
775
|
+
if (!res.headersSent) {
|
|
776
|
+
if (lastResponseBody) {
|
|
777
|
+
log(`All ${triedPeerIds.size} peer(s) failed, returning last error (${lastStatusCode})`);
|
|
778
|
+
res.writeHead(lastStatusCode, lastResponseHeaders);
|
|
779
|
+
res.end(lastResponseBody);
|
|
780
|
+
}
|
|
781
|
+
else {
|
|
782
|
+
const diagnostics = this._formatPeerSelectionDiagnostics(routingPeers);
|
|
783
|
+
log('No peers available for request');
|
|
784
|
+
res.writeHead(502, { 'content-type': 'text/plain' });
|
|
785
|
+
res.end(`Router could not select a suitable peer. ${diagnostics}`);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
/**
|
|
790
|
+
* Dispatch a request to a specific peer. Returns `{ done: true }` if the response
|
|
791
|
+
* was sent to the client (success or non-retryable error), or retry info if the
|
|
792
|
+
* caller should try another peer.
|
|
793
|
+
*/
|
|
794
|
+
async _dispatchToPeer(res, serializedReq, selectedPeer, routeKey, routePlanByPeerId, requestProtocol, requestedService, explicitProvider, router, retryableStatusCodes, requestSignal) {
|
|
795
|
+
const selectedRoutePlan = routePlanByPeerId.get(selectedPeer.peerId)
|
|
796
|
+
?? resolvePeerRoutePlan(selectedPeer, requestProtocol, requestedService, explicitProvider);
|
|
797
|
+
if (!selectedRoutePlan) {
|
|
798
|
+
return { done: false, statusCode: 502, responseBody: Buffer.from('No compatible provider route'), responseHeaders: { 'content-type': 'text/plain' }, errorMessage: null };
|
|
799
|
+
}
|
|
800
|
+
const { 'x-antseed-pin-peer': _pinPeer, 'x-antseed-prefer-peer': _preferPeer, ...headersForPeer } = serializedReq.headers;
|
|
801
|
+
let requestForPeer = {
|
|
802
|
+
...serializedReq,
|
|
803
|
+
headers: {
|
|
804
|
+
...headersForPeer,
|
|
805
|
+
'x-antseed-provider': selectedRoutePlan.provider,
|
|
806
|
+
},
|
|
807
|
+
};
|
|
808
|
+
let adaptResponse = null;
|
|
809
|
+
let streamResponseAdapter = null;
|
|
810
|
+
if (selectedRoutePlan.selection?.requiresTransform) {
|
|
811
|
+
const transformKey = `${requestProtocol}→${selectedRoutePlan.selection.targetProtocol}`;
|
|
812
|
+
const strategy = PROTOCOL_TRANSFORMS[transformKey];
|
|
813
|
+
if (!strategy) {
|
|
814
|
+
res.writeHead(502, { 'content-type': 'text/plain' });
|
|
815
|
+
res.end('Unsupported protocol transformation path');
|
|
816
|
+
return { done: true };
|
|
817
|
+
}
|
|
818
|
+
log(`Applying protocol adapter ${transformKey} via provider "${selectedRoutePlan.provider}"`);
|
|
819
|
+
const transformed = strategy.transformRequest(requestForPeer);
|
|
820
|
+
if (!transformed) {
|
|
821
|
+
res.writeHead(502, { 'content-type': 'text/plain' });
|
|
822
|
+
res.end(`Failed to transform request for ${transformKey}`);
|
|
823
|
+
return { done: true };
|
|
824
|
+
}
|
|
825
|
+
requestForPeer = {
|
|
826
|
+
...transformed.request,
|
|
827
|
+
headers: {
|
|
828
|
+
...transformed.request.headers,
|
|
829
|
+
'x-antseed-provider': selectedRoutePlan.provider,
|
|
830
|
+
},
|
|
831
|
+
};
|
|
832
|
+
adaptResponse = (response) => strategy.adaptResponse(response, {
|
|
833
|
+
streamRequested: transformed.streamRequested,
|
|
834
|
+
fallbackModel: transformed.requestedModel,
|
|
835
|
+
});
|
|
836
|
+
if (transformed.streamRequested) {
|
|
837
|
+
streamResponseAdapter = strategy.createStreamAdapter({
|
|
838
|
+
fallbackModel: transformed.requestedModel,
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
if (DEBUG()) {
|
|
843
|
+
log(`Outbound request shape: ${summarizeRequestShape(requestForPeer)}`);
|
|
844
|
+
}
|
|
845
|
+
log(`Routing to peer ${selectedPeer.peerId.slice(0, 12)}...`);
|
|
846
|
+
// Forward through P2P
|
|
847
|
+
const wantsStreaming = requestWantsStreaming(requestForPeer.headers, requestForPeer.body);
|
|
848
|
+
const startTime = Date.now();
|
|
849
|
+
try {
|
|
850
|
+
if (wantsStreaming) {
|
|
851
|
+
let streamed = false;
|
|
852
|
+
const response = await this._node.sendRequestStream(selectedPeer, requestForPeer, {
|
|
853
|
+
onResponseStart: (startResponse, metadata) => {
|
|
854
|
+
if (!metadata.streaming)
|
|
855
|
+
return;
|
|
856
|
+
streamed = true;
|
|
857
|
+
const adaptedStartResponse = streamResponseAdapter
|
|
858
|
+
? streamResponseAdapter.adaptStart(startResponse)
|
|
859
|
+
: startResponse;
|
|
860
|
+
const streamingHeaders = attachStreamingAntseedHeaders(adaptedStartResponse.headers, selectedPeer, requestForPeer.requestId);
|
|
861
|
+
// Ensure content-type is set for SSE — some upstream APIs (e.g. Codex)
|
|
862
|
+
// omit it, which can cause the client's fetch body reader to not
|
|
863
|
+
// detect end-of-stream properly.
|
|
864
|
+
if (!streamingHeaders['content-type']) {
|
|
865
|
+
streamingHeaders['content-type'] = 'text/event-stream';
|
|
866
|
+
}
|
|
867
|
+
res.writeHead(adaptedStartResponse.statusCode, streamingHeaders);
|
|
868
|
+
if (adaptedStartResponse.body.length > 0) {
|
|
869
|
+
res.write(Buffer.from(adaptedStartResponse.body));
|
|
870
|
+
}
|
|
871
|
+
},
|
|
872
|
+
onResponseChunk: (chunk) => {
|
|
873
|
+
if (!streamed)
|
|
874
|
+
return;
|
|
875
|
+
const adaptedChunks = streamResponseAdapter
|
|
876
|
+
? streamResponseAdapter.adaptChunk(chunk)
|
|
877
|
+
: [chunk];
|
|
878
|
+
for (const adaptedChunk of adaptedChunks) {
|
|
879
|
+
if (adaptedChunk.data.length > 0) {
|
|
880
|
+
res.write(Buffer.from(adaptedChunk.data));
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
},
|
|
884
|
+
}, { signal: requestSignal });
|
|
885
|
+
let responseForClient = response;
|
|
886
|
+
if (!streamed && adaptResponse) {
|
|
887
|
+
responseForClient = adaptResponse(response);
|
|
888
|
+
}
|
|
889
|
+
responseForClient = adaptOpenAICompatibleErrorResponse(responseForClient, requestProtocol);
|
|
890
|
+
if (responseForClient.statusCode === 402) {
|
|
891
|
+
responseForClient = inject402PeerId(responseForClient, selectedPeer.peerId);
|
|
892
|
+
}
|
|
893
|
+
const latencyMs = Date.now() - startTime;
|
|
894
|
+
log(`Response: ${responseForClient.statusCode} (${latencyMs}ms, ${responseForClient.body.length} bytes)`);
|
|
895
|
+
if (responseForClient.statusCode >= 400) {
|
|
896
|
+
const prefix = adaptResponse && !streamed ? 'Upstream adapted error detail' : 'Upstream error detail';
|
|
897
|
+
log(`${prefix}: ${summarizeErrorResponse(responseForClient)}`);
|
|
898
|
+
}
|
|
899
|
+
const telemetry = computeResponseTelemetry(requestForPeer, responseForClient.headers, responseForClient.body, selectedPeer);
|
|
900
|
+
if (router) {
|
|
901
|
+
router.onResult(selectedPeer, {
|
|
902
|
+
success: !retryableStatusCodes.has(responseForClient.statusCode),
|
|
903
|
+
latencyMs,
|
|
904
|
+
tokens: telemetry.usage.totalTokens,
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
if (streamed) {
|
|
908
|
+
// Headers already sent to client, can't retry
|
|
909
|
+
if (responseForClient.statusCode >= 200 && responseForClient.statusCode < 400) {
|
|
910
|
+
this._rememberSuccessfulPeer(routeKey, selectedPeer.peerId);
|
|
911
|
+
}
|
|
912
|
+
if (!res.writableEnded) {
|
|
913
|
+
res.end();
|
|
914
|
+
}
|
|
915
|
+
return { done: true };
|
|
916
|
+
}
|
|
917
|
+
// Non-streamed response — check if retryable
|
|
918
|
+
const responseHeaders = attachAntseedTelemetryHeaders(responseForClient.headers, selectedPeer, telemetry, requestForPeer.requestId, latencyMs);
|
|
919
|
+
if (retryableStatusCodes.has(responseForClient.statusCode)) {
|
|
920
|
+
return {
|
|
921
|
+
done: false,
|
|
922
|
+
statusCode: responseForClient.statusCode,
|
|
923
|
+
responseBody: Buffer.from(responseForClient.body),
|
|
924
|
+
responseHeaders,
|
|
925
|
+
errorMessage: null,
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
if (responseForClient.statusCode >= 200 && responseForClient.statusCode < 400) {
|
|
929
|
+
this._rememberSuccessfulPeer(routeKey, selectedPeer.peerId);
|
|
930
|
+
}
|
|
931
|
+
res.writeHead(responseForClient.statusCode, responseHeaders);
|
|
932
|
+
res.end(Buffer.from(responseForClient.body));
|
|
933
|
+
return { done: true };
|
|
934
|
+
}
|
|
935
|
+
else {
|
|
936
|
+
const upstreamResponse = await this._node.sendRequest(selectedPeer, requestForPeer, { signal: requestSignal });
|
|
937
|
+
if (upstreamResponse.statusCode >= 400 && !adaptResponse) {
|
|
938
|
+
log(`Upstream raw error detail: ${summarizeErrorResponse(upstreamResponse)}`);
|
|
939
|
+
}
|
|
940
|
+
let response = upstreamResponse;
|
|
941
|
+
if (adaptResponse) {
|
|
942
|
+
response = adaptResponse(response);
|
|
943
|
+
}
|
|
944
|
+
response = adaptOpenAICompatibleErrorResponse(response, requestProtocol);
|
|
945
|
+
if (response.statusCode === 402) {
|
|
946
|
+
response = inject402PeerId(response, selectedPeer.peerId);
|
|
947
|
+
}
|
|
948
|
+
const latencyMs = Date.now() - startTime;
|
|
949
|
+
log(`Response: ${response.statusCode} (${latencyMs}ms, ${response.body.length} bytes)`);
|
|
950
|
+
if (response.statusCode >= 400) {
|
|
951
|
+
const prefix = adaptResponse ? 'Upstream adapted error detail' : 'Upstream error detail';
|
|
952
|
+
log(`${prefix}: ${summarizeErrorResponse(response)}`);
|
|
953
|
+
}
|
|
954
|
+
const telemetry = computeResponseTelemetry(requestForPeer, response.headers, response.body, selectedPeer);
|
|
955
|
+
const responseHeaders = attachAntseedTelemetryHeaders(response.headers, selectedPeer, telemetry, requestForPeer.requestId, latencyMs);
|
|
956
|
+
// Report result to router for learning
|
|
957
|
+
if (router) {
|
|
958
|
+
router.onResult(selectedPeer, {
|
|
959
|
+
success: !retryableStatusCodes.has(response.statusCode),
|
|
960
|
+
latencyMs,
|
|
961
|
+
tokens: telemetry.usage.totalTokens,
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
// Check if retryable
|
|
965
|
+
if (retryableStatusCodes.has(response.statusCode)) {
|
|
966
|
+
return { done: false, statusCode: response.statusCode, responseBody: Buffer.from(response.body), responseHeaders, errorMessage: null };
|
|
967
|
+
}
|
|
968
|
+
if (response.statusCode >= 200 && response.statusCode < 400) {
|
|
969
|
+
this._rememberSuccessfulPeer(routeKey, selectedPeer.peerId);
|
|
970
|
+
}
|
|
971
|
+
// Forward response headers and body to the HTTP client
|
|
972
|
+
res.writeHead(response.statusCode, responseHeaders);
|
|
973
|
+
res.end(Buffer.from(response.body));
|
|
974
|
+
return { done: true };
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
catch (err) {
|
|
978
|
+
const latencyMs = Date.now() - startTime;
|
|
979
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
980
|
+
const abortedLocally = requestSignal.aborted;
|
|
981
|
+
const connectionChurnError = isConnectionChurnError(message);
|
|
982
|
+
log(`Request failed after ${latencyMs}ms: ${message}`);
|
|
983
|
+
if (abortedLocally) {
|
|
984
|
+
log(`Request ${requestForPeer.requestId.slice(0, 8)} aborted locally; skipping retry, router penalty, and peer eviction.`);
|
|
985
|
+
if (!res.writableEnded) {
|
|
986
|
+
let responded = false;
|
|
987
|
+
if (!res.headersSent) {
|
|
988
|
+
try {
|
|
989
|
+
res.writeHead(499, { 'content-type': 'text/plain' });
|
|
990
|
+
responded = true;
|
|
991
|
+
}
|
|
992
|
+
catch {
|
|
993
|
+
// ignore
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
try {
|
|
997
|
+
if (res.writableEnded) {
|
|
998
|
+
// no-op
|
|
999
|
+
}
|
|
1000
|
+
else {
|
|
1001
|
+
if (responded) {
|
|
1002
|
+
res.end('Request cancelled');
|
|
1003
|
+
}
|
|
1004
|
+
else {
|
|
1005
|
+
res.end();
|
|
1006
|
+
}
|
|
1007
|
+
responded = true;
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
catch {
|
|
1011
|
+
// ignore
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
return { done: true };
|
|
1015
|
+
}
|
|
1016
|
+
if (router) {
|
|
1017
|
+
router.onResult(selectedPeer, {
|
|
1018
|
+
success: false,
|
|
1019
|
+
latencyMs,
|
|
1020
|
+
tokens: 0,
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
// Avoid poisoning routing cache from control-plane service enumeration failures.
|
|
1024
|
+
// Some peers can time out on /v1/models (service probe) while still serving inference paths.
|
|
1025
|
+
const normalizedPath = requestForPeer.path.toLowerCase();
|
|
1026
|
+
const isControlPlaneServicesRequest = normalizedPath.startsWith('/v1/models');
|
|
1027
|
+
if (isControlPlaneServicesRequest) {
|
|
1028
|
+
log(`Skipping peer eviction for control-plane failure on ${requestForPeer.path}`);
|
|
1029
|
+
}
|
|
1030
|
+
else if (connectionChurnError) {
|
|
1031
|
+
const currentState = this._node.getPeerConnectionState(selectedPeer.peerId);
|
|
1032
|
+
if (isConnectionHealthy(currentState)) {
|
|
1033
|
+
log(`Skipping peer eviction after connection churn: peer ${selectedPeer.peerId.slice(0, 12)}... `
|
|
1034
|
+
+ `has replacement connection state=${currentState}`);
|
|
1035
|
+
}
|
|
1036
|
+
else {
|
|
1037
|
+
this._evictPeer(selectedPeer.peerId);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
else {
|
|
1041
|
+
// Evict only the failing peer — others remain usable.
|
|
1042
|
+
this._evictPeer(selectedPeer.peerId);
|
|
1043
|
+
}
|
|
1044
|
+
this._forgetSuccessfulPeer(routeKey, selectedPeer.peerId);
|
|
1045
|
+
if (res.headersSent) {
|
|
1046
|
+
// Headers already sent (streaming), can't retry
|
|
1047
|
+
if (!res.writableEnded) {
|
|
1048
|
+
res.end();
|
|
1049
|
+
}
|
|
1050
|
+
return { done: true };
|
|
1051
|
+
}
|
|
1052
|
+
return { done: false, statusCode: 502, responseBody: Buffer.from(`P2P request failed: ${message}`), responseHeaders: { 'content-type': 'text/plain' }, errorMessage: message };
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
//# sourceMappingURL=buyer-proxy.js.map
|