@antseed/cli 0.1.61 → 0.1.63

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (209) hide show
  1. package/dist/cli/commands/agent.d.ts +3 -0
  2. package/dist/cli/commands/agent.d.ts.map +1 -0
  3. package/dist/cli/commands/agent.js +165 -0
  4. package/dist/cli/commands/agent.js.map +1 -0
  5. package/dist/cli/commands/balance.d.ts +3 -0
  6. package/dist/cli/commands/balance.d.ts.map +1 -0
  7. package/dist/cli/commands/balance.js +62 -0
  8. package/dist/cli/commands/balance.js.map +1 -0
  9. package/dist/cli/commands/bootstrap.d.ts +3 -0
  10. package/dist/cli/commands/bootstrap.d.ts.map +1 -0
  11. package/dist/cli/commands/bootstrap.js +46 -0
  12. package/dist/cli/commands/bootstrap.js.map +1 -0
  13. package/dist/cli/commands/browse.d.ts +7 -0
  14. package/dist/cli/commands/browse.d.ts.map +1 -0
  15. package/dist/cli/commands/browse.js +103 -0
  16. package/dist/cli/commands/browse.js.map +1 -0
  17. package/dist/cli/commands/channels.d.ts +3 -0
  18. package/dist/cli/commands/channels.d.ts.map +1 -0
  19. package/dist/cli/commands/channels.js +79 -0
  20. package/dist/cli/commands/channels.js.map +1 -0
  21. package/dist/cli/commands/config.d.ts +20 -0
  22. package/dist/cli/commands/config.d.ts.map +1 -0
  23. package/dist/cli/commands/config.js +239 -0
  24. package/dist/cli/commands/config.js.map +1 -0
  25. package/dist/cli/commands/connect.d.ts +13 -0
  26. package/dist/cli/commands/connect.d.ts.map +1 -0
  27. package/dist/cli/commands/connect.js +402 -0
  28. package/dist/cli/commands/connect.js.map +1 -0
  29. package/dist/cli/commands/connect.test.d.ts +2 -0
  30. package/dist/cli/commands/connect.test.d.ts.map +1 -0
  31. package/dist/cli/commands/connect.test.js +52 -0
  32. package/dist/cli/commands/connect.test.js.map +1 -0
  33. package/dist/cli/commands/connection.d.ts +3 -0
  34. package/dist/cli/commands/connection.d.ts.map +1 -0
  35. package/dist/cli/commands/connection.js +128 -0
  36. package/dist/cli/commands/connection.js.map +1 -0
  37. package/dist/cli/commands/dashboard.d.ts +6 -0
  38. package/dist/cli/commands/dashboard.d.ts.map +1 -0
  39. package/dist/cli/commands/dashboard.js +48 -0
  40. package/dist/cli/commands/dashboard.js.map +1 -0
  41. package/dist/cli/commands/deposit.d.ts +3 -0
  42. package/dist/cli/commands/deposit.d.ts.map +1 -0
  43. package/dist/cli/commands/deposit.js +38 -0
  44. package/dist/cli/commands/deposit.js.map +1 -0
  45. package/dist/cli/commands/dev.d.ts +3 -0
  46. package/dist/cli/commands/dev.d.ts.map +1 -0
  47. package/dist/cli/commands/dev.js +94 -0
  48. package/dist/cli/commands/dev.js.map +1 -0
  49. package/dist/cli/commands/emissions.d.ts +3 -0
  50. package/dist/cli/commands/emissions.d.ts.map +1 -0
  51. package/dist/cli/commands/emissions.js +80 -0
  52. package/dist/cli/commands/emissions.js.map +1 -0
  53. package/dist/cli/commands/init.d.ts +3 -0
  54. package/dist/cli/commands/init.d.ts.map +1 -0
  55. package/dist/cli/commands/init.js +90 -0
  56. package/dist/cli/commands/init.js.map +1 -0
  57. package/dist/cli/commands/payments.d.ts +3 -0
  58. package/dist/cli/commands/payments.d.ts.map +1 -0
  59. package/dist/cli/commands/payments.js +26 -0
  60. package/dist/cli/commands/payments.js.map +1 -0
  61. package/dist/cli/commands/plugin-create.d.ts +11 -0
  62. package/dist/cli/commands/plugin-create.d.ts.map +1 -0
  63. package/dist/cli/commands/plugin-create.js +201 -0
  64. package/dist/cli/commands/plugin-create.js.map +1 -0
  65. package/dist/cli/commands/plugin-create.test.d.ts +2 -0
  66. package/dist/cli/commands/plugin-create.test.d.ts.map +1 -0
  67. package/dist/cli/commands/plugin-create.test.js +53 -0
  68. package/dist/cli/commands/plugin-create.test.js.map +1 -0
  69. package/dist/cli/commands/plugin.d.ts +3 -0
  70. package/dist/cli/commands/plugin.d.ts.map +1 -0
  71. package/dist/cli/commands/plugin.js +279 -0
  72. package/dist/cli/commands/plugin.js.map +1 -0
  73. package/dist/cli/commands/plugin.test.d.ts +2 -0
  74. package/dist/cli/commands/plugin.test.d.ts.map +1 -0
  75. package/dist/cli/commands/plugin.test.js +54 -0
  76. package/dist/cli/commands/plugin.test.js.map +1 -0
  77. package/dist/cli/commands/profile.d.ts +10 -0
  78. package/dist/cli/commands/profile.d.ts.map +1 -0
  79. package/dist/cli/commands/profile.js +89 -0
  80. package/dist/cli/commands/profile.js.map +1 -0
  81. package/dist/cli/commands/register.d.ts +3 -0
  82. package/dist/cli/commands/register.d.ts.map +1 -0
  83. package/dist/cli/commands/register.js +35 -0
  84. package/dist/cli/commands/register.js.map +1 -0
  85. package/dist/cli/commands/seed.d.ts +14 -0
  86. package/dist/cli/commands/seed.d.ts.map +1 -0
  87. package/dist/cli/commands/seed.js +500 -0
  88. package/dist/cli/commands/seed.js.map +1 -0
  89. package/dist/cli/commands/seed.test.d.ts +2 -0
  90. package/dist/cli/commands/seed.test.d.ts.map +1 -0
  91. package/dist/cli/commands/seed.test.js +92 -0
  92. package/dist/cli/commands/seed.test.js.map +1 -0
  93. package/dist/cli/commands/setup.d.ts +3 -0
  94. package/dist/cli/commands/setup.d.ts.map +1 -0
  95. package/dist/cli/commands/setup.js +84 -0
  96. package/dist/cli/commands/setup.js.map +1 -0
  97. package/dist/cli/commands/stake.d.ts +3 -0
  98. package/dist/cli/commands/stake.d.ts.map +1 -0
  99. package/dist/cli/commands/stake.js +83 -0
  100. package/dist/cli/commands/stake.js.map +1 -0
  101. package/dist/cli/commands/status.d.ts +8 -0
  102. package/dist/cli/commands/status.d.ts.map +1 -0
  103. package/dist/cli/commands/status.js +55 -0
  104. package/dist/cli/commands/status.js.map +1 -0
  105. package/dist/cli/commands/subscribe.d.ts +3 -0
  106. package/dist/cli/commands/subscribe.d.ts.map +1 -0
  107. package/dist/cli/commands/subscribe.js +96 -0
  108. package/dist/cli/commands/subscribe.js.map +1 -0
  109. package/dist/cli/commands/types.d.ts +14 -0
  110. package/dist/cli/commands/types.d.ts.map +1 -0
  111. package/dist/cli/commands/types.js +41 -0
  112. package/dist/cli/commands/types.js.map +1 -0
  113. package/dist/cli/commands/withdraw.d.ts +3 -0
  114. package/dist/cli/commands/withdraw.d.ts.map +1 -0
  115. package/dist/cli/commands/withdraw.js +48 -0
  116. package/dist/cli/commands/withdraw.js.map +1 -0
  117. package/dist/cli/formatters.d.ts +29 -0
  118. package/dist/cli/formatters.d.ts.map +1 -0
  119. package/dist/cli/formatters.js +67 -0
  120. package/dist/cli/formatters.js.map +1 -0
  121. package/dist/cli/index.d.ts +3 -0
  122. package/dist/cli/index.d.ts.map +1 -0
  123. package/dist/cli/index.js +63 -0
  124. package/dist/cli/index.js.map +1 -0
  125. package/dist/cli/payment-utils.d.ts +53 -0
  126. package/dist/cli/payment-utils.d.ts.map +1 -0
  127. package/dist/cli/payment-utils.js +137 -0
  128. package/dist/cli/payment-utils.js.map +1 -0
  129. package/dist/cli/shutdown.d.ts +11 -0
  130. package/dist/cli/shutdown.d.ts.map +1 -0
  131. package/dist/cli/shutdown.js +34 -0
  132. package/dist/cli/shutdown.js.map +1 -0
  133. package/dist/config/defaults.d.ts +6 -0
  134. package/dist/config/defaults.d.ts.map +1 -0
  135. package/dist/config/defaults.js +41 -0
  136. package/dist/config/defaults.js.map +1 -0
  137. package/dist/config/effective.d.ts +25 -0
  138. package/dist/config/effective.d.ts.map +1 -0
  139. package/dist/config/effective.js +67 -0
  140. package/dist/config/effective.js.map +1 -0
  141. package/dist/config/effective.test.d.ts +2 -0
  142. package/dist/config/effective.test.d.ts.map +1 -0
  143. package/dist/config/effective.test.js +62 -0
  144. package/dist/config/effective.test.js.map +1 -0
  145. package/dist/config/loader.d.ts +12 -0
  146. package/dist/config/loader.d.ts.map +1 -0
  147. package/dist/config/loader.js +269 -0
  148. package/dist/config/loader.js.map +1 -0
  149. package/dist/config/loader.test.d.ts +2 -0
  150. package/dist/config/loader.test.d.ts.map +1 -0
  151. package/dist/config/loader.test.js +124 -0
  152. package/dist/config/loader.test.js.map +1 -0
  153. package/dist/config/types.d.ts +167 -0
  154. package/dist/config/types.d.ts.map +1 -0
  155. package/dist/config/types.js +2 -0
  156. package/dist/config/types.js.map +1 -0
  157. package/dist/config/validation.d.ts +10 -0
  158. package/dist/config/validation.d.ts.map +1 -0
  159. package/dist/config/validation.js +130 -0
  160. package/dist/config/validation.js.map +1 -0
  161. package/dist/env/load-env.d.ts +6 -0
  162. package/dist/env/load-env.d.ts.map +1 -0
  163. package/dist/env/load-env.js +18 -0
  164. package/dist/env/load-env.js.map +1 -0
  165. package/dist/plugins/loader.d.ts +12 -0
  166. package/dist/plugins/loader.d.ts.map +1 -0
  167. package/dist/plugins/loader.js +111 -0
  168. package/dist/plugins/loader.js.map +1 -0
  169. package/dist/plugins/manager.d.ts +11 -0
  170. package/dist/plugins/manager.d.ts.map +1 -0
  171. package/dist/plugins/manager.js +52 -0
  172. package/dist/plugins/manager.js.map +1 -0
  173. package/dist/plugins/registry.d.ts +8 -0
  174. package/dist/plugins/registry.d.ts.map +1 -0
  175. package/dist/plugins/registry.js +45 -0
  176. package/dist/plugins/registry.js.map +1 -0
  177. package/dist/proxy/buyer-proxy.d.ts +82 -0
  178. package/dist/proxy/buyer-proxy.d.ts.map +1 -0
  179. package/dist/proxy/buyer-proxy.js +1056 -0
  180. package/dist/proxy/buyer-proxy.js.map +1 -0
  181. package/dist/proxy/buyer-proxy.test.d.ts +2 -0
  182. package/dist/proxy/buyer-proxy.test.d.ts.map +1 -0
  183. package/dist/proxy/buyer-proxy.test.js +124 -0
  184. package/dist/proxy/buyer-proxy.test.js.map +1 -0
  185. package/dist/proxy/request-utils.d.ts +21 -0
  186. package/dist/proxy/request-utils.d.ts.map +1 -0
  187. package/dist/proxy/request-utils.js +237 -0
  188. package/dist/proxy/request-utils.js.map +1 -0
  189. package/dist/proxy/routing.d.ts +17 -0
  190. package/dist/proxy/routing.d.ts.map +1 -0
  191. package/dist/proxy/routing.js +115 -0
  192. package/dist/proxy/routing.js.map +1 -0
  193. package/dist/proxy/service-api-adapter.d.ts +3 -0
  194. package/dist/proxy/service-api-adapter.d.ts.map +1 -0
  195. package/dist/proxy/service-api-adapter.js +2 -0
  196. package/dist/proxy/service-api-adapter.js.map +1 -0
  197. package/dist/proxy/service-api-adapter.test.d.ts +2 -0
  198. package/dist/proxy/service-api-adapter.test.d.ts.map +1 -0
  199. package/dist/proxy/service-api-adapter.test.js +121 -0
  200. package/dist/proxy/service-api-adapter.test.js.map +1 -0
  201. package/dist/proxy/telemetry.d.ts +23 -0
  202. package/dist/proxy/telemetry.d.ts.map +1 -0
  203. package/dist/proxy/telemetry.js +227 -0
  204. package/dist/proxy/telemetry.js.map +1 -0
  205. package/dist/status/node-status.d.ts +22 -0
  206. package/dist/status/node-status.d.ts.map +1 -0
  207. package/dist/status/node-status.js +83 -0
  208. package/dist/status/node-status.js.map +1 -0
  209. package/package.json +7 -7
@@ -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