@antseed/cli 0.1.187 → 0.1.189

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 (89) hide show
  1. package/README.md +9 -0
  2. package/dist/config/service-metadata.test.js +4 -0
  3. package/dist/config/service-metadata.test.js.map +1 -1
  4. package/dist/gateway/console-api/handlers/network-mapping.d.ts +2 -0
  5. package/dist/gateway/console-api/handlers/network-mapping.d.ts.map +1 -1
  6. package/dist/gateway/console-api/handlers/network-mapping.js +4 -0
  7. package/dist/gateway/console-api/handlers/network-mapping.js.map +1 -1
  8. package/dist/gateway/console-api/handlers/network.test.js +5 -1
  9. package/dist/gateway/console-api/handlers/network.test.js.map +1 -1
  10. package/dist/gateway/console-api/types.d.ts +10 -2
  11. package/dist/gateway/console-api/types.d.ts.map +1 -1
  12. package/dist/gateway/console-api/types.js.map +1 -1
  13. package/dist/gateway/gateway-policy.test.js +44 -0
  14. package/dist/gateway/gateway-policy.test.js.map +1 -1
  15. package/dist/gateway/server.d.ts.map +1 -1
  16. package/dist/gateway/server.js +12 -1
  17. package/dist/gateway/server.js.map +1 -1
  18. package/dist/gateway/services/usage.d.ts.map +1 -1
  19. package/dist/gateway/services/usage.js +3 -1
  20. package/dist/gateway/services/usage.js.map +1 -1
  21. package/dist/gateway/store-data.test.js +1 -1
  22. package/dist/gateway/store-data.test.js.map +1 -1
  23. package/dist/gateway/store-migrations.d.ts.map +1 -1
  24. package/dist/gateway/store-migrations.js +5 -0
  25. package/dist/gateway/store-migrations.js.map +1 -1
  26. package/dist/gateway/store.d.ts +9 -2
  27. package/dist/gateway/store.d.ts.map +1 -1
  28. package/dist/gateway/store.js +26 -10
  29. package/dist/gateway/store.js.map +1 -1
  30. package/dist/gateway/store.test.js +1 -1
  31. package/dist/gateway/store.test.js.map +1 -1
  32. package/dist/plugins/registry.d.ts.map +1 -1
  33. package/dist/plugins/registry.js +6 -0
  34. package/dist/plugins/registry.js.map +1 -1
  35. package/dist/proxy/buyer-proxy.d.ts +64 -8
  36. package/dist/proxy/buyer-proxy.d.ts.map +1 -1
  37. package/dist/proxy/buyer-proxy.js +523 -145
  38. package/dist/proxy/buyer-proxy.js.map +1 -1
  39. package/dist/proxy/buyer-proxy.test.js +1357 -5
  40. package/dist/proxy/buyer-proxy.test.js.map +1 -1
  41. package/dist/proxy/conversation-store.d.ts +8 -0
  42. package/dist/proxy/conversation-store.d.ts.map +1 -1
  43. package/dist/proxy/conversation-store.js +20 -0
  44. package/dist/proxy/conversation-store.js.map +1 -1
  45. package/dist/proxy/conversation-store.test.js +54 -1
  46. package/dist/proxy/conversation-store.test.js.map +1 -1
  47. package/dist/proxy/gateway-policy.test.js +145 -7
  48. package/dist/proxy/gateway-policy.test.js.map +1 -1
  49. package/dist/proxy/network-models.d.ts +21 -1
  50. package/dist/proxy/network-models.d.ts.map +1 -1
  51. package/dist/proxy/network-models.js +2 -0
  52. package/dist/proxy/network-models.js.map +1 -1
  53. package/dist/proxy/request-utils.d.ts +20 -0
  54. package/dist/proxy/request-utils.d.ts.map +1 -1
  55. package/dist/proxy/request-utils.js +46 -1
  56. package/dist/proxy/request-utils.js.map +1 -1
  57. package/dist/proxy/route-policy.d.ts +7 -0
  58. package/dist/proxy/route-policy.d.ts.map +1 -1
  59. package/dist/proxy/route-policy.js +10 -0
  60. package/dist/proxy/route-policy.js.map +1 -1
  61. package/dist/proxy/router-execution.d.ts +63 -0
  62. package/dist/proxy/router-execution.d.ts.map +1 -0
  63. package/dist/proxy/router-execution.js +220 -0
  64. package/dist/proxy/router-execution.js.map +1 -0
  65. package/dist/proxy/router-execution.test.d.ts +2 -0
  66. package/dist/proxy/router-execution.test.d.ts.map +1 -0
  67. package/dist/proxy/router-execution.test.js +269 -0
  68. package/dist/proxy/router-execution.test.js.map +1 -0
  69. package/dist/proxy/routing-services.d.ts +37 -0
  70. package/dist/proxy/routing-services.d.ts.map +1 -0
  71. package/dist/proxy/routing-services.js +58 -0
  72. package/dist/proxy/routing-services.js.map +1 -0
  73. package/dist/proxy/routing-services.test.d.ts +2 -0
  74. package/dist/proxy/routing-services.test.d.ts.map +1 -0
  75. package/dist/proxy/routing-services.test.js +95 -0
  76. package/dist/proxy/routing-services.test.js.map +1 -0
  77. package/dist/proxy/routing-usage.d.ts +4 -0
  78. package/dist/proxy/routing-usage.d.ts.map +1 -0
  79. package/dist/proxy/routing-usage.js +19 -0
  80. package/dist/proxy/routing-usage.js.map +1 -0
  81. package/dist/proxy/routing-usage.test.d.ts +2 -0
  82. package/dist/proxy/routing-usage.test.d.ts.map +1 -0
  83. package/dist/proxy/routing-usage.test.js +32 -0
  84. package/dist/proxy/routing-usage.test.js.map +1 -0
  85. package/dist/proxy/telemetry.d.ts +5 -2
  86. package/dist/proxy/telemetry.d.ts.map +1 -1
  87. package/dist/proxy/telemetry.js +7 -2
  88. package/dist/proxy/telemetry.js.map +1 -1
  89. package/package.json +11 -9
@@ -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 { ModelRoutingClient } 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';
@@ -56,7 +60,22 @@ const CONTROL_SECRET_RECHECK_MS = 1_000;
56
60
  const CONTROL_SECRET_FORCED_RECHECK_MS = 100;
57
61
  const LATENCY_EMA_ALPHA = 0.3;
58
62
  const MAX_LATENCY_ENTRIES = 4096;
63
+ /** Responses with fewer output tokens than this say little about a seller's generation speed. */
64
+ const MIN_THROUGHPUT_TOKENS = 20;
59
65
  const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000';
66
+ /** Moves `key`'s EMA toward `sample`; the map stays least recently measured first and bounded. */
67
+ function updateEma(map, key, sample) {
68
+ if (!Number.isFinite(sample) || sample < 0)
69
+ return;
70
+ const previous = map.get(key);
71
+ map.delete(key);
72
+ map.set(key, previous === undefined ? sample : previous + LATENCY_EMA_ALPHA * (sample - previous));
73
+ if (map.size > MAX_LATENCY_ENTRIES) {
74
+ const oldest = map.keys().next().value;
75
+ if (oldest !== undefined)
76
+ map.delete(oldest);
77
+ }
78
+ }
60
79
  /** Exact USDC decimal string from base units, e.g. 1500000n -> "1.500000". */
61
80
  function formatUsdcBaseUnits(amount) {
62
81
  const sign = amount < 0n ? '-' : '';
@@ -76,6 +95,8 @@ function isLoopbackAddress(address) {
76
95
  // because model_not_found already has dedicated unadvertise handling while a
77
96
  // generic 404 would repeat identically on every peer.
78
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]);
79
100
  /**
80
101
  * Gateway-policy requests on automatic routing also move on after a 402: the
81
102
  * buyer could not fund that seller (no deposits for its reserve), but a free
@@ -132,6 +153,10 @@ function isValidRoutedModelTarget(value) {
132
153
  return false;
133
154
  return !value.includes('@') || parsePeerPinnedService(value) !== null;
134
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
+ }
135
160
  /** Returns `request` with its body's model field rewritten to `serviceId`, or unchanged if nothing rewrote. */
136
161
  function withRoutedModel(request, serviceId) {
137
162
  const rewritten = overrideRoutedModelInBody(request.body, request.headers, serviceId);
@@ -239,6 +264,18 @@ function responseFaultAttribution(response) {
239
264
  ? attribution
240
265
  : 'peer';
241
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
+ }
242
279
  function adaptOpenAICompatibleErrorResponse(response, requestProtocol) {
243
280
  if (response.statusCode !== 402) {
244
281
  return response;
@@ -708,12 +745,14 @@ export class BuyerProxy {
708
745
  _configFileWatching = false;
709
746
  _pinnedPeer;
710
747
  /**
711
- * Route substituted for the `antseed` model alias (`<service>` for automatic
712
- * peer selection, or `<peerId>@<service>` for an explicit seller pin).
713
- * Set via `POST /_antseed/route` (the desktop keeps it on the current VPR
714
- * 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.
715
751
  */
716
752
  _defaultRoutedModel = null;
753
+ _defaultRouter = null;
754
+ _routingModels = new RoutingModelsCache();
755
+ _modelRoutingClient;
717
756
  _conversations;
718
757
  /**
719
758
  * Wall-clock of the last model-request activity (dispatch or streamed
@@ -782,11 +821,13 @@ export class BuyerProxy {
782
821
  _controlSecretForcedAt = 0;
783
822
  _loggedUnauthenticatedPolicy = false;
784
823
  /**
785
- * Per-seller latency EMA (ms) measured by this proxy: time to the first
786
- * response frame for streamed requests, full round trip otherwise. Feeds
787
- * `sort: 'latency'`; sellers without a measurement rank after measured ones.
824
+ * Per-seller speed measured by this proxy on streamed requests, as EMAs:
825
+ * time to the first response frame (ms) and output tokens per second after
826
+ * it. Kept apart so a long answer doesn't read as a slow seller. Latency
827
+ * feeds `sort: 'latency'`; sellers without a measurement rank after measured ones.
788
828
  */
789
829
  _peerLatencyMs = new Map();
830
+ _peerTokensPerSecond = new Map();
790
831
  _balanceReader;
791
832
  _balanceCache = new ChainReadCache();
792
833
  _restartHandler;
@@ -801,6 +842,7 @@ export class BuyerProxy {
801
842
  this._restartAllowedAt = Date.now() + (config.minRestartIntervalMs ?? MIN_RESTART_INTERVAL_MS);
802
843
  this._settingOverrides = [...(config.settingOverrides ?? [])];
803
844
  this._node = config.node;
845
+ this._modelRoutingClient = config.modelRoutingClient ?? new ModelRoutingClient();
804
846
  this._verifier = config.verifier;
805
847
  this._buyerIdentities = config.buyerIdentities ?? new BuyerIdentityLoader(config.node, config.dataDir);
806
848
  this._teeVerification = new TeeVerification(config.verifier);
@@ -871,13 +913,15 @@ export class BuyerProxy {
871
913
  const entry = this._requestConversations.get(event.requestId);
872
914
  if (!entry)
873
915
  return;
916
+ const routing = entry.purpose === 'routing';
874
917
  this._conversations.addSpend(entry.convId, {
875
918
  amountUsdc: event.amountUsdc,
876
919
  inputTokens: event.inputTokens,
877
920
  cachedInputTokens: event.cachedInputTokens,
878
921
  outputTokens: event.outputTokens,
879
- }, !entry.counted);
880
- entry.counted = true;
922
+ }, !routing && !entry.counted);
923
+ if (!routing)
924
+ entry.counted = true;
881
925
  }
882
926
  /**
883
927
  * Identity named by a request: null for the default, the name when it is
@@ -1050,20 +1094,15 @@ export class BuyerProxy {
1050
1094
  _peerLatency(peerId, serviceId) {
1051
1095
  return this._peerLatencyMs.get(this._latencyKey(peerId, serviceId)) ?? null;
1052
1096
  }
1053
- _recordPeerLatency(peerId, serviceId, latencyMs) {
1054
- if (!Number.isFinite(latencyMs) || latencyMs < 0)
1055
- return;
1097
+ _peerThroughput(peerId, serviceId) {
1098
+ return this._peerTokensPerSecond.get(this._latencyKey(peerId, serviceId)) ?? null;
1099
+ }
1100
+ /** One streamed request: time to its first frame, then output tokens over the rest of the stream. */
1101
+ _recordPeerSpeed(peerId, serviceId, firstFrameMs, streamMs, outputTokens) {
1056
1102
  const key = this._latencyKey(peerId, serviceId);
1057
- const previous = this._peerLatencyMs.get(key);
1058
- // Re-inserted so the map's order is least recently measured first.
1059
- this._peerLatencyMs.delete(key);
1060
- this._peerLatencyMs.set(key, previous === undefined
1061
- ? latencyMs
1062
- : previous + LATENCY_EMA_ALPHA * (latencyMs - previous));
1063
- if (this._peerLatencyMs.size > MAX_LATENCY_ENTRIES) {
1064
- const oldest = this._peerLatencyMs.keys().next().value;
1065
- if (oldest !== undefined)
1066
- this._peerLatencyMs.delete(oldest);
1103
+ updateEma(this._peerLatencyMs, key, firstFrameMs);
1104
+ if (outputTokens >= MIN_THROUGHPUT_TOKENS && streamMs > 0) {
1105
+ updateEma(this._peerTokensPerSecond, key, outputTokens / (streamMs / 1000));
1067
1106
  }
1068
1107
  }
1069
1108
  /**
@@ -1214,6 +1253,9 @@ export class BuyerProxy {
1214
1253
  const latency = this._peerLatency(candidate.peerId, candidate.serviceId);
1215
1254
  if (latency !== null)
1216
1255
  verdict.reasons.push(`latency ~${Math.round(latency)}ms`);
1256
+ const throughput = this._peerThroughput(candidate.peerId, candidate.serviceId);
1257
+ if (throughput !== null)
1258
+ verdict.reasons.push(`~${Math.round(throughput)} tok/s`);
1217
1259
  });
1218
1260
  return { candidates, verdicts: [...verdicts.values()], effectivePolicy };
1219
1261
  }
@@ -1424,8 +1466,12 @@ export class BuyerProxy {
1424
1466
  * a grace window — the seller-initiated auth path can land just after the
1425
1467
  * response is returned. Bounded so a leaked id can't grow the map forever.
1426
1468
  */
1427
- _trackRequestConversation(requestId, convId) {
1428
- 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 });
1429
1475
  while (this._requestConversations.size > MAX_TRACKED_REQUEST_CONVERSATIONS) {
1430
1476
  const oldest = this._requestConversations.keys().next().value;
1431
1477
  if (oldest === undefined)
@@ -1602,6 +1648,27 @@ export class BuyerProxy {
1602
1648
  // watcher setup failed; non-fatal
1603
1649
  }
1604
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, costQualityTradeoff?, allowedModels? }`). */
1658
+ _parseDefaultRouter(value) {
1659
+ if (!value || typeof value !== 'object' || Array.isArray(value))
1660
+ throw new Error('router must be { service, costQualityTradeoff? }');
1661
+ const selection = { ...value, kind: 'router' };
1662
+ this._validateRouterSelection(selection);
1663
+ return structuredClone(selection);
1664
+ }
1665
+ /** The default router in its wire form, without the internal `kind` tag. */
1666
+ get _defaultRouterJson() {
1667
+ if (!this._defaultRouter)
1668
+ return null;
1669
+ const { kind: _kind, ...router } = this._defaultRouter;
1670
+ return router;
1671
+ }
1605
1672
  async _reloadSessionOverrides(opts = {}) {
1606
1673
  try {
1607
1674
  const raw = await readFile(this._stateFile, 'utf-8');
@@ -1614,7 +1681,16 @@ export class BuyerProxy {
1614
1681
  }
1615
1682
  const routedModel = typeof parsed.defaultRoutedModel === 'string' ? parsed.defaultRoutedModel.trim() : '';
1616
1683
  this._defaultRoutedModel = routedModel.length > 0 && isValidRoutedModelTarget(routedModel) ? routedModel : null;
1617
- log(`Session overrides reloaded: peer=${this._pinnedPeer ?? 'none'} route=${this._defaultRoutedModel ?? 'none'}`);
1684
+ try {
1685
+ this._defaultRouter = null;
1686
+ if (parsed.defaultRouter != null && this._defaultRoutedModel === null) {
1687
+ this._defaultRouter = this._parseDefaultRouter(parsed.defaultRouter);
1688
+ }
1689
+ }
1690
+ catch {
1691
+ this._defaultRouter = null;
1692
+ }
1693
+ log(`Session overrides reloaded: peer=${this._pinnedPeer ?? 'none'} route=${this._defaultRoutedModel ?? 'none'} router=${JSON.stringify(this._defaultRouterJson)}`);
1618
1694
  }
1619
1695
  catch {
1620
1696
  // state file unreadable; keep current values
@@ -1693,7 +1769,7 @@ export class BuyerProxy {
1693
1769
  // in the file — the debounce may have been cancelled before
1694
1770
  // _reloadSessionOverrides could commit the latest CLI-written values.
1695
1771
  const sessionOverrides = state === 'connected'
1696
- ? { pinnedPeerId: this._pinnedPeer, defaultRoutedModel: this._defaultRoutedModel }
1772
+ ? { pinnedPeerId: this._pinnedPeer, defaultRoutedModel: this._defaultRoutedModel, defaultRouter: this._defaultRouterJson }
1697
1773
  : {};
1698
1774
  await this._mergeStateFile({
1699
1775
  state,
@@ -2239,9 +2315,14 @@ export class BuyerProxy {
2239
2315
  res.end(JSON.stringify({ ok: true, peerId: normalized }));
2240
2316
  return;
2241
2317
  }
2318
+ if (path === '/_antseed/routing-services' && method === 'GET') {
2319
+ res.writeHead(200, { 'content-type': 'application/json' });
2320
+ res.end(JSON.stringify({ ok: true, services: await buildRoutingServices(await this._getPeers(), this._modelRoutingClient, this._node, this._routingModels) }));
2321
+ return;
2322
+ }
2242
2323
  if (path === '/_antseed/route' && method === 'GET') {
2243
2324
  res.writeHead(200, { 'content-type': 'application/json' });
2244
- res.end(JSON.stringify({ ok: true, model: this._defaultRoutedModel }));
2325
+ res.end(JSON.stringify({ ok: true, model: this._defaultRoutedModel, router: this._defaultRouterJson }));
2245
2326
  return;
2246
2327
  }
2247
2328
  if (path === '/_antseed/route' && method === 'POST') {
@@ -2256,9 +2337,10 @@ export class BuyerProxy {
2256
2337
  }
2257
2338
  chunks.push(chunk);
2258
2339
  }
2340
+ let body;
2259
2341
  let model;
2260
2342
  try {
2261
- const body = JSON.parse(Buffer.concat(chunks).toString());
2343
+ body = JSON.parse(Buffer.concat(chunks).toString());
2262
2344
  model = typeof body.model === 'string' ? body.model.trim() : '';
2263
2345
  }
2264
2346
  catch {
@@ -2266,16 +2348,28 @@ export class BuyerProxy {
2266
2348
  res.end(JSON.stringify({ ok: false, error: 'Invalid JSON body' }));
2267
2349
  return;
2268
2350
  }
2269
- if (model.length > 0 && !isValidRoutedModelTarget(model)) {
2351
+ let router = null;
2352
+ try {
2353
+ if (body.router != null) {
2354
+ if (model.length > 0)
2355
+ throw new Error('Set model or router, not both');
2356
+ router = this._parseDefaultRouter(body.router);
2357
+ }
2358
+ if (model.length > 0 && !isValidRoutedModelTarget(model)) {
2359
+ throw new Error('model must be "<service>", "<peerId>@<service>", or empty to clear');
2360
+ }
2361
+ }
2362
+ catch (error) {
2270
2363
  res.writeHead(400, { 'content-type': 'application/json' });
2271
- res.end(JSON.stringify({ ok: false, error: 'model must be "<service>", "<peerId>@<service>", or empty to clear' }));
2364
+ res.end(JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) }));
2272
2365
  return;
2273
2366
  }
2274
2367
  this._defaultRoutedModel = model.length > 0 ? model : null;
2275
- await this._mergeStateFile({ defaultRoutedModel: this._defaultRoutedModel });
2276
- log(`Default routed model set: ${this._defaultRoutedModel ?? 'none'}`);
2368
+ this._defaultRouter = router;
2369
+ await this._mergeStateFile({ defaultRoutedModel: this._defaultRoutedModel, defaultRouter: this._defaultRouterJson });
2370
+ log(`Default route set: model=${this._defaultRoutedModel ?? 'none'} router=${JSON.stringify(this._defaultRouterJson)}`);
2277
2371
  res.writeHead(200, { 'content-type': 'application/json' });
2278
- res.end(JSON.stringify({ ok: true, model: this._defaultRoutedModel }));
2372
+ res.end(JSON.stringify({ ok: true, model: this._defaultRoutedModel, router: this._defaultRouterJson }));
2279
2373
  return;
2280
2374
  }
2281
2375
  if (path === '/_antseed/conversations' && method === 'GET') {
@@ -2316,6 +2410,8 @@ export class BuyerProxy {
2316
2410
  let parsed;
2317
2411
  try {
2318
2412
  parsed = JSON.parse(Buffer.concat(chunks).toString());
2413
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
2414
+ throw new Error('Invalid conversation update');
2319
2415
  }
2320
2416
  catch {
2321
2417
  res.writeHead(400, { 'content-type': 'application/json' });
@@ -2340,6 +2436,21 @@ export class BuyerProxy {
2340
2436
  res.end(JSON.stringify({ ok: false, error: 'Unknown conversation' }));
2341
2437
  return;
2342
2438
  }
2439
+ if ('routingSelection' in parsed) {
2440
+ try {
2441
+ if ('pinnedModel' in parsed)
2442
+ throw new Error('Update routingSelection or pinnedModel, not both');
2443
+ if (parsed.routingSelection !== null)
2444
+ this._validateRouterSelection(parsed.routingSelection);
2445
+ }
2446
+ catch (error) {
2447
+ res.writeHead(400, { 'content-type': 'application/json' });
2448
+ res.end(JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) }));
2449
+ return;
2450
+ }
2451
+ const selection = parsed.routingSelection;
2452
+ conversation = this._conversations.setRoutingSelection(id, selection);
2453
+ }
2343
2454
  if ('pinnedModel' in parsed) {
2344
2455
  const pin = typeof parsed.pinnedModel === 'string' ? parsed.pinnedModel.trim() : '';
2345
2456
  if (pin.length > 0 && !isValidRoutedModelTarget(pin)) {
@@ -2715,18 +2826,64 @@ export class BuyerProxy {
2715
2826
  res.writeHead(400, responseHeaders);
2716
2827
  res.end(JSON.stringify({
2717
2828
  error: {
2718
- message: `Unknown model type "${url.searchParams.get('type') ?? ''}" — expected "text", "images", "decisions", or "videos".`,
2829
+ message: `Unknown model type "${url.searchParams.get('type') ?? ''}" — expected "text", "images", "decisions", "videos", or "routers".`,
2719
2830
  type: 'invalid_request_error',
2720
2831
  param: 'type',
2721
2832
  },
2722
2833
  }));
2723
2834
  return;
2724
2835
  }
2836
+ if (typeFilter === 'router') {
2837
+ const routers = await this._networkRouters(peers, requestPolicy);
2838
+ log(`GET /v1/models?type=router answered locally: ${routers.length} routers`);
2839
+ res.writeHead(200, responseHeaders);
2840
+ res.end(JSON.stringify({ object: 'list', data: routers }));
2841
+ return;
2842
+ }
2725
2843
  const data = typeFilter === 'all' ? models : models.filter((entry) => entry.type === typeFilter);
2726
2844
  log(`GET /v1/models answered locally: ${data.length} models across ${peers.length} peers${typeFilter ? ` (type=${typeFilter})` : ''}`);
2727
2845
  res.writeHead(200, responseHeaders);
2728
2846
  res.end(JSON.stringify({ object: 'list', data }));
2729
2847
  }
2848
+ /**
2849
+ * Routing services on the network, one entry per service id with each
2850
+ * router peer offering it. A router is listed only when a request could pay
2851
+ * it (see `_routerExclusionReasons`), and under a gateway policy its models
2852
+ * are cut to the ones the policy allows. Model lists are free calls, cached
2853
+ * per router.
2854
+ */
2855
+ async _networkRouters(peers, requestPolicy) {
2856
+ const policy = this._effectivePolicy(requestPolicy);
2857
+ const services = await buildRoutingServices(peers, this._modelRoutingClient, this._node, this._routingModels);
2858
+ const created = Math.floor(this._now() / 1000);
2859
+ const byId = new Map();
2860
+ for (const service of services) {
2861
+ const peer = peers.find((candidate) => candidate.peerId === service.peerId);
2862
+ if (!peer || this._routerExclusionReasons(peer, service.serviceId, requestPolicy).length > 0)
2863
+ continue;
2864
+ const models = service.catalog
2865
+ ? [...new Set(service.catalog.models.map((model) => model.serviceId))]
2866
+ .filter((model) => !policy || policyAllowsModel(policy, model))
2867
+ .sort((a, b) => a.localeCompare(b))
2868
+ : null;
2869
+ const price = Number(service.priceMicroUsdc);
2870
+ let entry = byId.get(service.serviceId);
2871
+ if (!entry) {
2872
+ entry = { id: service.serviceId, name: service.label, object: 'model', created, owned_by: 'antseed', type: 'router', peers: [] };
2873
+ byId.set(service.serviceId, entry);
2874
+ }
2875
+ entry.peers.push({
2876
+ peerId: service.peerId,
2877
+ ...(service.sellerName ? { displayName: service.sellerName } : {}),
2878
+ provider: service.provider,
2879
+ serviceId: service.serviceId,
2880
+ model: `${service.peerId}@${service.serviceId}`,
2881
+ priceUsdPerRequest: Number.isFinite(price) ? price / 1_000_000 : null,
2882
+ models,
2883
+ });
2884
+ }
2885
+ return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id));
2886
+ }
2730
2887
  /**
2731
2888
  * Whether a request under the gateway's policy could use this offer, by
2732
2889
  * the checks that judge a pinned `peer@model`: the model allow list, the
@@ -2896,6 +3053,19 @@ export class BuyerProxy {
2896
3053
  }));
2897
3054
  return;
2898
3055
  }
3056
+ // Per-request routing options (`routing` in the body) are read here and
3057
+ // stripped, so they never reach a seller, whatever the model. A malformed
3058
+ // `routing` is reported only when a router serves the request.
3059
+ let routingOptions = {};
3060
+ let invalidRouting = null;
3061
+ if (method === 'POST' && isCompletionRequestPath(path)) {
3062
+ const taken = takeRoutingOptions(serializedReq);
3063
+ if (taken.kind === 'options') {
3064
+ serializedReq = taken.request;
3065
+ routingOptions = taken.options;
3066
+ invalidRouting = taken.invalid ?? null;
3067
+ }
3068
+ }
2899
3069
  if (nativeVideo) {
2900
3070
  // Re-classify with the body: some APIs (Venice) carry the job ID in it.
2901
3071
  const prepared = prepareVideoRequest(nativeVideoRoute(serializedReq) ?? nativeVideo, serializedReq.headers, this._resourceRoutes);
@@ -2921,6 +3091,7 @@ export class BuyerProxy {
2921
3091
  const conversationIdentity = isConversationRequest
2922
3092
  ? extractConversationIdentity(serializedReq.headers, conversationBody)
2923
3093
  : null;
3094
+ const routingConversationKey = conversationIdentity ? `${conversationIdentity.tool}:${conversationIdentity.sessionKey}` : null;
2924
3095
  // Internal marker from the system proxy: source profile used only for
2925
3096
  // local conversation attribution. Stripped here so it never reaches a seller.
2926
3097
  delete serializedReq.headers[SYSTEM_PROXY_SOURCE_HEADER];
@@ -2930,6 +3101,10 @@ export class BuyerProxy {
2930
3101
  const storedConversation = conversationIdentity && trackedConversationKey
2931
3102
  ? this._conversations.get(`${conversationIdentity.tool}:${trackedConversationKey}`)
2932
3103
  : null;
3104
+ // Use this chat's router override when present; otherwise follow the buyer's default choice.
3105
+ const conversationSelection = storedConversation?.routingSelection ?? null;
3106
+ // Keep this request's choice stable; later selection changes affect subsequent requests.
3107
+ const routerSelection = structuredClone(conversationSelection ?? this._defaultRouter);
2933
3108
  const storedAutoRoute = storedConversation?.peerSource === 'auto' && storedConversation.pinnedModel
2934
3109
  ? parsePeerPinnedService(storedConversation.pinnedModel)
2935
3110
  : null;
@@ -2939,6 +3114,242 @@ export class BuyerProxy {
2939
3114
  const preferredPeerHeader = normalizePeerId(serializedReq.headers['x-antseed-prefer-peer'] ?? '');
2940
3115
  const preferredConversationPeerId = preferredPeerHeader ?? storedAutoRoute?.peerId ?? null;
2941
3116
  const effectiveRoutedModel = chatPinnedModel ?? this._defaultRoutedModel;
3117
+ // Where a router pin can come from besides the request's own model: the
3118
+ // `antseed` alias resolves to the chat's explicit pin or, when the chat
3119
+ // chose no route of its own (neither a pin nor a router), the default
3120
+ // route; a system-proxied request follows the chat's explicit pin. An
3121
+ // automatic chat route (the seller a router chose last turn) never
3122
+ // counts, so every turn of a router-pinned chat is ranked again.
3123
+ const userChatPin = storedConversation?.peerSource === 'user' ? storedConversation.pinnedModel : null;
3124
+ const aliasTarget = userChatPin ?? (conversationSelection ? null : this._defaultRoutedModel);
3125
+ // Reuse disconnect cancellation for both the recommendation and the eventual inference call.
3126
+ const clientAbortController = new AbortController();
3127
+ const onClientAbort = () => {
3128
+ if (clientAbortController.signal.aborted)
3129
+ return;
3130
+ clientAbortController.abort();
3131
+ log(`Client disconnected; aborting upstream request reqId=${serializedReq.requestId.slice(0, 8)}`);
3132
+ };
3133
+ req.once('close', () => {
3134
+ if (!req.complete && !res.writableEnded)
3135
+ onClientAbort();
3136
+ });
3137
+ res.once('close', () => {
3138
+ if (!res.writableEnded)
3139
+ onClientAbort();
3140
+ });
3141
+ // Explicit model/seller choices bypass recommendations, even when the default is a router.
3142
+ // A system-proxy connection's model was assigned at connect time, not chosen by the tool:
3143
+ // under a router it stands for the automatic alias, unless this chat is pinned. The request
3144
+ // is only rewritten once the router path is certain (below), so a request that goes direct
3145
+ // keeps its connect-time model.
3146
+ const systemRoutedAsAlias = routerSelection !== null && systemRoutedModel && storedConversation?.peerSource !== 'user';
3147
+ const rawService = systemRoutedAsAlias ? ROUTED_MODEL_ALIAS : extractRequestedService(serializedReq);
3148
+ // `<routerPeer>@<routingServiceId>`: the request names a routing service as
3149
+ // its model, so that router picks the model and seller for this request.
3150
+ const routerPinSource = rawService === ROUTED_MODEL_ALIAS
3151
+ ? aliasTarget
3152
+ : systemRoutedModel && userChatPin ? userChatPin : rawService;
3153
+ const pinnedRouter = await this._resolvePinnedRouter(routerPinSource);
3154
+ const autoRequested = routerSelection !== null
3155
+ && rawService === ROUTED_MODEL_ALIAS;
3156
+ const chatModelPin = storedConversation?.peerSource === 'user' ? chatPinnedModel : null;
3157
+ if (autoRequested && chatModelPin) {
3158
+ serializedReq = withRoutedModel(serializedReq, chatModelPin);
3159
+ }
3160
+ const useModelRouter = pinnedRouter !== null || (autoRequested && !chatModelPin
3161
+ && !getExplicitPeerIdOverride(serializedReq, effectivePinnedPeer ?? undefined));
3162
+ if (useModelRouter && systemRoutedAsAlias) {
3163
+ serializedReq = withRoutedModel(serializedReq, ROUTED_MODEL_ALIAS);
3164
+ }
3165
+ // A request without `routing` uses the saved router settings (tradeoff,
3166
+ // model allow list) when it pins the router those settings belong to.
3167
+ const savedRouter = this._defaultRouter;
3168
+ const savedForPinned = pinnedRouter && savedRouter && sameRoutingServiceTarget(savedRouter.service, pinnedRouter.service)
3169
+ ? structuredClone(savedRouter)
3170
+ : null;
3171
+ const baseSelection = pinnedRouter ? savedForPinned ?? pinnedRouter : routerSelection;
3172
+ const activeSelection = baseSelection && routingOptions.costQualityTradeoff !== undefined
3173
+ ? { ...baseSelection, costQualityTradeoff: routingOptions.costQualityTradeoff }
3174
+ : baseSelection;
3175
+ const context = {
3176
+ request: serializedReq, requiredParameters, requestPolicy, signal: clientAbortController.signal,
3177
+ systemRoutedModel, conversationIdentity, conversationBody, storedConversation,
3178
+ effectivePinnedPeer, effectiveRoutedModel, chatPinnedModel,
3179
+ preferredConversationPeerId,
3180
+ // Cache-reuse observations only feed a router, so skip them when none is configured.
3181
+ routingConversationKey: activeSelection ? routingConversationKey : null,
3182
+ spendAttributionTag,
3183
+ };
3184
+ // Both paths return an inference answer; only the router path buys a recommendation first.
3185
+ if (activeSelection && useModelRouter) {
3186
+ if (invalidRouting) {
3187
+ sendJson(res, 400, { error: { type: 'invalid_request_error', code: 'invalid_routing', message: invalidRouting, param: 'routing' } });
3188
+ return;
3189
+ }
3190
+ const refusal = await this._routerRefusal(activeSelection, requestPolicy);
3191
+ if (refusal) {
3192
+ log(`Routing service refused before ranking: ${refusal.error.message}`);
3193
+ sendJson(res, 403, refusal);
3194
+ return;
3195
+ }
3196
+ await this._dispatchViaModelRouter(res, context, activeSelection);
3197
+ }
3198
+ else {
3199
+ await this._dispatchDirectModelRequest(res, context);
3200
+ }
3201
+ }
3202
+ /**
3203
+ * Buy ranked recommendations, then send each one through the normal inference path
3204
+ * until one succeeds. A failed exact peer falls back to the next recommendation.
3205
+ */
3206
+ async _dispatchViaModelRouter(res, context, selection) {
3207
+ const { request, requiredParameters, requestPolicy, signal, routingConversationKey, spendAttributionTag } = context;
3208
+ const conversationId = this._trackRouterConversation(context);
3209
+ let recommendations;
3210
+ try {
3211
+ const client = this._modelRoutingClient;
3212
+ if (!selection.service)
3213
+ throw new Error('Select an exact routing-service target');
3214
+ const peers = await this._getPeers();
3215
+ // The router may only recommend destinations the buyer's own policies allow.
3216
+ const candidates = eligibleRouterCandidates(request, peers, requiredParameters, this._routingPreferences, (policyRequest, peer) => peerAllowedByPolicy(this._node.router, policyRequest, peer)
3217
+ && this._routerCandidateAllowedByRequestPolicy(requestPolicy, policyRequest, peer)
3218
+ && !isCoolingDown(this._peerHealth.get(peer.peerId), this._now()));
3219
+ recommendations = await executeRouterSelection({
3220
+ node: this._node, client, request, peers, candidates,
3221
+ conversationKey: routingConversationKey, selection, modelsCache: this._routingModels,
3222
+ ...(request.headers[BUYER_IDENTITY_HEADER] ? { buyerIdentity: request.headers[BUYER_IDENTITY_HEADER] } : {}),
3223
+ signal: AbortSignal.any([signal, AbortSignal.timeout(120_000)]),
3224
+ onRoutingRequest: requestId => {
3225
+ if (spendAttributionTag)
3226
+ this._spendAttribution.track(requestId, spendAttributionTag);
3227
+ if (conversationId)
3228
+ this._trackRequestConversation(requestId, conversationId, undefined, 'routing');
3229
+ },
3230
+ });
3231
+ }
3232
+ catch (error) {
3233
+ // Report routing failures rather than silently choosing a model for the user.
3234
+ const message = error instanceof Error ? error.message : String(error);
3235
+ writeDispatchFailure(res, error instanceof NoEligibleCandidatesError
3236
+ ? dispatchFailure(422, { 'content-type': 'application/json' }, JSON.stringify({
3237
+ error: { type: 'invalid_request_error', code: 'no_eligible_candidates', message },
3238
+ }))
3239
+ : dispatchFailure(502, { 'content-type': 'application/json' }, JSON.stringify({
3240
+ error: { type: 'routing_error', message },
3241
+ })));
3242
+ return;
3243
+ }
3244
+ let lastFailure = null;
3245
+ for (const recommendation of recommendations) {
3246
+ // Never switch sellers once the user has disconnected or a response has started.
3247
+ if (signal.aborted || res.headersSent || res.writableEnded)
3248
+ return;
3249
+ // Each attempt has its own billing ID, but all attempts count as one chat turn.
3250
+ const attempt = requestForRecommendation(request, recommendation, randomUUID());
3251
+ if (spendAttributionTag)
3252
+ this._spendAttribution.track(attempt.requestId, spendAttributionTag);
3253
+ if (conversationId)
3254
+ this._trackRequestConversation(attempt.requestId, conversationId, request.requestId);
3255
+ const result = await this._dispatchInference(res, context, attempt, conversationId);
3256
+ if (result.done)
3257
+ return;
3258
+ lastFailure = result;
3259
+ // Only try the next recommendation after a clear seller refusal, never after an ambiguous
3260
+ // transport failure or timeout (the seller may have done paid work) or a buyer-side fault.
3261
+ if (result.errorMessage !== null || isBuyerFault(result) || !RECOMMENDATION_FALLBACK_STATUS_CODES.has(result.statusCode))
3262
+ break;
3263
+ log(`Recommendation ${recommendation.peerId ? `${recommendation.peerId.slice(0, 12)}...@` : ''}${recommendation.serviceId} failed (${result.statusCode}); trying the next recommendation.`);
3264
+ }
3265
+ if (signal.aborted)
3266
+ return;
3267
+ writeDispatchFailure(res, lastFailure ?? dispatchFailure(502, { 'content-type': 'application/json' }, JSON.stringify({
3268
+ error: { type: 'routing_error', message: 'No recommended destination could serve this request' },
3269
+ })));
3270
+ }
3271
+ /**
3272
+ * The router selection a `<peerId>@<serviceId>` model names, when that
3273
+ * peer's routing service is `<serviceId>`. A peer missing from the cache
3274
+ * gets one refresh first, so a router that just came online is not taken
3275
+ * for a seller. Still missing, the pin goes down the seller-pin path, which
3276
+ * reports the peer as unreachable.
3277
+ */
3278
+ async _resolvePinnedRouter(model) {
3279
+ const pin = model ? parsePeerPinnedService(model) : null;
3280
+ if (!pin)
3281
+ return null;
3282
+ let peers = await this._getPeers();
3283
+ if (!peers.some((peer) => peer.peerId.toLowerCase() === pin.peerId)) {
3284
+ peers = await this._getPeers({ forceRefresh: true });
3285
+ }
3286
+ const service = findPinnedRoutingService(peers, pin.peerId, pin.service);
3287
+ return service ? { kind: 'router', service } : null;
3288
+ }
3289
+ /**
3290
+ * Why a routing service may not be paid, the way a pinned seller is judged:
3291
+ * the gateway policy's model allow list (the router's service id counts as
3292
+ * its model), its seller rules and strict model routes, and the buyer's
3293
+ * hard limits, all through `routingServicePolicy`. Empty when allowed.
3294
+ */
3295
+ _routerExclusionReasons(peer, serviceId, requestPolicy) {
3296
+ const facts = this._policyFacts(peer, null);
3297
+ const reasons = requestPolicy ? pinnedPeerExclusionReasons(routingServicePolicy(requestPolicy), facts, serviceId) : [];
3298
+ if (requestPolicy && !policyAllowsModel(this._effectivePolicy(requestPolicy), serviceId))
3299
+ reasons.unshift('model not allowed');
3300
+ addBuyerConfigReasons(reasons, policyExclusionReasons(routingServicePolicy(this._buyerHardPolicy()), facts));
3301
+ return reasons;
3302
+ }
3303
+ /** A 403 body when the policy forbids paying this router for a ranking; null when allowed or its peer is unknown. */
3304
+ async _routerRefusal(selection, requestPolicy) {
3305
+ const service = selection.service;
3306
+ if (!service)
3307
+ return null;
3308
+ const peer = (await this._getPeers()).find((candidate) => candidate.peerId.toLowerCase() === service.peerId.toLowerCase());
3309
+ if (!peer)
3310
+ return null;
3311
+ const reasons = this._routerExclusionReasons(peer, service.serviceId, requestPolicy);
3312
+ if (reasons.length === 0)
3313
+ return null;
3314
+ const label = `${service.peerId.slice(0, 12)}...@${service.serviceId}`;
3315
+ if (reasons[0] === 'model not allowed') {
3316
+ return { error: { type: 'permission_error', code: 'model_not_allowed', param: 'model',
3317
+ message: `Routing service "${service.serviceId}" is not allowed by the routing policy.` } };
3318
+ }
3319
+ return { error: { type: 'permission_error', code: 'peer_not_allowed',
3320
+ message: `Routing service ${label} is not allowed by the routing policy (${reasons.join('; ')}).` } };
3321
+ }
3322
+ _routerCandidateAllowedByRequestPolicy(requestPolicy, request, peer) {
3323
+ if (!requestPolicy)
3324
+ return true;
3325
+ const provider = getExplicitProviderOverride(request);
3326
+ const serviceId = extractRequestedService(request);
3327
+ const offer = provider && serviceId ? findAdvertisedServiceOffer(peer, provider, serviceId) : null;
3328
+ return offer !== null
3329
+ && this._policyAllowsOffer(requestPolicy, peer, offer)
3330
+ && policyExclusionReasons(this._buyerHardPolicy(), this._policyFacts(peer, offer)).length === 0;
3331
+ }
3332
+ /** Track a router-served chat before buying its recommendation, so routing costs are attributed. */
3333
+ _trackRouterConversation(context) {
3334
+ const { request, conversationIdentity, conversationBody, storedConversation } = context;
3335
+ if (!conversationIdentity?.isUserThread)
3336
+ return storedConversation?.id ?? null;
3337
+ if (!storedConversation && isTitleGenerationRequest(conversationBody))
3338
+ return null;
3339
+ const { id } = this._conversations.touch({
3340
+ tool: conversationIdentity.tool,
3341
+ sessionKey: conversationIdentity.parentSessionKey ?? conversationIdentity.sessionKey,
3342
+ snippet: storedConversation?.snippet ? null : extractFirstUserSnippet(conversationBody),
3343
+ lastModel: null,
3344
+ });
3345
+ // Inference attempts link to this ID so fallbacks count as one chat turn.
3346
+ this._trackRequestConversation(request.requestId, id);
3347
+ return id;
3348
+ }
3349
+ /** Apply model aliases and chat pins, then attach inference to its conversation for accounting. */
3350
+ _prepareInferenceRequest(res, context) {
3351
+ const { effectiveRoutedModel, chatPinnedModel, systemRoutedModel, storedConversation, conversationIdentity, conversationBody } = context;
3352
+ let serializedReq = context.request;
2942
3353
  let trackedConversationId = storedConversation?.id ?? null;
2943
3354
  // Resolve the `antseed` model alias to the session's default route first,
2944
3355
  // so the regular `<peerId>@<service>` pin rewrite below picks up the
@@ -2957,7 +3368,7 @@ export class BuyerProxy {
2957
3368
  param: 'model',
2958
3369
  },
2959
3370
  }));
2960
- return;
3371
+ return null;
2961
3372
  }
2962
3373
  if (aliasResult.substituted) {
2963
3374
  serializedReq = { ...serializedReq, body: aliasResult.body, headers: aliasResult.headers };
@@ -3020,10 +3431,29 @@ export class BuyerProxy {
3020
3431
  this._trackRequestConversation(serializedReq.requestId, tracked.id);
3021
3432
  }
3022
3433
  }
3434
+ return { request: serializedReq, conversationId: trackedConversationId };
3435
+ }
3436
+ /** Send a fixed-model or pinned-seller request without buying a model recommendation. */
3437
+ async _dispatchDirectModelRequest(res, context) {
3438
+ const prepared = this._prepareInferenceRequest(res, context);
3439
+ if (!prepared)
3440
+ return;
3441
+ const result = await this._dispatchInference(res, context, prepared.request, prepared.conversationId);
3442
+ if (!result.done)
3443
+ writeDispatchFailure(res, result);
3444
+ }
3445
+ /**
3446
+ * Select a seller for the requested model (or the pinned seller) and send the request.
3447
+ * Success is written to `res`; failures are returned so callers can fall back or report them.
3448
+ */
3449
+ async _dispatchInference(res, context, request, trackedConversationId) {
3023
3450
  // A chat switched while this request is in flight must keep the new route.
3024
3451
  const conversationPinAtRouting = trackedConversationId
3025
3452
  ? this._conversations.get(trackedConversationId)?.pinnedModel ?? null
3026
3453
  : null;
3454
+ const nativeVideo = nativeVideoRoute(request);
3455
+ let serializedReq = request;
3456
+ const { requiredParameters, requestPolicy, signal, effectivePinnedPeer, preferredConversationPeerId, routingConversationKey } = context;
3027
3457
  const { body: servicePinBody, headers: servicePinHeaders, pinnedPeerId: bodyPinnedPeer, } = rewritePeerPinnedServiceInBody(serializedReq.body, serializedReq.headers);
3028
3458
  if (servicePinBody !== serializedReq.body) {
3029
3459
  serializedReq = { ...serializedReq, body: servicePinBody, headers: servicePinHeaders };
@@ -3031,24 +3461,6 @@ export class BuyerProxy {
3031
3461
  log(`Model peer pin applied: peer=${bodyPinnedPeer.slice(0, 12)}...`);
3032
3462
  }
3033
3463
  }
3034
- const clientAbortController = new AbortController();
3035
- const onClientAbort = () => {
3036
- if (clientAbortController.signal.aborted) {
3037
- return;
3038
- }
3039
- clientAbortController.abort();
3040
- log(`Client disconnected; aborting upstream request reqId=${serializedReq.requestId.slice(0, 8)}`);
3041
- };
3042
- req.once('close', () => {
3043
- if (!req.complete && !res.writableEnded) {
3044
- onClientAbort();
3045
- }
3046
- });
3047
- res.once('close', () => {
3048
- if (!res.writableEnded) {
3049
- onClientAbort();
3050
- }
3051
- });
3052
3464
  const requestProtocol = detectRequestServiceApiProtocol(serializedReq);
3053
3465
  const requestedService = extractRequestedService(serializedReq);
3054
3466
  log(`Routing: protocol=${requestProtocol ?? 'null'} service=${requestedService ?? 'null'}`);
@@ -3063,8 +3475,7 @@ export class BuyerProxy {
3063
3475
  + ' • Model name prefix: <peerId>@<model>\n'
3064
3476
  + ' • Session pin: antseed buyer connection set --peer <peerId>\n'
3065
3477
  + 'Discover peers with: antseed network browse';
3066
- res.writeHead(400, { 'content-type': 'application/json' });
3067
- res.end(JSON.stringify({
3478
+ return dispatchFailure(400, { 'content-type': 'application/json' }, JSON.stringify({
3068
3479
  error: {
3069
3480
  type: 'missing_routing_target',
3070
3481
  code: 'missing_routing_target',
@@ -3078,26 +3489,22 @@ export class BuyerProxy {
3078
3489
  },
3079
3490
  },
3080
3491
  }));
3081
- return;
3082
3492
  }
3083
3493
  if (requestPolicy && !policyAllowsModel(this._effectivePolicy(requestPolicy), requestedService)) {
3084
- sendJson(res, 403, {
3494
+ return dispatchFailure(403, { 'content-type': 'application/json' }, JSON.stringify({
3085
3495
  error: {
3086
3496
  type: 'permission_error',
3087
3497
  code: 'model_not_allowed',
3088
3498
  message: `Model "${requestedService ?? 'unknown'}" is not allowed by the routing policy.`,
3089
3499
  param: 'model',
3090
3500
  },
3091
- });
3092
- return;
3501
+ }));
3093
3502
  }
3094
3503
  // Discover peers
3095
3504
  const peers = await this._getPeers();
3096
3505
  if (peers.length === 0) {
3097
3506
  log('No sellers available');
3098
- res.writeHead(502, { 'content-type': 'text/plain' });
3099
- res.end('No sellers available on the network. Is a seeder running?');
3100
- return;
3507
+ return dispatchFailure(502, { 'content-type': 'text/plain' }, 'No sellers available on the network. Is a seeder running?');
3101
3508
  }
3102
3509
  if (!explicitPeerId && requestedService) {
3103
3510
  const selectModelPeers = (candidateSources) => selectCandidatePeersForRouting(candidateSources, requestProtocol, requestedService, explicitProvider, 'strict');
@@ -3132,8 +3539,7 @@ export class BuyerProxy {
3132
3539
  ? ` "${requestedService}" is a decision model; call POST /v1/systemone.`
3133
3540
  : '';
3134
3541
  log(`Request rejected: model ${requestedService} is served only via ${advertisedProtocols.join(', ')}`);
3135
- res.writeHead(400, { 'content-type': 'application/json' });
3136
- res.end(JSON.stringify({
3542
+ return dispatchFailure(400, { 'content-type': 'application/json' }, JSON.stringify({
3137
3543
  error: {
3138
3544
  type: 'unsupported_protocol',
3139
3545
  code: 'unsupported_protocol',
@@ -3142,10 +3548,8 @@ export class BuyerProxy {
3142
3548
  supported_protocols: advertisedProtocols,
3143
3549
  },
3144
3550
  }));
3145
- return;
3146
3551
  }
3147
- res.writeHead(capabilityRequired ? 422 : 502, { 'content-type': 'application/json' });
3148
- res.end(JSON.stringify({
3552
+ return dispatchFailure(capabilityRequired ? 422 : 502, { 'content-type': 'application/json' }, JSON.stringify({
3149
3553
  error: capabilityRequired
3150
3554
  ? {
3151
3555
  type: 'required_capability_unavailable',
@@ -3160,7 +3564,6 @@ export class BuyerProxy {
3160
3564
  param: 'model',
3161
3565
  },
3162
3566
  }));
3163
- return;
3164
3567
  }
3165
3568
  let lastRetry = null;
3166
3569
  let lastVerificationError = null;
@@ -3172,8 +3575,8 @@ export class BuyerProxy {
3172
3575
  if (unfunded && !isFree(selected))
3173
3576
  continue;
3174
3577
  if (this._verifier) {
3175
- const makeReach = (chosenId) => makeVerifierReach(this._node, selected.peer, chosenId, clientAbortController.signal);
3176
- const outcome = await this._verifyPeer(selected.peer, makeReach, clientAbortController.signal, requireVerified);
3578
+ const makeReach = (chosenId) => makeVerifierReach(this._node, selected.peer, chosenId, signal);
3579
+ const outcome = await this._verifyPeer(selected.peer, makeReach, signal, requireVerified);
3177
3580
  if (!outcome.ok) {
3178
3581
  lastVerificationError = `Peer ${selected.peer.peerId.slice(0, 12)}... failed required verification (${outcome.reason ?? 'failed'}).`;
3179
3582
  log(`${lastVerificationError} Trying the next model peer.`);
@@ -3185,33 +3588,28 @@ export class BuyerProxy {
3185
3588
  + `service="${selected.serviceId}" reputation=${selected.reputation} `
3186
3589
  + `effective=${selected.effectiveReputationScore ?? 'unknown'} peer=${index + 1}/${candidates.length} `
3187
3590
  + `attempt=${peerAttempt + 1}/${MODEL_RATE_LIMIT_MAX_ATTEMPTS_PER_PEER}`);
3188
- const result = await this._dispatchToPeer(res, selected.request, selected.peer, modelPlans, requestProtocol, selected.serviceId, explicitProvider, router, false, clientAbortController.signal, requestPolicy !== null);
3591
+ const result = await this._dispatchToPeer(res, selected.request, selected.peer, modelPlans, requestProtocol, selected.serviceId, explicitProvider, router, false, signal, requestPolicy !== null, routingConversationKey);
3189
3592
  if (result.done) {
3190
3593
  // Only a seller that answered becomes the chat's route: an error
3191
3594
  // must not pin later turns to it.
3192
3595
  if (trackedConversationId && res.statusCode < 400) {
3193
3596
  this._conversations.recordRoutedModel(trackedConversationId, `${selected.peer.peerId}@${selected.serviceId}`, conversationPinAtRouting);
3194
3597
  }
3195
- return;
3598
+ return result;
3196
3599
  }
3197
3600
  // Never retry a video on another seller: a create may already have
3198
3601
  // started (and been charged), and follow-ups only exist on one seller.
3199
- if (nativeVideo) {
3200
- res.writeHead(result.statusCode, result.responseHeaders);
3201
- res.end(result.responseBody);
3202
- return;
3203
- }
3602
+ if (nativeVideo)
3603
+ return result;
3204
3604
  lastRetry = result;
3205
- if (result.responseHeaders[ANTSEED_FAULT_ATTRIBUTION_HEADER]?.toLowerCase() === 'buyer') {
3605
+ if (isBuyerFault(result)) {
3206
3606
  const freeLeft = candidates.slice(index + 1).some(isFree);
3207
3607
  if (result.statusCode === 402 && freeLeft) {
3208
3608
  unfunded = true;
3209
3609
  log(`No deposits to pay peer ${selected.peer.peerId.slice(0, 12)}...; trying free sellers only.`);
3210
3610
  break;
3211
3611
  }
3212
- res.writeHead(result.statusCode, result.responseHeaders);
3213
- res.end(result.responseBody);
3214
- return;
3612
+ return result;
3215
3613
  }
3216
3614
  const retrySamePeer = result.statusCode === 429
3217
3615
  && peerAttempt + 1 < MODEL_RATE_LIMIT_MAX_ATTEMPTS_PER_PEER;
@@ -3219,28 +3617,20 @@ export class BuyerProxy {
3219
3617
  break;
3220
3618
  const delayMs = rateLimitRetryDelayMs(result.responseHeaders, peerAttempt);
3221
3619
  log(`Peer ${selected.peer.peerId.slice(0, 12)}... is rate-limited; retrying in ${delayMs}ms.`);
3222
- if (!await waitForRetry(delayMs, clientAbortController.signal))
3223
- return;
3620
+ if (!await waitForRetry(delayMs, signal))
3621
+ return { done: true };
3224
3622
  }
3225
3623
  if (index + 1 < candidates.length) {
3226
3624
  log(`Peer ${selected.peer.peerId.slice(0, 12)}... failed retryably; trying next model peer.`);
3227
3625
  }
3228
3626
  }
3229
- if (lastRetry) {
3230
- res.writeHead(lastRetry.statusCode, lastRetry.responseHeaders);
3231
- res.end(lastRetry.responseBody);
3232
- }
3233
- else {
3234
- res.writeHead(502, { 'content-type': 'application/json' });
3235
- res.end(JSON.stringify({
3236
- error: {
3237
- type: 'peer_verification_failed',
3238
- code: 'peer_verification_failed',
3239
- message: lastVerificationError ?? `No verified peer currently serves model "${requestedService}".`,
3240
- },
3241
- }));
3242
- }
3243
- return;
3627
+ return lastRetry ?? dispatchFailure(502, { 'content-type': 'application/json' }, JSON.stringify({
3628
+ error: {
3629
+ type: 'peer_verification_failed',
3630
+ code: 'peer_verification_failed',
3631
+ message: lastVerificationError ?? `No verified peer currently serves model "${requestedService}".`,
3632
+ },
3633
+ }));
3244
3634
  }
3245
3635
  const pinnedPeerId = explicitPeerId;
3246
3636
  // Narrow the candidate set to just the pinned peer (if we already know
@@ -3288,12 +3678,12 @@ export class BuyerProxy {
3288
3678
  : '--peer flag or session pin';
3289
3679
  const diagnostics = this._formatPeerSelectionDiagnostics(discoveredPeers);
3290
3680
  log(`Pinned peer ${pinnedPeerId.slice(0, 12)}... not discoverable in DHT (${logSource})`);
3291
- res.writeHead(502, { 'content-type': 'text/plain' });
3292
- res.end(`Pinned peer ${pinnedPeerId.slice(0, 12)}... is not reachable right now. `
3681
+ // Also the answer for a `<peerId>@<routingServiceId>` pin whose router
3682
+ // is offline: an absent peer cannot be told apart from a seller.
3683
+ return dispatchFailure(502, { 'content-type': 'text/plain' }, `Pinned peer ${pinnedPeerId.slice(0, 12)}... (seller or routing service) is not reachable right now. `
3293
3684
  + 'It may be offline, not announcing, or temporarily unreachable. '
3294
3685
  + 'Pick a different service in Discover or try again later. '
3295
3686
  + diagnostics);
3296
- return;
3297
3687
  }
3298
3688
  if (routingPeers.length === 0) {
3299
3689
  const pinnedPeer = discoveredPeers.find((peer) => peer.peerId.toLowerCase() === pinnedPeerId) ?? null;
@@ -3308,20 +3698,16 @@ export class BuyerProxy {
3308
3698
  if (!providers.includes(explicitProvider)) {
3309
3699
  const providerList = providers.length > 0 ? providers.join(', ') : 'none';
3310
3700
  log(`Pinned peer ${pinnedPeerId.slice(0, 12)}... does not offer explicit provider=${explicitProvider}`);
3311
- res.writeHead(502, { 'content-type': 'text/plain' });
3312
- res.end(`Pinned peer ${pinnedPeerId.slice(0, 12)}... does not offer provider=${explicitProvider}. `
3701
+ return dispatchFailure(502, { 'content-type': 'text/plain' }, `Pinned peer ${pinnedPeerId.slice(0, 12)}... does not offer provider=${explicitProvider}. `
3313
3702
  + `Available providers: ${providerList}. `
3314
3703
  + 'Remove or change the x-antseed-provider header, or pick a different peer. '
3315
3704
  + diagnostics);
3316
- return;
3317
3705
  }
3318
3706
  }
3319
3707
  log(`Pinned peer ${pinnedPeerId.slice(0, 12)}... filtered out by protocol/service match`);
3320
- res.writeHead(502, { 'content-type': 'text/plain' });
3321
- res.end(`Pinned peer ${pinnedPeerId.slice(0, 12)}... does not support this request `
3708
+ return dispatchFailure(502, { 'content-type': 'text/plain' }, `Pinned peer ${pinnedPeerId.slice(0, 12)}... does not support this request `
3322
3709
  + `(${protocolLabel}, ${providerLabel}, ${serviceLabel}). `
3323
3710
  + `Pick a different service in Discover. ${diagnostics}`);
3324
- return;
3325
3711
  }
3326
3712
  log(`Routing candidates: ${routingPeers.length} peer(s)`);
3327
3713
  const router = this._node.router;
@@ -3331,9 +3717,7 @@ export class BuyerProxy {
3331
3717
  // if an invariant breaks.
3332
3718
  if (!selectedPeer) {
3333
3719
  log(`Invariant: pinned peer ${pinnedPeerId.slice(0, 12)}... present in DHT but missing from narrowed candidate list`);
3334
- res.writeHead(502, { 'content-type': 'text/plain' });
3335
- res.end(`Pinned peer ${pinnedPeerId.slice(0, 12)}... is currently unreachable. Try again in a moment.`);
3336
- return;
3720
+ return dispatchFailure(502, { 'content-type': 'text/plain' }, `Pinned peer ${pinnedPeerId.slice(0, 12)}... is currently unreachable. Try again in a moment.`);
3337
3721
  }
3338
3722
  const policyRouter = router;
3339
3723
  const selectedPlan = routingPlans.get(selectedPeer.peerId)
@@ -3343,8 +3727,7 @@ export class BuyerProxy {
3343
3727
  ? findMissingRequiredParameters(selectedPeer, selectedPlan.provider, pinnedServiceId, requiredParameters)
3344
3728
  : requiredParameters;
3345
3729
  if (missingRequired.length > 0) {
3346
- res.writeHead(422, { 'content-type': 'application/json' });
3347
- res.end(JSON.stringify({
3730
+ return dispatchFailure(422, { 'content-type': 'application/json' }, JSON.stringify({
3348
3731
  error: {
3349
3732
  type: 'required_capability_unavailable',
3350
3733
  code: 'required_capability_unavailable',
@@ -3352,7 +3735,6 @@ export class BuyerProxy {
3352
3735
  param: REQUIRED_PARAMETERS_HEADER,
3353
3736
  },
3354
3737
  }));
3355
- return;
3356
3738
  }
3357
3739
  const pinnedRequest = pinnedServiceId ? withRoutedModel(serializedReq, pinnedServiceId) : serializedReq;
3358
3740
  // A hard pin cannot escape the gateway's policy: an excluded seller is
@@ -3370,26 +3752,23 @@ export class BuyerProxy {
3370
3752
  addBuyerConfigReasons(reasons, policyExclusionReasons(this._buyerHardPolicy(), facts));
3371
3753
  if (reasons.length > 0) {
3372
3754
  log(`Pinned peer ${selectedPeer.peerId.slice(0, 12)}... refused by routing policy: ${reasons.join('; ')}`);
3373
- sendJson(res, 403, {
3755
+ return dispatchFailure(403, { 'content-type': 'application/json' }, JSON.stringify({
3374
3756
  error: {
3375
3757
  type: 'permission_error',
3376
3758
  code: 'peer_not_allowed',
3377
3759
  message: `Seller ${selectedPeer.peerId.slice(0, 12)}... is not allowed by the routing policy (${reasons.join('; ')}).`,
3378
3760
  },
3379
- });
3380
- return;
3761
+ }));
3381
3762
  }
3382
3763
  }
3383
3764
  if (!peerAllowedByPolicy(policyRouter, pinnedRequest, selectedPeer)) {
3384
3765
  log(`Pinned peer ${selectedPeer.peerId.slice(0, 12)}... filtered out by buyer routing policy`);
3385
- res.writeHead(502, { 'content-type': 'text/plain' });
3386
- res.end(`Pinned peer ${selectedPeer.peerId.slice(0, 12)}... is outside your buyer routing policy. `
3766
+ return dispatchFailure(502, { 'content-type': 'text/plain' }, `Pinned peer ${selectedPeer.peerId.slice(0, 12)}... is outside your buyer routing policy. `
3387
3767
  + 'Pick a different service in Discover or adjust your buyer pricing/reputation limits.');
3388
- return;
3389
3768
  }
3390
3769
  if (this._verifier) {
3391
- const makeReach = (chosenId) => makeVerifierReach(this._node, selectedPeer, chosenId, clientAbortController.signal);
3392
- const outcome = await this._verifyPeer(selectedPeer, makeReach, clientAbortController.signal, pinnedPolicy?.requireVerified === true);
3770
+ const makeReach = (chosenId) => makeVerifierReach(this._node, selectedPeer, chosenId, signal);
3771
+ const outcome = await this._verifyPeer(selectedPeer, makeReach, signal, pinnedPolicy?.requireVerified === true);
3393
3772
  const short = selectedPeer.peerId.slice(0, 12);
3394
3773
  if (outcome.verified) {
3395
3774
  log(`Verified ${short}... via ${outcome.sdk}`);
@@ -3398,23 +3777,17 @@ export class BuyerProxy {
3398
3777
  log(`Verification ${outcome.sdk ? `(${outcome.sdk}) ` : ''}did not pass for ${short}...: ${outcome.reason ?? 'failed'}${outcome.ok ? ' (optional — routing anyway)' : ''}`);
3399
3778
  }
3400
3779
  if (!outcome.ok) {
3401
- res.writeHead(502, { 'content-type': 'text/plain' });
3402
- res.end(`Pinned peer ${short}... failed required verification (${outcome.reason ?? 'failed'}). `
3780
+ return dispatchFailure(502, { 'content-type': 'text/plain' }, `Pinned peer ${short}... failed required verification (${outcome.reason ?? 'failed'}). `
3403
3781
  + 'Pick a different peer, or run without --require-verifier.');
3404
- return;
3405
3782
  }
3406
3783
  }
3407
3784
  log(`Using pinned peer ${selectedPeer.peerId.slice(0, 12)}...`);
3408
- const result = await this._dispatchToPeer(res, pinnedRequest, selectedPeer, routingPlans, requestProtocol, requestedService, explicitProvider, router, true, clientAbortController.signal);
3785
+ const result = await this._dispatchToPeer(res, pinnedRequest, selectedPeer, routingPlans, requestProtocol, requestedService, explicitProvider, router, true, signal, false, routingConversationKey);
3409
3786
  if (result.done && trackedConversationId && pinnedServiceId) {
3410
3787
  this._conversations.recordRoutedModel(trackedConversationId, `${selectedPeer.peerId}@${pinnedServiceId}`, conversationPinAtRouting);
3411
3788
  }
3412
- if (!result.done) {
3413
- // Pinned peer returned a retryable error. We never retry against another
3414
- // peer for an explicit pin, so surface the error to the client.
3415
- res.writeHead(result.statusCode, result.responseHeaders);
3416
- res.end(result.responseBody);
3417
- }
3789
+ // A pinned peer is never retried against another peer; the caller reports any failure.
3790
+ return result;
3418
3791
  }
3419
3792
  async _verifyPeer(peer, makeReach, signal, requireVerified = false) {
3420
3793
  const configured = this._verifier;
@@ -3499,7 +3872,7 @@ export class BuyerProxy {
3499
3872
  * was sent to the client (success or non-retryable error), or retry info if the
3500
3873
  * caller should try another peer.
3501
3874
  */
3502
- async _dispatchToPeer(res, serializedReq, selectedPeer, routePlanByPeerId, requestProtocol, requestedService, explicitProvider, router, pinned, requestSignal, gatewayRouted = false) {
3875
+ async _dispatchToPeer(res, serializedReq, selectedPeer, routePlanByPeerId, requestProtocol, requestedService, explicitProvider, router, pinned, requestSignal, gatewayRouted = false, routingConversationKey = null) {
3503
3876
  const gatewayAutoRoute = gatewayRouted && !pinned;
3504
3877
  const selectedRoutePlan = routePlanByPeerId.get(selectedPeer.peerId)
3505
3878
  ?? resolvePeerRoutePlan(selectedPeer, requestProtocol, requestedService, explicitProvider, 'lenient');
@@ -3588,7 +3961,10 @@ export class BuyerProxy {
3588
3961
  // Video responses are JSON job objects or a streamed MP4, never SSE.
3589
3962
  const wantsStreaming = clientWantsStreaming && !videoRoute;
3590
3963
  if (videoRoute?.action === 'retrieve') {
3591
- await downloadVideo(requestForPeer, res, (request, callbacks, signal) => this._node.sendRequestStream(selectedPeer, request, callbacks, { signal, pinned: true }), requestSignal);
3964
+ // The seller hands a job only to the buyer that created it: retrieve with the same wallet.
3965
+ await downloadVideo(requestForPeer, res, (request, callbacks, signal) => this._node.sendRequestStream(selectedPeer, request, callbacks, {
3966
+ signal, pinned: true, ...(buyerIdentity ? { buyerIdentity } : {}),
3967
+ }), requestSignal);
3592
3968
  return { done: true };
3593
3969
  }
3594
3970
  const peerResponseProtocol = selectedRoutePlan.selection?.targetProtocol ?? requestProtocol;
@@ -3597,17 +3973,17 @@ export class BuyerProxy {
3597
3973
  try {
3598
3974
  if (wantsStreaming) {
3599
3975
  let streamed = false;
3976
+ let firstFrameAt = 0;
3600
3977
  const response = await this._node.sendRequestStream(selectedPeer, requestForPeer, {
3601
3978
  onResponseStart: (startResponse, metadata) => {
3602
3979
  if (!metadata.streaming)
3603
3980
  return;
3604
3981
  streamed = true;
3605
- if (startResponse.statusCode < 400)
3606
- this._recordPeerLatency(selectedPeer.peerId, latencyServiceId, Date.now() - startTime);
3982
+ firstFrameAt = Date.now();
3607
3983
  const adaptedStartResponse = streamResponseAdapter
3608
3984
  ? streamResponseAdapter.adaptStart(startResponse)
3609
3985
  : startResponse;
3610
- const streamingHeaders = attachStreamingAntseedHeaders(adaptedStartResponse.headers, selectedPeer, requestForPeer);
3986
+ const streamingHeaders = attachStreamingAntseedHeaders(adaptedStartResponse.headers, selectedPeer, requestForPeer, firstFrameAt - startTime);
3611
3987
  // Ensure content-type is set for SSE — some upstream APIs (e.g. Codex)
3612
3988
  // omit it, which can cause the client's fetch body reader to not
3613
3989
  // detect end-of-stream properly.
@@ -3633,6 +4009,7 @@ export class BuyerProxy {
3633
4009
  }
3634
4010
  },
3635
4011
  }, { signal: requestSignal, pinned, ...(buyerIdentity ? { buyerIdentity } : {}) });
4012
+ recordRouterUsage(this._modelRoutingClient, routingConversationKey, requestForPeer, response, selectedPeer, requestSignal);
3636
4013
  let responseForClient = adaptBuyerFaultErrorResponse(response, requestProtocol);
3637
4014
  responseForClient = adaptPeerResponse(responseForClient);
3638
4015
  if (!streamed
@@ -3645,15 +4022,17 @@ export class BuyerProxy {
3645
4022
  if (responseForClient.statusCode === 402) {
3646
4023
  responseForClient = inject402PeerId(responseForClient, selectedPeer.peerId);
3647
4024
  }
3648
- const latencyMs = Date.now() - startTime;
3649
- if (!streamed && responseForClient.statusCode < 400)
3650
- this._recordPeerLatency(selectedPeer.peerId, latencyServiceId, latencyMs);
4025
+ const finishedAt = Date.now();
4026
+ const latencyMs = finishedAt - startTime;
3651
4027
  log(`Response: ${responseForClient.statusCode} (${latencyMs}ms, ${responseForClient.body.length} bytes)`);
3652
4028
  if (responseForClient.statusCode >= 400) {
3653
4029
  const prefix = adaptResponse && !streamed ? 'Upstream adapted error detail' : 'Upstream error detail';
3654
4030
  log(`${prefix}: ${summarizeErrorResponse(responseForClient)}`);
3655
4031
  }
3656
4032
  const telemetry = computeResponseTelemetry(requestForPeer, responseForClient.headers, responseForClient.body, selectedPeer);
4033
+ if (streamed && responseForClient.statusCode < 400) {
4034
+ this._recordPeerSpeed(selectedPeer.peerId, latencyServiceId, firstFrameAt - startTime, finishedAt - firstFrameAt, telemetry.usage.outputTokens);
4035
+ }
3657
4036
  const responseFault = responseFaultAttribution(responseForClient);
3658
4037
  const modelNotFound = !streamed
3659
4038
  && !isControlPlaneServicesPath(requestForPeer.path)
@@ -3706,6 +4085,7 @@ export class BuyerProxy {
3706
4085
  if (upstreamResponse.statusCode >= 400 && !adaptResponse) {
3707
4086
  log(`Upstream raw error detail: ${summarizeErrorResponse(upstreamResponse)}`);
3708
4087
  }
4088
+ recordRouterUsage(this._modelRoutingClient, routingConversationKey, requestForPeer, upstreamResponse, selectedPeer, requestSignal);
3709
4089
  // Only the seller that accepted a video job knows its ID. Remember and
3710
4090
  // persist job ID -> seller so later retrieve requests (even
3711
4091
  // after a proxy restart) are pinned back to that seller.
@@ -3725,8 +4105,6 @@ export class BuyerProxy {
3725
4105
  }
3726
4106
  const latencyMs = Date.now() - startTime;
3727
4107
  this._markModelActivity();
3728
- if (response.statusCode < 400)
3729
- this._recordPeerLatency(selectedPeer.peerId, latencyServiceId, latencyMs);
3730
4108
  log(`Response: ${response.statusCode} (${latencyMs}ms, ${response.body.length} bytes)`);
3731
4109
  if (response.statusCode >= 400) {
3732
4110
  const prefix = adaptResponse ? 'Upstream adapted error detail' : 'Upstream error detail';