@antseed/cli 0.1.190 → 0.1.191

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 (66) hide show
  1. package/README.md +9 -0
  2. package/dist/cli/commands/seller/start.d.ts.map +1 -1
  3. package/dist/cli/commands/seller/start.js +2 -1
  4. package/dist/cli/commands/seller/start.js.map +1 -1
  5. package/dist/config/service-metadata.test.js +4 -0
  6. package/dist/config/service-metadata.test.js.map +1 -1
  7. package/dist/gateway/gateway-policy.test.js +19 -0
  8. package/dist/gateway/gateway-policy.test.js.map +1 -1
  9. package/dist/gateway/routing-through-gateway.test.d.ts +2 -0
  10. package/dist/gateway/routing-through-gateway.test.d.ts.map +1 -0
  11. package/dist/gateway/routing-through-gateway.test.js +177 -0
  12. package/dist/gateway/routing-through-gateway.test.js.map +1 -0
  13. package/dist/plugins/registry.d.ts.map +1 -1
  14. package/dist/plugins/registry.js +12 -0
  15. package/dist/plugins/registry.js.map +1 -1
  16. package/dist/proxy/buyer-proxy.d.ts +62 -4
  17. package/dist/proxy/buyer-proxy.d.ts.map +1 -1
  18. package/dist/proxy/buyer-proxy.js +509 -120
  19. package/dist/proxy/buyer-proxy.js.map +1 -1
  20. package/dist/proxy/buyer-proxy.test.js +1341 -5
  21. package/dist/proxy/buyer-proxy.test.js.map +1 -1
  22. package/dist/proxy/conversation-store.d.ts +8 -0
  23. package/dist/proxy/conversation-store.d.ts.map +1 -1
  24. package/dist/proxy/conversation-store.js +20 -0
  25. package/dist/proxy/conversation-store.js.map +1 -1
  26. package/dist/proxy/conversation-store.test.js +54 -1
  27. package/dist/proxy/conversation-store.test.js.map +1 -1
  28. package/dist/proxy/gateway-policy.test.js +110 -0
  29. package/dist/proxy/gateway-policy.test.js.map +1 -1
  30. package/dist/proxy/network-models.d.ts +27 -1
  31. package/dist/proxy/network-models.d.ts.map +1 -1
  32. package/dist/proxy/network-models.js +2 -0
  33. package/dist/proxy/network-models.js.map +1 -1
  34. package/dist/proxy/request-utils.d.ts +21 -1
  35. package/dist/proxy/request-utils.d.ts.map +1 -1
  36. package/dist/proxy/request-utils.js +24 -1
  37. package/dist/proxy/request-utils.js.map +1 -1
  38. package/dist/proxy/route-policy.d.ts +7 -0
  39. package/dist/proxy/route-policy.d.ts.map +1 -1
  40. package/dist/proxy/route-policy.js +10 -0
  41. package/dist/proxy/route-policy.js.map +1 -1
  42. package/dist/proxy/router-execution.d.ts +63 -0
  43. package/dist/proxy/router-execution.d.ts.map +1 -0
  44. package/dist/proxy/router-execution.js +222 -0
  45. package/dist/proxy/router-execution.js.map +1 -0
  46. package/dist/proxy/router-execution.test.d.ts +2 -0
  47. package/dist/proxy/router-execution.test.d.ts.map +1 -0
  48. package/dist/proxy/router-execution.test.js +269 -0
  49. package/dist/proxy/router-execution.test.js.map +1 -0
  50. package/dist/proxy/routing-services.d.ts +48 -0
  51. package/dist/proxy/routing-services.d.ts.map +1 -0
  52. package/dist/proxy/routing-services.js +66 -0
  53. package/dist/proxy/routing-services.js.map +1 -0
  54. package/dist/proxy/routing-services.test.d.ts +2 -0
  55. package/dist/proxy/routing-services.test.d.ts.map +1 -0
  56. package/dist/proxy/routing-services.test.js +95 -0
  57. package/dist/proxy/routing-services.test.js.map +1 -0
  58. package/dist/proxy/routing-usage.d.ts +4 -0
  59. package/dist/proxy/routing-usage.d.ts.map +1 -0
  60. package/dist/proxy/routing-usage.js +19 -0
  61. package/dist/proxy/routing-usage.js.map +1 -0
  62. package/dist/proxy/routing-usage.test.d.ts +2 -0
  63. package/dist/proxy/routing-usage.test.d.ts.map +1 -0
  64. package/dist/proxy/routing-usage.test.js +32 -0
  65. package/dist/proxy/routing-usage.test.js.map +1 -0
  66. package/package.json +9 -7
@@ -4,21 +4,24 @@ import { prepareVideoRequest, recordVideoAcceptance } from './native-video-proxy
4
4
  import { downloadVideo } from './video-download.js';
5
5
  import { createServer } from 'node:http';
6
6
  import { randomUUID } from 'node:crypto';
7
+ import { buildRoutingServices, findPinnedRoutingService } from './routing-services.js';
7
8
  import { readFileSync, watchFile, unwatchFile } from 'node:fs';
8
9
  import { readFile, writeFile, rename, mkdir, readdir, stat, unlink } from 'node:fs/promises';
9
10
  import { join } from 'node:path';
10
- import { ANTSEED_BUYER_FAULT_ERROR_CODE, ANTSEED_FAULT_ATTRIBUTION_HEADER, ANTSEED_ATTEST_PATH, adaptPeerFaultErrorResponse, computeTrustScore, decodeSweepRequest, faultAttributionOf, faultCodeOf, isModelRouteCoolingDown, isModelRouteEligible, modelRouteReputationScore, modelRouteTotalPrice, normalizedModelReputationScore, peerSupportsCooperativeClose, DEFAULT_BUYER_IDENTITY, rankModelRoutes, sanitizePeerDisplayName, } from '@antseed/node';
11
+ import { ANTSEED_BUYER_FAULT_ERROR_CODE, ANTSEED_FAULT_ATTRIBUTION_HEADER, ANTSEED_ATTEST_PATH, adaptPeerFaultErrorResponse, computeTrustScore, decodeSweepRequest, faultAttributionOf, faultCodeOf, isModelRouteCoolingDown, isModelRouteEligible, modelRouteReputationScore, modelRouteTotalPrice, normalizedModelReputationScore, peerSupportsCooperativeClose, isRoutingSelection, DEFAULT_BUYER_IDENTITY, rankModelRoutes, sanitizePeerDisplayName, } from '@antseed/node';
11
12
  import { canonicalModelKey } from '@antseed/node/model-identity';
12
13
  import { createStreamingAdapter, detectRequestServiceApiProtocol, transformRequest, transformResponse, } from './service-api-adapter.js';
13
- import { DEBUG, log, extractRequestedService, summarizeRequestShape, summarizeErrorResponse, requestWantsStreaming, parsePeerPinnedService, rewritePeerPinnedServiceInBody, substituteRoutedModelAlias, overrideRoutedModelInBody, ROUTED_MODEL_ALIAS, SYSTEM_PROXY_SOURCE_HEADER, SYSTEM_ROUTED_MODEL_HEADER, BUYER_IDENTITY_HEADER, normalizePeerId, } from './request-utils.js';
14
+ import { DEBUG, log, extractRequestedService, summarizeRequestShape, summarizeErrorResponse, requestWantsStreaming, parsePeerPinnedService, rewritePeerPinnedServiceInBody, substituteRoutedModelAlias, overrideRoutedModelInBody, ROUTED_MODEL_ALIAS, SYSTEM_PROXY_SOURCE_HEADER, SYSTEM_ROUTED_MODEL_HEADER, BUYER_IDENTITY_HEADER, normalizePeerId, takeRoutingOptions, } from './request-utils.js';
14
15
  import { BuyerIdentityLoader } from '../buyer-identities/loader.js';
15
16
  import { buildNetworkModels, effectiveModelReputationScore, parseModelTypeFilter, } from './network-models.js';
16
17
  import { findMissingRequiredParameters, findUnannouncedRequestParameters, findAdvertisedServiceOffer, findAdvertisedServiceProtocols, getExplicitProviderOverride, getExplicitPeerIdOverride, parseRequiredParametersHeader, REQUIRED_PARAMETERS_HEADER, resolvePeerRoutePlan, selectCandidatePeersForRouting, } from './routing.js';
17
18
  import { computeResponseTelemetry, attachAntseedTelemetryHeaders, attachStreamingAntseedHeaders, } from './telemetry.js';
18
19
  import { DEFAULT_BUYER_PEER_REFRESH_INTERVAL_MS } from '../config/defaults.js';
19
20
  import { extractConversationIdentity, extractFirstUserSnippet, isCompletionRequestPath, isTitleGenerationRequest, parseRequestBodyObject, } from './conversation-identity.js';
20
- import { SPEND_ATTRIBUTION_DB_FILE, SPEND_ATTRIBUTION_HEADER, SpendAttributionFeed, parseSpendAttributionTag } from './spend-attribution.js';
21
21
  import { ConversationStore } from './conversation-store.js';
22
+ import { eligibleRouterCandidates, executeRouterSelection, NoEligibleCandidatesError, requestForRecommendation, RoutingModelsCache } from './router-execution.js';
23
+ import { recordRouterUsage } from './routing-usage.js';
24
+ import { SPEND_ATTRIBUTION_DB_FILE, SPEND_ATTRIBUTION_HEADER, SpendAttributionFeed, parseSpendAttributionTag } from './spend-attribution.js';
22
25
  import { DEPOSIT_WATCH_ACTIVE_TTL_MS } from './deposit-watcher.js';
23
26
  import { DepositLog } from './deposit-log.js';
24
27
  import { recordPeerFailureEntry, clearPeerHealthEntry, isCoolingDown, parsePersistedPeerHealth, prunePeerHealth, serializePeerHealth, reasonEscalates, } from './peer-health.js';
@@ -32,8 +35,9 @@ import { TeeControl } from './tee-control.js';
32
35
  import { ChainReadCache } from './chain-read-cache.js';
33
36
  import { isClientCredentialHeader } from './credential-headers.js';
34
37
  import { loadConfig } from '../config/loader.js';
38
+ import { InvalidRouterOptionsError, ModelRoutingClient, routingClientAdapter, routingProtocolOfTarget, validateRouterOptions } from '@antseed/router-core';
35
39
  import { GATEWAY_CONTROL_HEADER, GATEWAY_CONTROL_SECRET_ENV, GATEWAY_CONTROL_SECRET_FILE, ROUTING_POLICY_HEADER, decodePolicyHeader, findModelRoute, meaningfulPolicy, narrowPolicy, normalizePeerId as normalizePolicyPeerId, policyAllowsModel, policyModelKey, } from '../routing-policy/policy.js';
36
- import { buyerConfigReason, buyerHardPolicy, buyerPreferencePolicy, orderByPolicy, pinnedPeerExclusionReasons, policyExclusionReasons, preferenceExclusionReasons, rankingPreferences, secretsMatch, } from './route-policy.js';
40
+ import { buyerConfigReason, buyerHardPolicy, buyerPreferencePolicy, orderByPolicy, pinnedPeerExclusionReasons, policyExclusionReasons, preferenceExclusionReasons, rankingPreferences, secretsMatch, routingServicePolicy, } from './route-policy.js';
37
41
  // Re-export for backward compatibility (used by tests and other consumers)
38
42
  export { selectCandidatePeersForRouting } from './routing.js';
39
43
  export { parsePeerPinnedService, rewritePeerPinnedServiceInBody, substituteRoutedModelAlias, ROUTED_MODEL_ALIAS } from './request-utils.js';
@@ -91,6 +95,8 @@ function isLoopbackAddress(address) {
91
95
  // because model_not_found already has dedicated unadvertise handling while a
92
96
  // generic 404 would repeat identically on every peer.
93
97
  const RETRYABLE_STATUS_CODES = new Set([401, 403, 408, 429, 500, 502, 503, 504]);
98
+ // Moving to another recommendation is a new purchase, so skip timeouts (408/504), where the seller may already have done the work.
99
+ const RECOMMENDATION_FALLBACK_STATUS_CODES = new Set([401, 403, 429, 500, 502, 503]);
94
100
  /**
95
101
  * Gateway-policy requests on automatic routing also move on after a 402: the
96
102
  * buyer could not fund that seller (no deposits for its reserve), but a free
@@ -147,6 +153,10 @@ function isValidRoutedModelTarget(value) {
147
153
  return false;
148
154
  return !value.includes('@') || parsePeerPinnedService(value) !== null;
149
155
  }
156
+ function sameRoutingServiceTarget(a, b) {
157
+ return !!a && !!b && a.peerId.toLowerCase() === b.peerId.toLowerCase()
158
+ && a.provider === b.provider && a.serviceId === b.serviceId;
159
+ }
150
160
  /** Returns `request` with its body's model field rewritten to `serviceId`, or unchanged if nothing rewrote. */
151
161
  function withRoutedModel(request, serviceId) {
152
162
  const rewritten = overrideRoutedModelInBody(request.body, request.headers, serviceId);
@@ -254,6 +264,18 @@ function responseFaultAttribution(response) {
254
264
  ? attribution
255
265
  : 'peer';
256
266
  }
267
+ function dispatchFailure(statusCode, responseHeaders, body) {
268
+ return { done: false, statusCode, responseHeaders, responseBody: Buffer.from(body), errorMessage: null };
269
+ }
270
+ function writeDispatchFailure(res, result) {
271
+ if (res.headersSent || res.writableEnded)
272
+ return;
273
+ res.writeHead(result.statusCode, result.responseHeaders);
274
+ res.end(result.responseBody);
275
+ }
276
+ function isBuyerFault(result) {
277
+ return result.responseHeaders[ANTSEED_FAULT_ATTRIBUTION_HEADER]?.toLowerCase() === 'buyer';
278
+ }
257
279
  function adaptOpenAICompatibleErrorResponse(response, requestProtocol) {
258
280
  if (response.statusCode !== 402) {
259
281
  return response;
@@ -723,12 +745,14 @@ export class BuyerProxy {
723
745
  _configFileWatching = false;
724
746
  _pinnedPeer;
725
747
  /**
726
- * Route substituted for the `antseed` model alias (`<service>` for automatic
727
- * peer selection, or `<peerId>@<service>` for an explicit seller pin).
728
- * Set via `POST /_antseed/route` (the desktop keeps it on the current VPR
729
- * selection) and persisted in buyer.state.json like the session peer pin.
748
+ * Default for the `antseed` model alias: an explicit model route (`model`) or an exact
749
+ * routing service (`router`). At most one is set. Set via `POST /_antseed/route` and
750
+ * persisted in buyer.state.json.
730
751
  */
731
752
  _defaultRoutedModel = null;
753
+ _defaultRouter = null;
754
+ _routingModels = new RoutingModelsCache();
755
+ _modelRoutingClient;
732
756
  _conversations;
733
757
  /**
734
758
  * Wall-clock of the last model-request activity (dispatch or streamed
@@ -818,6 +842,7 @@ export class BuyerProxy {
818
842
  this._restartAllowedAt = Date.now() + (config.minRestartIntervalMs ?? MIN_RESTART_INTERVAL_MS);
819
843
  this._settingOverrides = [...(config.settingOverrides ?? [])];
820
844
  this._node = config.node;
845
+ this._modelRoutingClient = config.modelRoutingClient ?? new ModelRoutingClient();
821
846
  this._verifier = config.verifier;
822
847
  this._buyerIdentities = config.buyerIdentities ?? new BuyerIdentityLoader(config.node, config.dataDir);
823
848
  this._teeVerification = new TeeVerification(config.verifier);
@@ -888,13 +913,15 @@ export class BuyerProxy {
888
913
  const entry = this._requestConversations.get(event.requestId);
889
914
  if (!entry)
890
915
  return;
916
+ const routing = entry.purpose === 'routing';
891
917
  this._conversations.addSpend(entry.convId, {
892
918
  amountUsdc: event.amountUsdc,
893
919
  inputTokens: event.inputTokens,
894
920
  cachedInputTokens: event.cachedInputTokens,
895
921
  outputTokens: event.outputTokens,
896
- }, !entry.counted);
897
- entry.counted = true;
922
+ }, !routing && !entry.counted);
923
+ if (!routing)
924
+ entry.counted = true;
898
925
  }
899
926
  /**
900
927
  * Identity named by a request: null for the default, the name when it is
@@ -1439,8 +1466,12 @@ export class BuyerProxy {
1439
1466
  * a grace window — the seller-initiated auth path can land just after the
1440
1467
  * response is returned. Bounded so a leaked id can't grow the map forever.
1441
1468
  */
1442
- _trackRequestConversation(requestId, convId) {
1443
- this._requestConversations.set(requestId, { convId, counted: false });
1469
+ _trackRequestConversation(requestId, convId, parentRequestId, purpose) {
1470
+ if (this._requestConversations.has(requestId))
1471
+ throw new Error('Request ID already tracked');
1472
+ // Inference retries share one turn counter; recommendation purchases have their own cost entry.
1473
+ const parent = !purpose && parentRequestId ? this._requestConversations.get(parentRequestId) : undefined;
1474
+ this._requestConversations.set(requestId, parent?.convId === convId ? parent : { convId, counted: false, purpose });
1444
1475
  while (this._requestConversations.size > MAX_TRACKED_REQUEST_CONVERSATIONS) {
1445
1476
  const oldest = this._requestConversations.keys().next().value;
1446
1477
  if (oldest === undefined)
@@ -1617,6 +1648,46 @@ export class BuyerProxy {
1617
1648
  // watcher setup failed; non-fatal
1618
1649
  }
1619
1650
  }
1651
+ _validateRouterSelection(value) {
1652
+ if (!isRoutingSelection(value) || value.kind !== 'router')
1653
+ throw new Error('Invalid router selection');
1654
+ if (!value.service)
1655
+ throw new Error('Select an exact routing-service target');
1656
+ }
1657
+ /** Parses the `router` field of `POST /_antseed/route` (`{ service, options?, allowedModels? }`). */
1658
+ _parseDefaultRouter(value) {
1659
+ if (!value || typeof value !== 'object' || Array.isArray(value))
1660
+ throw new Error('router must be { service, options?, allowedModels? }');
1661
+ const selection = { ...value, kind: 'router' };
1662
+ this._validateRouterSelection(selection);
1663
+ return structuredClone(selection);
1664
+ }
1665
+ /**
1666
+ * Check a router selection's options against the protocol its router advertises, when the
1667
+ * router is in the peer cache; throws `InvalidRouterOptionsError`. An unknown router is
1668
+ * checked again before each ranking.
1669
+ */
1670
+ async _checkRouterOptions(selection) {
1671
+ if (!selection.service || selection.options === undefined)
1672
+ return;
1673
+ let peers;
1674
+ try {
1675
+ peers = await this._getPeers();
1676
+ }
1677
+ catch {
1678
+ return; // Discovery is unavailable: the options are checked again before each ranking.
1679
+ }
1680
+ const protocol = routingProtocolOfTarget(peers, selection.service);
1681
+ if (protocol)
1682
+ validateRouterOptions(routingClientAdapter(protocol).options, selection.options);
1683
+ }
1684
+ /** The default router in its wire form, without the internal `kind` tag. */
1685
+ get _defaultRouterJson() {
1686
+ if (!this._defaultRouter)
1687
+ return null;
1688
+ const { kind: _kind, ...router } = this._defaultRouter;
1689
+ return router;
1690
+ }
1620
1691
  async _reloadSessionOverrides(opts = {}) {
1621
1692
  try {
1622
1693
  const raw = await readFile(this._stateFile, 'utf-8');
@@ -1629,7 +1700,16 @@ export class BuyerProxy {
1629
1700
  }
1630
1701
  const routedModel = typeof parsed.defaultRoutedModel === 'string' ? parsed.defaultRoutedModel.trim() : '';
1631
1702
  this._defaultRoutedModel = routedModel.length > 0 && isValidRoutedModelTarget(routedModel) ? routedModel : null;
1632
- log(`Session overrides reloaded: peer=${this._pinnedPeer ?? 'none'} route=${this._defaultRoutedModel ?? 'none'}`);
1703
+ try {
1704
+ this._defaultRouter = null;
1705
+ if (parsed.defaultRouter != null && this._defaultRoutedModel === null) {
1706
+ this._defaultRouter = this._parseDefaultRouter(parsed.defaultRouter);
1707
+ }
1708
+ }
1709
+ catch {
1710
+ this._defaultRouter = null;
1711
+ }
1712
+ log(`Session overrides reloaded: peer=${this._pinnedPeer ?? 'none'} route=${this._defaultRoutedModel ?? 'none'} router=${JSON.stringify(this._defaultRouterJson)}`);
1633
1713
  }
1634
1714
  catch {
1635
1715
  // state file unreadable; keep current values
@@ -1708,7 +1788,7 @@ export class BuyerProxy {
1708
1788
  // in the file — the debounce may have been cancelled before
1709
1789
  // _reloadSessionOverrides could commit the latest CLI-written values.
1710
1790
  const sessionOverrides = state === 'connected'
1711
- ? { pinnedPeerId: this._pinnedPeer, defaultRoutedModel: this._defaultRoutedModel }
1791
+ ? { pinnedPeerId: this._pinnedPeer, defaultRoutedModel: this._defaultRoutedModel, defaultRouter: this._defaultRouterJson }
1712
1792
  : {};
1713
1793
  await this._mergeStateFile({
1714
1794
  state,
@@ -2254,9 +2334,14 @@ export class BuyerProxy {
2254
2334
  res.end(JSON.stringify({ ok: true, peerId: normalized }));
2255
2335
  return;
2256
2336
  }
2337
+ if (path === '/_antseed/routing-services' && method === 'GET') {
2338
+ res.writeHead(200, { 'content-type': 'application/json' });
2339
+ res.end(JSON.stringify({ ok: true, services: await buildRoutingServices(await this._getPeers(), this._modelRoutingClient, this._node, this._routingModels) }));
2340
+ return;
2341
+ }
2257
2342
  if (path === '/_antseed/route' && method === 'GET') {
2258
2343
  res.writeHead(200, { 'content-type': 'application/json' });
2259
- res.end(JSON.stringify({ ok: true, model: this._defaultRoutedModel }));
2344
+ res.end(JSON.stringify({ ok: true, model: this._defaultRoutedModel, router: this._defaultRouterJson }));
2260
2345
  return;
2261
2346
  }
2262
2347
  if (path === '/_antseed/route' && method === 'POST') {
@@ -2271,9 +2356,10 @@ export class BuyerProxy {
2271
2356
  }
2272
2357
  chunks.push(chunk);
2273
2358
  }
2359
+ let body;
2274
2360
  let model;
2275
2361
  try {
2276
- const body = JSON.parse(Buffer.concat(chunks).toString());
2362
+ body = JSON.parse(Buffer.concat(chunks).toString());
2277
2363
  model = typeof body.model === 'string' ? body.model.trim() : '';
2278
2364
  }
2279
2365
  catch {
@@ -2281,16 +2367,29 @@ export class BuyerProxy {
2281
2367
  res.end(JSON.stringify({ ok: false, error: 'Invalid JSON body' }));
2282
2368
  return;
2283
2369
  }
2284
- if (model.length > 0 && !isValidRoutedModelTarget(model)) {
2370
+ let router = null;
2371
+ try {
2372
+ if (body.router != null) {
2373
+ if (model.length > 0)
2374
+ throw new Error('Set model or router, not both');
2375
+ router = this._parseDefaultRouter(body.router);
2376
+ await this._checkRouterOptions(router);
2377
+ }
2378
+ if (model.length > 0 && !isValidRoutedModelTarget(model)) {
2379
+ throw new Error('model must be "<service>", "<peerId>@<service>", or empty to clear');
2380
+ }
2381
+ }
2382
+ catch (error) {
2285
2383
  res.writeHead(400, { 'content-type': 'application/json' });
2286
- res.end(JSON.stringify({ ok: false, error: 'model must be "<service>", "<peerId>@<service>", or empty to clear' }));
2384
+ res.end(JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) }));
2287
2385
  return;
2288
2386
  }
2289
2387
  this._defaultRoutedModel = model.length > 0 ? model : null;
2290
- await this._mergeStateFile({ defaultRoutedModel: this._defaultRoutedModel });
2291
- log(`Default routed model set: ${this._defaultRoutedModel ?? 'none'}`);
2388
+ this._defaultRouter = router;
2389
+ await this._mergeStateFile({ defaultRoutedModel: this._defaultRoutedModel, defaultRouter: this._defaultRouterJson });
2390
+ log(`Default route set: model=${this._defaultRoutedModel ?? 'none'} router=${JSON.stringify(this._defaultRouterJson)}`);
2292
2391
  res.writeHead(200, { 'content-type': 'application/json' });
2293
- res.end(JSON.stringify({ ok: true, model: this._defaultRoutedModel }));
2392
+ res.end(JSON.stringify({ ok: true, model: this._defaultRoutedModel, router: this._defaultRouterJson }));
2294
2393
  return;
2295
2394
  }
2296
2395
  if (path === '/_antseed/conversations' && method === 'GET') {
@@ -2331,6 +2430,8 @@ export class BuyerProxy {
2331
2430
  let parsed;
2332
2431
  try {
2333
2432
  parsed = JSON.parse(Buffer.concat(chunks).toString());
2433
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
2434
+ throw new Error('Invalid conversation update');
2334
2435
  }
2335
2436
  catch {
2336
2437
  res.writeHead(400, { 'content-type': 'application/json' });
@@ -2355,6 +2456,23 @@ export class BuyerProxy {
2355
2456
  res.end(JSON.stringify({ ok: false, error: 'Unknown conversation' }));
2356
2457
  return;
2357
2458
  }
2459
+ if ('routingSelection' in parsed) {
2460
+ try {
2461
+ if ('pinnedModel' in parsed)
2462
+ throw new Error('Update routingSelection or pinnedModel, not both');
2463
+ if (parsed.routingSelection !== null) {
2464
+ this._validateRouterSelection(parsed.routingSelection);
2465
+ await this._checkRouterOptions(parsed.routingSelection);
2466
+ }
2467
+ }
2468
+ catch (error) {
2469
+ res.writeHead(400, { 'content-type': 'application/json' });
2470
+ res.end(JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) }));
2471
+ return;
2472
+ }
2473
+ const selection = parsed.routingSelection;
2474
+ conversation = this._conversations.setRoutingSelection(id, selection);
2475
+ }
2358
2476
  if ('pinnedModel' in parsed) {
2359
2477
  const pin = typeof parsed.pinnedModel === 'string' ? parsed.pinnedModel.trim() : '';
2360
2478
  if (pin.length > 0 && !isValidRoutedModelTarget(pin)) {
@@ -2730,18 +2848,66 @@ export class BuyerProxy {
2730
2848
  res.writeHead(400, responseHeaders);
2731
2849
  res.end(JSON.stringify({
2732
2850
  error: {
2733
- message: `Unknown model type "${url.searchParams.get('type') ?? ''}" — expected "text", "images", "decisions", or "videos".`,
2851
+ message: `Unknown model type "${url.searchParams.get('type') ?? ''}" — expected "text", "images", "decisions", "videos", or "routers".`,
2734
2852
  type: 'invalid_request_error',
2735
2853
  param: 'type',
2736
2854
  },
2737
2855
  }));
2738
2856
  return;
2739
2857
  }
2858
+ if (typeFilter === 'router') {
2859
+ const routers = await this._networkRouters(peers, requestPolicy);
2860
+ log(`GET /v1/models?type=router answered locally: ${routers.length} routers`);
2861
+ res.writeHead(200, responseHeaders);
2862
+ res.end(JSON.stringify({ object: 'list', data: routers }));
2863
+ return;
2864
+ }
2740
2865
  const data = typeFilter === 'all' ? models : models.filter((entry) => entry.type === typeFilter);
2741
2866
  log(`GET /v1/models answered locally: ${data.length} models across ${peers.length} peers${typeFilter ? ` (type=${typeFilter})` : ''}`);
2742
2867
  res.writeHead(200, responseHeaders);
2743
2868
  res.end(JSON.stringify({ object: 'list', data }));
2744
2869
  }
2870
+ /**
2871
+ * Routing services on the network, one entry per service id with each
2872
+ * router peer offering it. A router is listed only when a request could pay
2873
+ * it (see `_routerExclusionReasons`), and under a gateway policy its models
2874
+ * are cut to the ones the policy allows. Model lists are free calls, cached
2875
+ * per router.
2876
+ */
2877
+ async _networkRouters(peers, requestPolicy) {
2878
+ const policy = this._effectivePolicy(requestPolicy);
2879
+ const services = await buildRoutingServices(peers, this._modelRoutingClient, this._node, this._routingModels);
2880
+ const created = Math.floor(this._now() / 1000);
2881
+ const byId = new Map();
2882
+ for (const service of services) {
2883
+ const peer = peers.find((candidate) => candidate.peerId === service.peerId);
2884
+ if (!peer || this._routerExclusionReasons(peer, service.serviceId, requestPolicy).length > 0)
2885
+ continue;
2886
+ const models = service.catalog
2887
+ ? [...new Set(service.catalog.models.map((model) => model.serviceId))]
2888
+ .filter((model) => !policy || policyAllowsModel(policy, model))
2889
+ .sort((a, b) => a.localeCompare(b))
2890
+ : null;
2891
+ const price = Number(service.priceMicroUsdc);
2892
+ let entry = byId.get(service.serviceId);
2893
+ if (!entry) {
2894
+ entry = { id: service.serviceId, name: service.label, object: 'model', created, owned_by: 'antseed', type: 'router', peers: [] };
2895
+ byId.set(service.serviceId, entry);
2896
+ }
2897
+ entry.peers.push({
2898
+ peerId: service.peerId,
2899
+ ...(service.sellerName ? { displayName: service.sellerName } : {}),
2900
+ provider: service.provider,
2901
+ serviceId: service.serviceId,
2902
+ protocol: service.protocol,
2903
+ options: service.options,
2904
+ model: `${service.peerId}@${service.serviceId}`,
2905
+ priceUsdPerRequest: Number.isFinite(price) ? price / 1_000_000 : null,
2906
+ models,
2907
+ });
2908
+ }
2909
+ return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id));
2910
+ }
2745
2911
  /**
2746
2912
  * Whether a request under the gateway's policy could use this offer, by
2747
2913
  * the checks that judge a pinned `peer@model`: the model allow list, the
@@ -2911,6 +3077,19 @@ export class BuyerProxy {
2911
3077
  }));
2912
3078
  return;
2913
3079
  }
3080
+ // Per-request routing options (`routing` in the body) are read here and
3081
+ // stripped, so they never reach a seller, whatever the model. A malformed
3082
+ // `routing` is reported only when a router serves the request.
3083
+ let routingOptions = {};
3084
+ let invalidRouting = null;
3085
+ if (method === 'POST' && isCompletionRequestPath(path)) {
3086
+ const taken = takeRoutingOptions(serializedReq);
3087
+ if (taken.kind === 'options') {
3088
+ serializedReq = taken.request;
3089
+ routingOptions = taken.options;
3090
+ invalidRouting = taken.invalid ?? null;
3091
+ }
3092
+ }
2914
3093
  if (nativeVideo) {
2915
3094
  // Re-classify with the body: some APIs (Venice) carry the job ID in it.
2916
3095
  const prepared = prepareVideoRequest(nativeVideoRoute(serializedReq) ?? nativeVideo, serializedReq.headers, this._resourceRoutes);
@@ -2936,6 +3115,7 @@ export class BuyerProxy {
2936
3115
  const conversationIdentity = isConversationRequest
2937
3116
  ? extractConversationIdentity(serializedReq.headers, conversationBody)
2938
3117
  : null;
3118
+ const routingConversationKey = conversationIdentity ? `${conversationIdentity.tool}:${conversationIdentity.sessionKey}` : null;
2939
3119
  // Internal marker from the system proxy: source profile used only for
2940
3120
  // local conversation attribution. Stripped here so it never reaches a seller.
2941
3121
  delete serializedReq.headers[SYSTEM_PROXY_SOURCE_HEADER];
@@ -2945,6 +3125,10 @@ export class BuyerProxy {
2945
3125
  const storedConversation = conversationIdentity && trackedConversationKey
2946
3126
  ? this._conversations.get(`${conversationIdentity.tool}:${trackedConversationKey}`)
2947
3127
  : null;
3128
+ // Use this chat's router override when present; otherwise follow the buyer's default choice.
3129
+ const conversationSelection = storedConversation?.routingSelection ?? null;
3130
+ // Keep this request's choice stable; later selection changes affect subsequent requests.
3131
+ const routerSelection = structuredClone(conversationSelection ?? this._defaultRouter);
2948
3132
  const storedAutoRoute = storedConversation?.peerSource === 'auto' && storedConversation.pinnedModel
2949
3133
  ? parsePeerPinnedService(storedConversation.pinnedModel)
2950
3134
  : null;
@@ -2954,6 +3138,247 @@ export class BuyerProxy {
2954
3138
  const preferredPeerHeader = normalizePeerId(serializedReq.headers['x-antseed-prefer-peer'] ?? '');
2955
3139
  const preferredConversationPeerId = preferredPeerHeader ?? storedAutoRoute?.peerId ?? null;
2956
3140
  const effectiveRoutedModel = chatPinnedModel ?? this._defaultRoutedModel;
3141
+ // Where a router pin can come from besides the request's own model: the
3142
+ // `antseed` alias resolves to the chat's explicit pin or, when the chat
3143
+ // chose no route of its own (neither a pin nor a router), the default
3144
+ // route; a system-proxied request follows the chat's explicit pin. An
3145
+ // automatic chat route (the seller a router chose last turn) never
3146
+ // counts, so every turn of a router-pinned chat is ranked again.
3147
+ const userChatPin = storedConversation?.peerSource === 'user' ? storedConversation.pinnedModel : null;
3148
+ const aliasTarget = userChatPin ?? (conversationSelection ? null : this._defaultRoutedModel);
3149
+ // Reuse disconnect cancellation for both the recommendation and the eventual inference call.
3150
+ const clientAbortController = new AbortController();
3151
+ const onClientAbort = () => {
3152
+ if (clientAbortController.signal.aborted)
3153
+ return;
3154
+ clientAbortController.abort();
3155
+ log(`Client disconnected; aborting upstream request reqId=${serializedReq.requestId.slice(0, 8)}`);
3156
+ };
3157
+ req.once('close', () => {
3158
+ if (!req.complete && !res.writableEnded)
3159
+ onClientAbort();
3160
+ });
3161
+ res.once('close', () => {
3162
+ if (!res.writableEnded)
3163
+ onClientAbort();
3164
+ });
3165
+ // Explicit model/seller choices bypass recommendations, even when the default is a router.
3166
+ // A system-proxy connection's model was assigned at connect time, not chosen by the tool:
3167
+ // under a router it stands for the automatic alias, unless this chat is pinned. The request
3168
+ // is only rewritten once the router path is certain (below), so a request that goes direct
3169
+ // keeps its connect-time model.
3170
+ const systemRoutedAsAlias = routerSelection !== null && systemRoutedModel && storedConversation?.peerSource !== 'user';
3171
+ const rawService = systemRoutedAsAlias ? ROUTED_MODEL_ALIAS : extractRequestedService(serializedReq);
3172
+ // `<routerPeer>@<routingServiceId>`: the request names a routing service as
3173
+ // its model, so that router picks the model and seller for this request.
3174
+ const routerPinSource = rawService === ROUTED_MODEL_ALIAS
3175
+ ? aliasTarget
3176
+ : systemRoutedModel && userChatPin ? userChatPin : rawService;
3177
+ const pinnedRouter = await this._resolvePinnedRouter(routerPinSource);
3178
+ const autoRequested = routerSelection !== null
3179
+ && rawService === ROUTED_MODEL_ALIAS;
3180
+ const chatModelPin = storedConversation?.peerSource === 'user' ? chatPinnedModel : null;
3181
+ if (autoRequested && chatModelPin) {
3182
+ serializedReq = withRoutedModel(serializedReq, chatModelPin);
3183
+ }
3184
+ const useModelRouter = pinnedRouter !== null || (autoRequested && !chatModelPin
3185
+ && !getExplicitPeerIdOverride(serializedReq, effectivePinnedPeer ?? undefined));
3186
+ if (useModelRouter && systemRoutedAsAlias) {
3187
+ serializedReq = withRoutedModel(serializedReq, ROUTED_MODEL_ALIAS);
3188
+ }
3189
+ // A request without `routing` uses the saved router settings (options,
3190
+ // model allow list) when it pins the router those settings belong to;
3191
+ // a request's own `routing` options override the saved ones key by key.
3192
+ const savedRouter = this._defaultRouter;
3193
+ const savedForPinned = pinnedRouter && savedRouter && sameRoutingServiceTarget(savedRouter.service, pinnedRouter.service)
3194
+ ? structuredClone(savedRouter)
3195
+ : null;
3196
+ const baseSelection = pinnedRouter ? savedForPinned ?? pinnedRouter : routerSelection;
3197
+ const activeSelection = baseSelection && Object.keys(routingOptions).length > 0
3198
+ ? { ...baseSelection, options: { ...baseSelection.options, ...routingOptions } }
3199
+ : baseSelection;
3200
+ const context = {
3201
+ request: serializedReq, requiredParameters, requestPolicy, signal: clientAbortController.signal,
3202
+ systemRoutedModel, conversationIdentity, conversationBody, storedConversation,
3203
+ effectivePinnedPeer, effectiveRoutedModel, chatPinnedModel,
3204
+ preferredConversationPeerId,
3205
+ // Cache-reuse observations only feed a router, so skip them when none is configured.
3206
+ routingConversationKey: activeSelection ? routingConversationKey : null,
3207
+ spendAttributionTag,
3208
+ };
3209
+ // Both paths return an inference answer; only the router path buys a recommendation first.
3210
+ if (activeSelection && useModelRouter) {
3211
+ if (invalidRouting) {
3212
+ sendJson(res, 400, { error: { type: 'invalid_request_error', code: 'invalid_routing', message: invalidRouting, param: 'routing' } });
3213
+ return;
3214
+ }
3215
+ const refusal = await this._routerRefusal(activeSelection, requestPolicy);
3216
+ if (refusal) {
3217
+ log(`Routing service refused before ranking: ${refusal.error.message}`);
3218
+ sendJson(res, 403, refusal);
3219
+ return;
3220
+ }
3221
+ await this._dispatchViaModelRouter(res, context, activeSelection);
3222
+ }
3223
+ else {
3224
+ await this._dispatchDirectModelRequest(res, context);
3225
+ }
3226
+ }
3227
+ /**
3228
+ * Buy ranked recommendations, then send each one through the normal inference path
3229
+ * until one succeeds. A failed exact peer falls back to the next recommendation.
3230
+ */
3231
+ async _dispatchViaModelRouter(res, context, selection) {
3232
+ const { request, requiredParameters, requestPolicy, signal, routingConversationKey, spendAttributionTag } = context;
3233
+ const conversationId = this._trackRouterConversation(context);
3234
+ let recommendations;
3235
+ try {
3236
+ const client = this._modelRoutingClient;
3237
+ if (!selection.service)
3238
+ throw new Error('Select an exact routing-service target');
3239
+ const peers = await this._getPeers();
3240
+ // The router may only recommend destinations the buyer's own policies allow.
3241
+ const candidates = eligibleRouterCandidates(request, peers, requiredParameters, this._routingPreferences, (policyRequest, peer) => peerAllowedByPolicy(this._node.router, policyRequest, peer)
3242
+ && this._routerCandidateAllowedByRequestPolicy(requestPolicy, policyRequest, peer)
3243
+ && !isCoolingDown(this._peerHealth.get(peer.peerId), this._now()));
3244
+ recommendations = await executeRouterSelection({
3245
+ node: this._node, client, request, peers, candidates,
3246
+ conversationKey: routingConversationKey, selection, modelsCache: this._routingModels,
3247
+ ...(request.headers[BUYER_IDENTITY_HEADER] ? { buyerIdentity: request.headers[BUYER_IDENTITY_HEADER] } : {}),
3248
+ signal: AbortSignal.any([signal, AbortSignal.timeout(120_000)]),
3249
+ onRoutingRequest: requestId => {
3250
+ if (spendAttributionTag)
3251
+ this._spendAttribution.track(requestId, spendAttributionTag);
3252
+ if (conversationId)
3253
+ this._trackRequestConversation(requestId, conversationId, undefined, 'routing');
3254
+ },
3255
+ });
3256
+ }
3257
+ catch (error) {
3258
+ // Report routing failures rather than silently choosing a model for the user.
3259
+ const message = error instanceof Error ? error.message : String(error);
3260
+ if (error instanceof InvalidRouterOptionsError) {
3261
+ sendJson(res, 400, { error: { type: 'invalid_request_error', code: 'invalid_routing', message, param: 'routing' } });
3262
+ return;
3263
+ }
3264
+ writeDispatchFailure(res, error instanceof NoEligibleCandidatesError
3265
+ ? dispatchFailure(422, { 'content-type': 'application/json' }, JSON.stringify({
3266
+ error: { type: 'invalid_request_error', code: 'no_eligible_candidates', message },
3267
+ }))
3268
+ : dispatchFailure(502, { 'content-type': 'application/json' }, JSON.stringify({
3269
+ error: { type: 'routing_error', message },
3270
+ })));
3271
+ return;
3272
+ }
3273
+ let lastFailure = null;
3274
+ for (const recommendation of recommendations) {
3275
+ // Never switch sellers once the user has disconnected or a response has started.
3276
+ if (signal.aborted || res.headersSent || res.writableEnded)
3277
+ return;
3278
+ // Each attempt has its own billing ID, but all attempts count as one chat turn.
3279
+ const attempt = requestForRecommendation(request, recommendation, randomUUID());
3280
+ if (spendAttributionTag)
3281
+ this._spendAttribution.track(attempt.requestId, spendAttributionTag);
3282
+ if (conversationId)
3283
+ this._trackRequestConversation(attempt.requestId, conversationId, request.requestId);
3284
+ const result = await this._dispatchInference(res, context, attempt, conversationId);
3285
+ if (result.done)
3286
+ return;
3287
+ lastFailure = result;
3288
+ // Only try the next recommendation after a clear seller refusal, never after an ambiguous
3289
+ // transport failure or timeout (the seller may have done paid work) or a buyer-side fault.
3290
+ if (result.errorMessage !== null || isBuyerFault(result) || !RECOMMENDATION_FALLBACK_STATUS_CODES.has(result.statusCode))
3291
+ break;
3292
+ log(`Recommendation ${recommendation.peerId ? `${recommendation.peerId.slice(0, 12)}...@` : ''}${recommendation.serviceId} failed (${result.statusCode}); trying the next recommendation.`);
3293
+ }
3294
+ if (signal.aborted)
3295
+ return;
3296
+ writeDispatchFailure(res, lastFailure ?? dispatchFailure(502, { 'content-type': 'application/json' }, JSON.stringify({
3297
+ error: { type: 'routing_error', message: 'No recommended destination could serve this request' },
3298
+ })));
3299
+ }
3300
+ /**
3301
+ * The router selection a `<peerId>@<serviceId>` model names, when that
3302
+ * peer's routing service is `<serviceId>`. A peer missing from the cache
3303
+ * gets one refresh first, so a router that just came online is not taken
3304
+ * for a seller. Still missing, the pin goes down the seller-pin path, which
3305
+ * reports the peer as unreachable.
3306
+ */
3307
+ async _resolvePinnedRouter(model) {
3308
+ const pin = model ? parsePeerPinnedService(model) : null;
3309
+ if (!pin)
3310
+ return null;
3311
+ let peers = await this._getPeers();
3312
+ if (!peers.some((peer) => peer.peerId.toLowerCase() === pin.peerId)) {
3313
+ peers = await this._getPeers({ forceRefresh: true });
3314
+ }
3315
+ const service = findPinnedRoutingService(peers, pin.peerId, pin.service);
3316
+ return service ? { kind: 'router', service } : null;
3317
+ }
3318
+ /**
3319
+ * Why a routing service may not be paid, the way a pinned seller is judged:
3320
+ * the gateway policy's model allow list (the router's service id counts as
3321
+ * its model), its seller rules and strict model routes, and the buyer's
3322
+ * hard limits, all through `routingServicePolicy`. Empty when allowed.
3323
+ */
3324
+ _routerExclusionReasons(peer, serviceId, requestPolicy) {
3325
+ const facts = this._policyFacts(peer, null);
3326
+ const reasons = requestPolicy ? pinnedPeerExclusionReasons(routingServicePolicy(requestPolicy), facts, serviceId) : [];
3327
+ if (requestPolicy && !policyAllowsModel(this._effectivePolicy(requestPolicy), serviceId))
3328
+ reasons.unshift('model not allowed');
3329
+ addBuyerConfigReasons(reasons, policyExclusionReasons(routingServicePolicy(this._buyerHardPolicy()), facts));
3330
+ return reasons;
3331
+ }
3332
+ /** A 403 body when the policy forbids paying this router for a ranking; null when allowed or its peer is unknown. */
3333
+ async _routerRefusal(selection, requestPolicy) {
3334
+ const service = selection.service;
3335
+ if (!service)
3336
+ return null;
3337
+ const peer = (await this._getPeers()).find((candidate) => candidate.peerId.toLowerCase() === service.peerId.toLowerCase());
3338
+ if (!peer)
3339
+ return null;
3340
+ const reasons = this._routerExclusionReasons(peer, service.serviceId, requestPolicy);
3341
+ if (reasons.length === 0)
3342
+ return null;
3343
+ const label = `${service.peerId.slice(0, 12)}...@${service.serviceId}`;
3344
+ if (reasons[0] === 'model not allowed') {
3345
+ return { error: { type: 'permission_error', code: 'model_not_allowed', param: 'model',
3346
+ message: `Routing service "${service.serviceId}" is not allowed by the routing policy.` } };
3347
+ }
3348
+ return { error: { type: 'permission_error', code: 'peer_not_allowed',
3349
+ message: `Routing service ${label} is not allowed by the routing policy (${reasons.join('; ')}).` } };
3350
+ }
3351
+ _routerCandidateAllowedByRequestPolicy(requestPolicy, request, peer) {
3352
+ if (!requestPolicy)
3353
+ return true;
3354
+ const provider = getExplicitProviderOverride(request);
3355
+ const serviceId = extractRequestedService(request);
3356
+ const offer = provider && serviceId ? findAdvertisedServiceOffer(peer, provider, serviceId) : null;
3357
+ return offer !== null
3358
+ && this._policyAllowsOffer(requestPolicy, peer, offer)
3359
+ && policyExclusionReasons(this._buyerHardPolicy(), this._policyFacts(peer, offer)).length === 0;
3360
+ }
3361
+ /** Track a router-served chat before buying its recommendation, so routing costs are attributed. */
3362
+ _trackRouterConversation(context) {
3363
+ const { request, conversationIdentity, conversationBody, storedConversation } = context;
3364
+ if (!conversationIdentity?.isUserThread)
3365
+ return storedConversation?.id ?? null;
3366
+ if (!storedConversation && isTitleGenerationRequest(conversationBody))
3367
+ return null;
3368
+ const { id } = this._conversations.touch({
3369
+ tool: conversationIdentity.tool,
3370
+ sessionKey: conversationIdentity.parentSessionKey ?? conversationIdentity.sessionKey,
3371
+ snippet: storedConversation?.snippet ? null : extractFirstUserSnippet(conversationBody),
3372
+ lastModel: null,
3373
+ });
3374
+ // Inference attempts link to this ID so fallbacks count as one chat turn.
3375
+ this._trackRequestConversation(request.requestId, id);
3376
+ return id;
3377
+ }
3378
+ /** Apply model aliases and chat pins, then attach inference to its conversation for accounting. */
3379
+ _prepareInferenceRequest(res, context) {
3380
+ const { effectiveRoutedModel, chatPinnedModel, systemRoutedModel, storedConversation, conversationIdentity, conversationBody } = context;
3381
+ let serializedReq = context.request;
2957
3382
  let trackedConversationId = storedConversation?.id ?? null;
2958
3383
  // Resolve the `antseed` model alias to the session's default route first,
2959
3384
  // so the regular `<peerId>@<service>` pin rewrite below picks up the
@@ -2972,7 +3397,7 @@ export class BuyerProxy {
2972
3397
  param: 'model',
2973
3398
  },
2974
3399
  }));
2975
- return;
3400
+ return null;
2976
3401
  }
2977
3402
  if (aliasResult.substituted) {
2978
3403
  serializedReq = { ...serializedReq, body: aliasResult.body, headers: aliasResult.headers };
@@ -3035,10 +3460,29 @@ export class BuyerProxy {
3035
3460
  this._trackRequestConversation(serializedReq.requestId, tracked.id);
3036
3461
  }
3037
3462
  }
3463
+ return { request: serializedReq, conversationId: trackedConversationId };
3464
+ }
3465
+ /** Send a fixed-model or pinned-seller request without buying a model recommendation. */
3466
+ async _dispatchDirectModelRequest(res, context) {
3467
+ const prepared = this._prepareInferenceRequest(res, context);
3468
+ if (!prepared)
3469
+ return;
3470
+ const result = await this._dispatchInference(res, context, prepared.request, prepared.conversationId);
3471
+ if (!result.done)
3472
+ writeDispatchFailure(res, result);
3473
+ }
3474
+ /**
3475
+ * Select a seller for the requested model (or the pinned seller) and send the request.
3476
+ * Success is written to `res`; failures are returned so callers can fall back or report them.
3477
+ */
3478
+ async _dispatchInference(res, context, request, trackedConversationId) {
3038
3479
  // A chat switched while this request is in flight must keep the new route.
3039
3480
  const conversationPinAtRouting = trackedConversationId
3040
3481
  ? this._conversations.get(trackedConversationId)?.pinnedModel ?? null
3041
3482
  : null;
3483
+ const nativeVideo = nativeVideoRoute(request);
3484
+ let serializedReq = request;
3485
+ const { requiredParameters, requestPolicy, signal, effectivePinnedPeer, preferredConversationPeerId, routingConversationKey } = context;
3042
3486
  const { body: servicePinBody, headers: servicePinHeaders, pinnedPeerId: bodyPinnedPeer, } = rewritePeerPinnedServiceInBody(serializedReq.body, serializedReq.headers);
3043
3487
  if (servicePinBody !== serializedReq.body) {
3044
3488
  serializedReq = { ...serializedReq, body: servicePinBody, headers: servicePinHeaders };
@@ -3046,24 +3490,6 @@ export class BuyerProxy {
3046
3490
  log(`Model peer pin applied: peer=${bodyPinnedPeer.slice(0, 12)}...`);
3047
3491
  }
3048
3492
  }
3049
- const clientAbortController = new AbortController();
3050
- const onClientAbort = () => {
3051
- if (clientAbortController.signal.aborted) {
3052
- return;
3053
- }
3054
- clientAbortController.abort();
3055
- log(`Client disconnected; aborting upstream request reqId=${serializedReq.requestId.slice(0, 8)}`);
3056
- };
3057
- req.once('close', () => {
3058
- if (!req.complete && !res.writableEnded) {
3059
- onClientAbort();
3060
- }
3061
- });
3062
- res.once('close', () => {
3063
- if (!res.writableEnded) {
3064
- onClientAbort();
3065
- }
3066
- });
3067
3493
  const requestProtocol = detectRequestServiceApiProtocol(serializedReq);
3068
3494
  const requestedService = extractRequestedService(serializedReq);
3069
3495
  log(`Routing: protocol=${requestProtocol ?? 'null'} service=${requestedService ?? 'null'}`);
@@ -3078,8 +3504,7 @@ export class BuyerProxy {
3078
3504
  + ' • Model name prefix: <peerId>@<model>\n'
3079
3505
  + ' • Session pin: antseed buyer connection set --peer <peerId>\n'
3080
3506
  + 'Discover peers with: antseed network browse';
3081
- res.writeHead(400, { 'content-type': 'application/json' });
3082
- res.end(JSON.stringify({
3507
+ return dispatchFailure(400, { 'content-type': 'application/json' }, JSON.stringify({
3083
3508
  error: {
3084
3509
  type: 'missing_routing_target',
3085
3510
  code: 'missing_routing_target',
@@ -3093,26 +3518,22 @@ export class BuyerProxy {
3093
3518
  },
3094
3519
  },
3095
3520
  }));
3096
- return;
3097
3521
  }
3098
3522
  if (requestPolicy && !policyAllowsModel(this._effectivePolicy(requestPolicy), requestedService)) {
3099
- sendJson(res, 403, {
3523
+ return dispatchFailure(403, { 'content-type': 'application/json' }, JSON.stringify({
3100
3524
  error: {
3101
3525
  type: 'permission_error',
3102
3526
  code: 'model_not_allowed',
3103
3527
  message: `Model "${requestedService ?? 'unknown'}" is not allowed by the routing policy.`,
3104
3528
  param: 'model',
3105
3529
  },
3106
- });
3107
- return;
3530
+ }));
3108
3531
  }
3109
3532
  // Discover peers
3110
3533
  const peers = await this._getPeers();
3111
3534
  if (peers.length === 0) {
3112
3535
  log('No sellers available');
3113
- res.writeHead(502, { 'content-type': 'text/plain' });
3114
- res.end('No sellers available on the network. Is a seeder running?');
3115
- return;
3536
+ return dispatchFailure(502, { 'content-type': 'text/plain' }, 'No sellers available on the network. Is a seeder running?');
3116
3537
  }
3117
3538
  if (!explicitPeerId && requestedService) {
3118
3539
  const selectModelPeers = (candidateSources) => selectCandidatePeersForRouting(candidateSources, requestProtocol, requestedService, explicitProvider, 'strict');
@@ -3147,8 +3568,7 @@ export class BuyerProxy {
3147
3568
  ? ` "${requestedService}" is a decision model; call POST /v1/systemone.`
3148
3569
  : '';
3149
3570
  log(`Request rejected: model ${requestedService} is served only via ${advertisedProtocols.join(', ')}`);
3150
- res.writeHead(400, { 'content-type': 'application/json' });
3151
- res.end(JSON.stringify({
3571
+ return dispatchFailure(400, { 'content-type': 'application/json' }, JSON.stringify({
3152
3572
  error: {
3153
3573
  type: 'unsupported_protocol',
3154
3574
  code: 'unsupported_protocol',
@@ -3157,10 +3577,8 @@ export class BuyerProxy {
3157
3577
  supported_protocols: advertisedProtocols,
3158
3578
  },
3159
3579
  }));
3160
- return;
3161
3580
  }
3162
- res.writeHead(capabilityRequired ? 422 : 502, { 'content-type': 'application/json' });
3163
- res.end(JSON.stringify({
3581
+ return dispatchFailure(capabilityRequired ? 422 : 502, { 'content-type': 'application/json' }, JSON.stringify({
3164
3582
  error: capabilityRequired
3165
3583
  ? {
3166
3584
  type: 'required_capability_unavailable',
@@ -3175,7 +3593,6 @@ export class BuyerProxy {
3175
3593
  param: 'model',
3176
3594
  },
3177
3595
  }));
3178
- return;
3179
3596
  }
3180
3597
  let lastRetry = null;
3181
3598
  let lastVerificationError = null;
@@ -3187,8 +3604,8 @@ export class BuyerProxy {
3187
3604
  if (unfunded && !isFree(selected))
3188
3605
  continue;
3189
3606
  if (this._verifier) {
3190
- const makeReach = (chosenId) => makeVerifierReach(this._node, selected.peer, chosenId, clientAbortController.signal);
3191
- const outcome = await this._verifyPeer(selected.peer, makeReach, clientAbortController.signal, requireVerified);
3607
+ const makeReach = (chosenId) => makeVerifierReach(this._node, selected.peer, chosenId, signal);
3608
+ const outcome = await this._verifyPeer(selected.peer, makeReach, signal, requireVerified);
3192
3609
  if (!outcome.ok) {
3193
3610
  lastVerificationError = `Peer ${selected.peer.peerId.slice(0, 12)}... failed required verification (${outcome.reason ?? 'failed'}).`;
3194
3611
  log(`${lastVerificationError} Trying the next model peer.`);
@@ -3200,33 +3617,28 @@ export class BuyerProxy {
3200
3617
  + `service="${selected.serviceId}" reputation=${selected.reputation} `
3201
3618
  + `effective=${selected.effectiveReputationScore ?? 'unknown'} peer=${index + 1}/${candidates.length} `
3202
3619
  + `attempt=${peerAttempt + 1}/${MODEL_RATE_LIMIT_MAX_ATTEMPTS_PER_PEER}`);
3203
- const result = await this._dispatchToPeer(res, selected.request, selected.peer, modelPlans, requestProtocol, selected.serviceId, explicitProvider, router, false, clientAbortController.signal, requestPolicy !== null);
3620
+ const result = await this._dispatchToPeer(res, selected.request, selected.peer, modelPlans, requestProtocol, selected.serviceId, explicitProvider, router, false, signal, requestPolicy !== null, routingConversationKey);
3204
3621
  if (result.done) {
3205
3622
  // Only a seller that answered becomes the chat's route: an error
3206
3623
  // must not pin later turns to it.
3207
3624
  if (trackedConversationId && res.statusCode < 400) {
3208
3625
  this._conversations.recordRoutedModel(trackedConversationId, `${selected.peer.peerId}@${selected.serviceId}`, conversationPinAtRouting);
3209
3626
  }
3210
- return;
3627
+ return result;
3211
3628
  }
3212
3629
  // Never retry a video on another seller: a create may already have
3213
3630
  // started (and been charged), and follow-ups only exist on one seller.
3214
- if (nativeVideo) {
3215
- res.writeHead(result.statusCode, result.responseHeaders);
3216
- res.end(result.responseBody);
3217
- return;
3218
- }
3631
+ if (nativeVideo)
3632
+ return result;
3219
3633
  lastRetry = result;
3220
- if (result.responseHeaders[ANTSEED_FAULT_ATTRIBUTION_HEADER]?.toLowerCase() === 'buyer') {
3634
+ if (isBuyerFault(result)) {
3221
3635
  const freeLeft = candidates.slice(index + 1).some(isFree);
3222
3636
  if (result.statusCode === 402 && freeLeft) {
3223
3637
  unfunded = true;
3224
3638
  log(`No deposits to pay peer ${selected.peer.peerId.slice(0, 12)}...; trying free sellers only.`);
3225
3639
  break;
3226
3640
  }
3227
- res.writeHead(result.statusCode, result.responseHeaders);
3228
- res.end(result.responseBody);
3229
- return;
3641
+ return result;
3230
3642
  }
3231
3643
  const retrySamePeer = result.statusCode === 429
3232
3644
  && peerAttempt + 1 < MODEL_RATE_LIMIT_MAX_ATTEMPTS_PER_PEER;
@@ -3234,28 +3646,20 @@ export class BuyerProxy {
3234
3646
  break;
3235
3647
  const delayMs = rateLimitRetryDelayMs(result.responseHeaders, peerAttempt);
3236
3648
  log(`Peer ${selected.peer.peerId.slice(0, 12)}... is rate-limited; retrying in ${delayMs}ms.`);
3237
- if (!await waitForRetry(delayMs, clientAbortController.signal))
3238
- return;
3649
+ if (!await waitForRetry(delayMs, signal))
3650
+ return { done: true };
3239
3651
  }
3240
3652
  if (index + 1 < candidates.length) {
3241
3653
  log(`Peer ${selected.peer.peerId.slice(0, 12)}... failed retryably; trying next model peer.`);
3242
3654
  }
3243
3655
  }
3244
- if (lastRetry) {
3245
- res.writeHead(lastRetry.statusCode, lastRetry.responseHeaders);
3246
- res.end(lastRetry.responseBody);
3247
- }
3248
- else {
3249
- res.writeHead(502, { 'content-type': 'application/json' });
3250
- res.end(JSON.stringify({
3251
- error: {
3252
- type: 'peer_verification_failed',
3253
- code: 'peer_verification_failed',
3254
- message: lastVerificationError ?? `No verified peer currently serves model "${requestedService}".`,
3255
- },
3256
- }));
3257
- }
3258
- return;
3656
+ return lastRetry ?? dispatchFailure(502, { 'content-type': 'application/json' }, JSON.stringify({
3657
+ error: {
3658
+ type: 'peer_verification_failed',
3659
+ code: 'peer_verification_failed',
3660
+ message: lastVerificationError ?? `No verified peer currently serves model "${requestedService}".`,
3661
+ },
3662
+ }));
3259
3663
  }
3260
3664
  const pinnedPeerId = explicitPeerId;
3261
3665
  // Narrow the candidate set to just the pinned peer (if we already know
@@ -3303,12 +3707,12 @@ export class BuyerProxy {
3303
3707
  : '--peer flag or session pin';
3304
3708
  const diagnostics = this._formatPeerSelectionDiagnostics(discoveredPeers);
3305
3709
  log(`Pinned peer ${pinnedPeerId.slice(0, 12)}... not discoverable in DHT (${logSource})`);
3306
- res.writeHead(502, { 'content-type': 'text/plain' });
3307
- res.end(`Pinned peer ${pinnedPeerId.slice(0, 12)}... is not reachable right now. `
3710
+ // Also the answer for a `<peerId>@<routingServiceId>` pin whose router
3711
+ // is offline: an absent peer cannot be told apart from a seller.
3712
+ return dispatchFailure(502, { 'content-type': 'text/plain' }, `Pinned peer ${pinnedPeerId.slice(0, 12)}... (seller or routing service) is not reachable right now. `
3308
3713
  + 'It may be offline, not announcing, or temporarily unreachable. '
3309
3714
  + 'Pick a different service in Discover or try again later. '
3310
3715
  + diagnostics);
3311
- return;
3312
3716
  }
3313
3717
  if (routingPeers.length === 0) {
3314
3718
  const pinnedPeer = discoveredPeers.find((peer) => peer.peerId.toLowerCase() === pinnedPeerId) ?? null;
@@ -3323,20 +3727,16 @@ export class BuyerProxy {
3323
3727
  if (!providers.includes(explicitProvider)) {
3324
3728
  const providerList = providers.length > 0 ? providers.join(', ') : 'none';
3325
3729
  log(`Pinned peer ${pinnedPeerId.slice(0, 12)}... does not offer explicit provider=${explicitProvider}`);
3326
- res.writeHead(502, { 'content-type': 'text/plain' });
3327
- res.end(`Pinned peer ${pinnedPeerId.slice(0, 12)}... does not offer provider=${explicitProvider}. `
3730
+ return dispatchFailure(502, { 'content-type': 'text/plain' }, `Pinned peer ${pinnedPeerId.slice(0, 12)}... does not offer provider=${explicitProvider}. `
3328
3731
  + `Available providers: ${providerList}. `
3329
3732
  + 'Remove or change the x-antseed-provider header, or pick a different peer. '
3330
3733
  + diagnostics);
3331
- return;
3332
3734
  }
3333
3735
  }
3334
3736
  log(`Pinned peer ${pinnedPeerId.slice(0, 12)}... filtered out by protocol/service match`);
3335
- res.writeHead(502, { 'content-type': 'text/plain' });
3336
- res.end(`Pinned peer ${pinnedPeerId.slice(0, 12)}... does not support this request `
3737
+ return dispatchFailure(502, { 'content-type': 'text/plain' }, `Pinned peer ${pinnedPeerId.slice(0, 12)}... does not support this request `
3337
3738
  + `(${protocolLabel}, ${providerLabel}, ${serviceLabel}). `
3338
3739
  + `Pick a different service in Discover. ${diagnostics}`);
3339
- return;
3340
3740
  }
3341
3741
  log(`Routing candidates: ${routingPeers.length} peer(s)`);
3342
3742
  const router = this._node.router;
@@ -3346,9 +3746,7 @@ export class BuyerProxy {
3346
3746
  // if an invariant breaks.
3347
3747
  if (!selectedPeer) {
3348
3748
  log(`Invariant: pinned peer ${pinnedPeerId.slice(0, 12)}... present in DHT but missing from narrowed candidate list`);
3349
- res.writeHead(502, { 'content-type': 'text/plain' });
3350
- res.end(`Pinned peer ${pinnedPeerId.slice(0, 12)}... is currently unreachable. Try again in a moment.`);
3351
- return;
3749
+ return dispatchFailure(502, { 'content-type': 'text/plain' }, `Pinned peer ${pinnedPeerId.slice(0, 12)}... is currently unreachable. Try again in a moment.`);
3352
3750
  }
3353
3751
  const policyRouter = router;
3354
3752
  const selectedPlan = routingPlans.get(selectedPeer.peerId)
@@ -3358,8 +3756,7 @@ export class BuyerProxy {
3358
3756
  ? findMissingRequiredParameters(selectedPeer, selectedPlan.provider, pinnedServiceId, requiredParameters)
3359
3757
  : requiredParameters;
3360
3758
  if (missingRequired.length > 0) {
3361
- res.writeHead(422, { 'content-type': 'application/json' });
3362
- res.end(JSON.stringify({
3759
+ return dispatchFailure(422, { 'content-type': 'application/json' }, JSON.stringify({
3363
3760
  error: {
3364
3761
  type: 'required_capability_unavailable',
3365
3762
  code: 'required_capability_unavailable',
@@ -3367,7 +3764,6 @@ export class BuyerProxy {
3367
3764
  param: REQUIRED_PARAMETERS_HEADER,
3368
3765
  },
3369
3766
  }));
3370
- return;
3371
3767
  }
3372
3768
  const pinnedRequest = pinnedServiceId ? withRoutedModel(serializedReq, pinnedServiceId) : serializedReq;
3373
3769
  // A hard pin cannot escape the gateway's policy: an excluded seller is
@@ -3385,26 +3781,23 @@ export class BuyerProxy {
3385
3781
  addBuyerConfigReasons(reasons, policyExclusionReasons(this._buyerHardPolicy(), facts));
3386
3782
  if (reasons.length > 0) {
3387
3783
  log(`Pinned peer ${selectedPeer.peerId.slice(0, 12)}... refused by routing policy: ${reasons.join('; ')}`);
3388
- sendJson(res, 403, {
3784
+ return dispatchFailure(403, { 'content-type': 'application/json' }, JSON.stringify({
3389
3785
  error: {
3390
3786
  type: 'permission_error',
3391
3787
  code: 'peer_not_allowed',
3392
3788
  message: `Seller ${selectedPeer.peerId.slice(0, 12)}... is not allowed by the routing policy (${reasons.join('; ')}).`,
3393
3789
  },
3394
- });
3395
- return;
3790
+ }));
3396
3791
  }
3397
3792
  }
3398
3793
  if (!peerAllowedByPolicy(policyRouter, pinnedRequest, selectedPeer)) {
3399
3794
  log(`Pinned peer ${selectedPeer.peerId.slice(0, 12)}... filtered out by buyer routing policy`);
3400
- res.writeHead(502, { 'content-type': 'text/plain' });
3401
- res.end(`Pinned peer ${selectedPeer.peerId.slice(0, 12)}... is outside your buyer routing policy. `
3795
+ return dispatchFailure(502, { 'content-type': 'text/plain' }, `Pinned peer ${selectedPeer.peerId.slice(0, 12)}... is outside your buyer routing policy. `
3402
3796
  + 'Pick a different service in Discover or adjust your buyer pricing/reputation limits.');
3403
- return;
3404
3797
  }
3405
3798
  if (this._verifier) {
3406
- const makeReach = (chosenId) => makeVerifierReach(this._node, selectedPeer, chosenId, clientAbortController.signal);
3407
- const outcome = await this._verifyPeer(selectedPeer, makeReach, clientAbortController.signal, pinnedPolicy?.requireVerified === true);
3799
+ const makeReach = (chosenId) => makeVerifierReach(this._node, selectedPeer, chosenId, signal);
3800
+ const outcome = await this._verifyPeer(selectedPeer, makeReach, signal, pinnedPolicy?.requireVerified === true);
3408
3801
  const short = selectedPeer.peerId.slice(0, 12);
3409
3802
  if (outcome.verified) {
3410
3803
  log(`Verified ${short}... via ${outcome.sdk}`);
@@ -3413,23 +3806,17 @@ export class BuyerProxy {
3413
3806
  log(`Verification ${outcome.sdk ? `(${outcome.sdk}) ` : ''}did not pass for ${short}...: ${outcome.reason ?? 'failed'}${outcome.ok ? ' (optional — routing anyway)' : ''}`);
3414
3807
  }
3415
3808
  if (!outcome.ok) {
3416
- res.writeHead(502, { 'content-type': 'text/plain' });
3417
- res.end(`Pinned peer ${short}... failed required verification (${outcome.reason ?? 'failed'}). `
3809
+ return dispatchFailure(502, { 'content-type': 'text/plain' }, `Pinned peer ${short}... failed required verification (${outcome.reason ?? 'failed'}). `
3418
3810
  + 'Pick a different peer, or run without --require-verifier.');
3419
- return;
3420
3811
  }
3421
3812
  }
3422
3813
  log(`Using pinned peer ${selectedPeer.peerId.slice(0, 12)}...`);
3423
- const result = await this._dispatchToPeer(res, pinnedRequest, selectedPeer, routingPlans, requestProtocol, requestedService, explicitProvider, router, true, clientAbortController.signal);
3814
+ const result = await this._dispatchToPeer(res, pinnedRequest, selectedPeer, routingPlans, requestProtocol, requestedService, explicitProvider, router, true, signal, false, routingConversationKey);
3424
3815
  if (result.done && trackedConversationId && pinnedServiceId) {
3425
3816
  this._conversations.recordRoutedModel(trackedConversationId, `${selectedPeer.peerId}@${pinnedServiceId}`, conversationPinAtRouting);
3426
3817
  }
3427
- if (!result.done) {
3428
- // Pinned peer returned a retryable error. We never retry against another
3429
- // peer for an explicit pin, so surface the error to the client.
3430
- res.writeHead(result.statusCode, result.responseHeaders);
3431
- res.end(result.responseBody);
3432
- }
3818
+ // A pinned peer is never retried against another peer; the caller reports any failure.
3819
+ return result;
3433
3820
  }
3434
3821
  async _verifyPeer(peer, makeReach, signal, requireVerified = false) {
3435
3822
  const configured = this._verifier;
@@ -3514,7 +3901,7 @@ export class BuyerProxy {
3514
3901
  * was sent to the client (success or non-retryable error), or retry info if the
3515
3902
  * caller should try another peer.
3516
3903
  */
3517
- async _dispatchToPeer(res, serializedReq, selectedPeer, routePlanByPeerId, requestProtocol, requestedService, explicitProvider, router, pinned, requestSignal, gatewayRouted = false) {
3904
+ async _dispatchToPeer(res, serializedReq, selectedPeer, routePlanByPeerId, requestProtocol, requestedService, explicitProvider, router, pinned, requestSignal, gatewayRouted = false, routingConversationKey = null) {
3518
3905
  const gatewayAutoRoute = gatewayRouted && !pinned;
3519
3906
  const selectedRoutePlan = routePlanByPeerId.get(selectedPeer.peerId)
3520
3907
  ?? resolvePeerRoutePlan(selectedPeer, requestProtocol, requestedService, explicitProvider, 'lenient');
@@ -3651,6 +4038,7 @@ export class BuyerProxy {
3651
4038
  }
3652
4039
  },
3653
4040
  }, { signal: requestSignal, pinned, ...(buyerIdentity ? { buyerIdentity } : {}) });
4041
+ recordRouterUsage(this._modelRoutingClient, routingConversationKey, requestForPeer, response, selectedPeer, requestSignal);
3654
4042
  let responseForClient = adaptBuyerFaultErrorResponse(response, requestProtocol);
3655
4043
  responseForClient = adaptPeerResponse(responseForClient);
3656
4044
  if (!streamed
@@ -3726,6 +4114,7 @@ export class BuyerProxy {
3726
4114
  if (upstreamResponse.statusCode >= 400 && !adaptResponse) {
3727
4115
  log(`Upstream raw error detail: ${summarizeErrorResponse(upstreamResponse)}`);
3728
4116
  }
4117
+ recordRouterUsage(this._modelRoutingClient, routingConversationKey, requestForPeer, upstreamResponse, selectedPeer, requestSignal);
3729
4118
  // Only the seller that accepted a video job knows its ID. Remember and
3730
4119
  // persist job ID -> seller so later retrieve requests (even
3731
4120
  // after a proxy restart) are pinned back to that seller.