@antseed/cli 0.1.148 → 0.1.150

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 (94) hide show
  1. package/README.md +51 -17
  2. package/dist/cli/commands/buyer/activity.d.ts +3 -0
  3. package/dist/cli/commands/buyer/activity.d.ts.map +1 -0
  4. package/dist/cli/commands/buyer/activity.js +555 -0
  5. package/dist/cli/commands/buyer/activity.js.map +1 -0
  6. package/dist/cli/commands/buyer/daemon.d.ts +13 -0
  7. package/dist/cli/commands/buyer/daemon.d.ts.map +1 -0
  8. package/dist/cli/commands/buyer/daemon.js +39 -0
  9. package/dist/cli/commands/buyer/daemon.js.map +1 -0
  10. package/dist/cli/commands/buyer/deposit.d.ts +8 -0
  11. package/dist/cli/commands/buyer/deposit.d.ts.map +1 -1
  12. package/dist/cli/commands/buyer/deposit.js +338 -25
  13. package/dist/cli/commands/buyer/deposit.js.map +1 -1
  14. package/dist/cli/commands/buyer/index.d.ts.map +1 -1
  15. package/dist/cli/commands/buyer/index.js +2 -0
  16. package/dist/cli/commands/buyer/index.js.map +1 -1
  17. package/dist/cli/commands/buyer/start.d.ts +1 -0
  18. package/dist/cli/commands/buyer/start.d.ts.map +1 -1
  19. package/dist/cli/commands/buyer/start.js +53 -10
  20. package/dist/cli/commands/buyer/start.js.map +1 -1
  21. package/dist/cli/commands/buyer/start.test.js +30 -1
  22. package/dist/cli/commands/buyer/start.test.js.map +1 -1
  23. package/dist/cli/commands/buyer/sweep.d.ts.map +1 -1
  24. package/dist/cli/commands/buyer/sweep.js +17 -17
  25. package/dist/cli/commands/buyer/sweep.js.map +1 -1
  26. package/dist/cli/commands/network/browse.d.ts.map +1 -1
  27. package/dist/cli/commands/network/browse.js +65 -114
  28. package/dist/cli/commands/network/browse.js.map +1 -1
  29. package/dist/cli/commands/payments.d.ts.map +1 -1
  30. package/dist/cli/commands/payments.js +29 -34
  31. package/dist/cli/commands/payments.js.map +1 -1
  32. package/dist/cli/commands/seller/start.d.ts.map +1 -1
  33. package/dist/cli/commands/seller/start.js +4 -1
  34. package/dist/cli/commands/seller/start.js.map +1 -1
  35. package/dist/cli/commands/seller/start.test.js +15 -0
  36. package/dist/cli/commands/seller/start.test.js.map +1 -1
  37. package/dist/cli/index.js +2 -0
  38. package/dist/cli/index.js.map +1 -1
  39. package/dist/config/defaults.d.ts.map +1 -1
  40. package/dist/config/defaults.js +7 -0
  41. package/dist/config/defaults.js.map +1 -1
  42. package/dist/config/loader.d.ts.map +1 -1
  43. package/dist/config/loader.js +41 -0
  44. package/dist/config/loader.js.map +1 -1
  45. package/dist/config/loader.test.js +52 -0
  46. package/dist/config/loader.test.js.map +1 -1
  47. package/dist/config/types.d.ts +10 -0
  48. package/dist/config/types.d.ts.map +1 -1
  49. package/dist/config/validation.d.ts.map +1 -1
  50. package/dist/config/validation.js +17 -0
  51. package/dist/config/validation.js.map +1 -1
  52. package/dist/generated/baked-defaults.d.ts +15 -0
  53. package/dist/generated/baked-defaults.d.ts.map +1 -0
  54. package/dist/generated/baked-defaults.js +15 -0
  55. package/dist/generated/baked-defaults.js.map +1 -0
  56. package/dist/plugins/manager.d.ts +1 -0
  57. package/dist/plugins/manager.d.ts.map +1 -1
  58. package/dist/plugins/manager.js +15 -4
  59. package/dist/plugins/manager.js.map +1 -1
  60. package/dist/plugins/manager.test.d.ts +2 -0
  61. package/dist/plugins/manager.test.d.ts.map +1 -0
  62. package/dist/plugins/manager.test.js +15 -0
  63. package/dist/plugins/manager.test.js.map +1 -0
  64. package/dist/proxy/buyer-proxy.d.ts +31 -2
  65. package/dist/proxy/buyer-proxy.d.ts.map +1 -1
  66. package/dist/proxy/buyer-proxy.js +481 -59
  67. package/dist/proxy/buyer-proxy.js.map +1 -1
  68. package/dist/proxy/buyer-proxy.test.js +894 -56
  69. package/dist/proxy/buyer-proxy.test.js.map +1 -1
  70. package/dist/proxy/conversation-store.d.ts +6 -6
  71. package/dist/proxy/conversation-store.d.ts.map +1 -1
  72. package/dist/proxy/conversation-store.js +16 -0
  73. package/dist/proxy/conversation-store.js.map +1 -1
  74. package/dist/proxy/deposit-watcher.d.ts +125 -0
  75. package/dist/proxy/deposit-watcher.d.ts.map +1 -0
  76. package/dist/proxy/deposit-watcher.js +306 -0
  77. package/dist/proxy/deposit-watcher.js.map +1 -0
  78. package/dist/proxy/deposit-watcher.test.d.ts +2 -0
  79. package/dist/proxy/deposit-watcher.test.d.ts.map +1 -0
  80. package/dist/proxy/deposit-watcher.test.js +173 -0
  81. package/dist/proxy/deposit-watcher.test.js.map +1 -0
  82. package/dist/proxy/network-models.d.ts +76 -0
  83. package/dist/proxy/network-models.d.ts.map +1 -0
  84. package/dist/proxy/network-models.js +229 -0
  85. package/dist/proxy/network-models.js.map +1 -0
  86. package/dist/proxy/network-models.test.d.ts +2 -0
  87. package/dist/proxy/network-models.test.d.ts.map +1 -0
  88. package/dist/proxy/network-models.test.js +734 -0
  89. package/dist/proxy/network-models.test.js.map +1 -0
  90. package/dist/proxy/routing.d.ts +3 -1
  91. package/dist/proxy/routing.d.ts.map +1 -1
  92. package/dist/proxy/routing.js +69 -16
  93. package/dist/proxy/routing.js.map +1 -1
  94. package/package.json +6 -5
@@ -3,10 +3,12 @@ import { randomUUID } from 'node:crypto';
3
3
  import { watchFile, unwatchFile } from 'node:fs';
4
4
  import { readFile, writeFile, rename, mkdir, readdir, stat, unlink } from 'node:fs/promises';
5
5
  import { join } from 'node:path';
6
- import { ANTSEED_BUYER_FAULT_ERROR_CODE, ANTSEED_FAULT_ATTRIBUTION_HEADER, ANTSEED_ATTEST_PATH, computeOnChainReputationScore, decodeSweepRequest, faultAttributionOf, faultCodeOf, peerSupportsCooperativeClose, } from '@antseed/node';
6
+ import { ANTSEED_BUYER_FAULT_ERROR_CODE, ANTSEED_FAULT_ATTRIBUTION_HEADER, ANTSEED_ATTEST_PATH, computeOnChainReputationScore, decodeSweepRequest, faultAttributionOf, faultCodeOf, isModelRouteEligible, peerSupportsCooperativeClose, rankModelRoutes, } from '@antseed/node';
7
+ import { canonicalModelKey } from '@antseed/node/model-identity';
7
8
  import { createStreamingAdapter, detectRequestServiceApiProtocol, transformRequest, transformResponse, } from './service-api-adapter.js';
8
9
  import { DEBUG, log, extractRequestedService, summarizeRequestShape, summarizeErrorResponse, requestWantsStreaming, parsePeerPinnedService, rewritePeerPinnedServiceInBody, substituteRoutedModelAlias, overrideRoutedModelInBody, ROUTED_MODEL_ALIAS, SYSTEM_PROXY_SOURCE_HEADER, SYSTEM_ROUTED_MODEL_HEADER, normalizePeerId, } from './request-utils.js';
9
- import { findUnannouncedRequestParameters, getExplicitProviderOverride, getExplicitPeerIdOverride, resolvePeerRoutePlan, selectCandidatePeersForRouting, } from './routing.js';
10
+ import { buildNetworkModels, effectiveModelReputationScore, normalizedModelReputationScore, parseModelTypeFilter, } from './network-models.js';
11
+ import { findUnannouncedRequestParameters, findAdvertisedServiceOffer, getExplicitProviderOverride, getExplicitPeerIdOverride, resolvePeerRoutePlan, selectCandidatePeersForRouting, } from './routing.js';
10
12
  import { computeResponseTelemetry, attachAntseedTelemetryHeaders, attachStreamingAntseedHeaders, } from './telemetry.js';
11
13
  import { DEFAULT_BUYER_PEER_REFRESH_INTERVAL_MS } from '../config/defaults.js';
12
14
  import { extractConversationIdentity, extractFirstUserSnippet, isCompletionRequestPath, isTitleGenerationRequest, parseRequestBodyObject, } from './conversation-identity.js';
@@ -15,10 +17,63 @@ import { recordPeerFailureEntry, clearPeerHealthEntry, isCoolingDown, parsePersi
15
17
  import { PeerAttributionTracker, HEARTBEAT_MS } from './peer-attribution.js';
16
18
  import { estimateAnthropicPromptTokens, isCountTokensPath } from './count-tokens.js';
17
19
  import { getCachedVerdict, runVerifier, verifierSupportFingerprint } from '../plugins/verifier.js';
20
+ import { loadConfig } from '../config/loader.js';
18
21
  // Re-export for backward compatibility (used by tests and other consumers)
19
22
  export { selectCandidatePeersForRouting } from './routing.js';
20
23
  export { parsePeerPinnedService, rewritePeerPinnedServiceInBody, substituteRoutedModelAlias, ROUTED_MODEL_ALIAS } from './request-utils.js';
21
24
  const RETRYABLE_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504]);
25
+ const MODEL_RATE_LIMIT_MAX_ATTEMPTS_PER_PEER = 3;
26
+ const MODEL_RATE_LIMIT_RETRY_DELAYS_MS = [250, 750];
27
+ const MODEL_RATE_LIMIT_MAX_RETRY_AFTER_MS = 2_000;
28
+ function rateLimitRetryDelayMs(headers, retryIndex) {
29
+ const retryAfter = headers['retry-after'] ?? headers['Retry-After'];
30
+ if (retryAfter) {
31
+ const seconds = Number(retryAfter);
32
+ if (Number.isFinite(seconds) && seconds >= 0) {
33
+ return Math.min(MODEL_RATE_LIMIT_MAX_RETRY_AFTER_MS, Math.round(seconds * 1_000));
34
+ }
35
+ }
36
+ return MODEL_RATE_LIMIT_RETRY_DELAYS_MS[retryIndex] ?? MODEL_RATE_LIMIT_RETRY_DELAYS_MS.at(-1);
37
+ }
38
+ async function waitForRetry(delayMs, signal) {
39
+ if (signal.aborted)
40
+ return false;
41
+ return new Promise((resolve) => {
42
+ const onAbort = () => {
43
+ clearTimeout(timeout);
44
+ resolve(false);
45
+ };
46
+ const timeout = setTimeout(() => {
47
+ signal.removeEventListener('abort', onAbort);
48
+ resolve(true);
49
+ }, delayMs);
50
+ signal.addEventListener('abort', onAbort, { once: true });
51
+ });
52
+ }
53
+ /**
54
+ * A routed-model target is either a bare `<service>` (automatic peer
55
+ * selection) or an explicit `<peerId>@<service>` pin. The `antseed` alias
56
+ * itself can never be a target — it would recurse.
57
+ */
58
+ function isValidRoutedModelTarget(value) {
59
+ if (value === ROUTED_MODEL_ALIAS)
60
+ return false;
61
+ return !value.includes('@') || parsePeerPinnedService(value) !== null;
62
+ }
63
+ /** Returns `request` with its body's model field rewritten to `serviceId`, or unchanged if nothing rewrote. */
64
+ function withRoutedModel(request, serviceId) {
65
+ const rewritten = overrideRoutedModelInBody(request.body, request.headers, serviceId);
66
+ return rewritten.overridden
67
+ ? { ...request, body: rewritten.body, headers: rewritten.headers }
68
+ : request;
69
+ }
70
+ function peerAllowedByPolicy(policyRouter, request, peer) {
71
+ if (policyRouter?.allowsPeerForPolicy)
72
+ return policyRouter.allowsPeerForPolicy(request, peer);
73
+ if (policyRouter?.allowsPeerForPricing)
74
+ return policyRouter.allowsPeerForPricing(request, peer);
75
+ return true;
76
+ }
22
77
  function isControlPlaneServicesPath(path) {
23
78
  return path.toLowerCase().startsWith('/v1/models');
24
79
  }
@@ -390,6 +445,11 @@ export function parsePersistedPeers(parsed, nowMs = Date.now(), maxAgeMs = CARRY
390
445
  if (entry.verificationResults && typeof entry.verificationResults === 'object') {
391
446
  peer.verificationResults = entry.verificationResults;
392
447
  }
448
+ if (peer.onChainReputationScore === undefined) {
449
+ const derivedScore = computeOnChainReputationScore(peer, nowMs);
450
+ if (derivedScore !== null)
451
+ peer.onChainReputationScore = derivedScore;
452
+ }
393
453
  peers.push(peer);
394
454
  }
395
455
  return peers;
@@ -501,10 +561,13 @@ export class BuyerProxy {
501
561
  _peerCacheTtlMs;
502
562
  _stateDir;
503
563
  _stateFile;
564
+ _configPath;
504
565
  _stateFileWatching = false;
566
+ _configFileWatching = false;
505
567
  _pinnedPeer;
506
568
  /**
507
- * Route substituted for the `antseed` model alias (`<peerId>@<service>`).
569
+ * Route substituted for the `antseed` model alias (`<service>` for automatic
570
+ * peer selection, or `<peerId>@<service>` for an explicit seller pin).
508
571
  * Set via `POST /_antseed/route` (the desktop keeps it on the current VPR
509
572
  * selection) and persisted in buyer.state.json like the session peer pin.
510
573
  */
@@ -520,6 +583,8 @@ export class BuyerProxy {
520
583
  _verifier;
521
584
  _verifyCache = new Map();
522
585
  _stateWatchDebounce = null;
586
+ _configWatchDebounce = null;
587
+ _routingPreferences;
523
588
  _stateWriteChain = Promise.resolve();
524
589
  _cachedPeers = [];
525
590
  _cacheLastUpdatedAtMs = 0;
@@ -544,6 +609,12 @@ export class BuyerProxy {
544
609
  _now;
545
610
  /** Latest relayer receipt per sweep authNonce, for CLI progress polling. */
546
611
  _sweepReceipts = new Map();
612
+ /**
613
+ * Hot-wallet deposit watcher (auto-sweep). Owned by `buyer start`, attached
614
+ * here so the desktop and `antseed deposit` can drive it over the control
615
+ * plane instead of running a second signer against the same wallet.
616
+ */
617
+ _depositWatcher = null;
547
618
  /**
548
619
  * requestId -> the conversation that issued it, so the node's per-request
549
620
  * spend events can be attributed to a chat. Only the proxy knows this
@@ -561,8 +632,16 @@ export class BuyerProxy {
561
632
  this._peerCacheTtlMs = Math.max(0, config.peerCacheTtlMs ?? Math.max(6 * 60_000, this._bgRefreshIntervalMs + 60_000));
562
633
  this._stateDir = config.dataDir;
563
634
  this._stateFile = join(config.dataDir, 'buyer.state.json');
635
+ this._configPath = config.configPath ?? null;
564
636
  this._conversations = new ConversationStore(config.dataDir);
565
637
  this._pinnedPeer = config.pinnedPeerId?.toLowerCase() ?? null;
638
+ this._routingPreferences = config.routingPreferences
639
+ ? {
640
+ ...config.routingPreferences,
641
+ allowedPeerIds: [...config.routingPreferences.allowedPeerIds],
642
+ blockedPeerIds: [...config.routingPreferences.blockedPeerIds],
643
+ }
644
+ : null;
566
645
  this._now = config.now ?? (() => Date.now());
567
646
  this._server = createServer((req, res) => {
568
647
  this._handleRequest(req, res).catch((err) => {
@@ -629,6 +708,14 @@ export class BuyerProxy {
629
708
  this._requestConversations.delete(oldest);
630
709
  }
631
710
  }
711
+ /** Attach the hot-wallet deposit watcher (owned by `buyer start`). */
712
+ setDepositWatcher(watcher) {
713
+ this._depositWatcher = watcher;
714
+ }
715
+ /** Latest relayer receipt for a sweep authNonce, if one has arrived. */
716
+ getSweepReceipt(authNonce) {
717
+ return this._sweepReceipts.get(authNonce.toLowerCase()) ?? null;
718
+ }
632
719
  async start() {
633
720
  this._startedAtMs = Date.now();
634
721
  // Clean up temp files orphaned by state writes whose rename failed in a
@@ -661,6 +748,7 @@ export class BuyerProxy {
661
748
  this._startIncrementalDiscoverySweep();
662
749
  await this._writeStateFile('connected');
663
750
  this._watchStateFile();
751
+ this._watchConfigFile();
664
752
  }
665
753
  async _hydratePeersFromStateFile() {
666
754
  try {
@@ -699,6 +787,14 @@ export class BuyerProxy {
699
787
  unwatchFile(this._stateFile);
700
788
  this._stateFileWatching = false;
701
789
  }
790
+ if (this._configWatchDebounce) {
791
+ clearTimeout(this._configWatchDebounce);
792
+ this._configWatchDebounce = null;
793
+ }
794
+ if (this._configFileWatching && this._configPath) {
795
+ unwatchFile(this._configPath);
796
+ this._configFileWatching = false;
797
+ }
702
798
  if (this._bgRefreshHandle) {
703
799
  clearInterval(this._bgRefreshHandle);
704
800
  this._bgRefreshHandle = null;
@@ -747,13 +843,51 @@ export class BuyerProxy {
747
843
  this._pinnedPeer = pinnedPeer;
748
844
  }
749
845
  const routedModel = typeof parsed.defaultRoutedModel === 'string' ? parsed.defaultRoutedModel.trim() : '';
750
- this._defaultRoutedModel = parsePeerPinnedService(routedModel) ? routedModel : null;
846
+ this._defaultRoutedModel = routedModel.length > 0 && isValidRoutedModelTarget(routedModel) ? routedModel : null;
751
847
  log(`Session overrides reloaded: peer=${this._pinnedPeer ?? 'none'} route=${this._defaultRoutedModel ?? 'none'}`);
752
848
  }
753
849
  catch {
754
850
  // state file unreadable; keep current values
755
851
  }
756
852
  }
853
+ _watchConfigFile() {
854
+ if (!this._configPath)
855
+ return;
856
+ try {
857
+ watchFile(this._configPath, { persistent: false, interval: 500 }, (curr, prev) => {
858
+ if (curr.mtimeMs === prev.mtimeMs && curr.ino === prev.ino)
859
+ return;
860
+ if (this._configWatchDebounce)
861
+ clearTimeout(this._configWatchDebounce);
862
+ this._configWatchDebounce = setTimeout(() => {
863
+ this._configWatchDebounce = null;
864
+ void this._reloadRoutingPreferences().catch(() => { });
865
+ }, 50);
866
+ });
867
+ this._configFileWatching = true;
868
+ }
869
+ catch {
870
+ // Config watcher failure is non-fatal; startup preferences remain active.
871
+ }
872
+ }
873
+ async _reloadRoutingPreferences() {
874
+ if (!this._configPath)
875
+ return;
876
+ try {
877
+ const config = await loadConfig(this._configPath);
878
+ const next = config.buyer.routingPreferences;
879
+ this._routingPreferences = {
880
+ ...next,
881
+ allowedPeerIds: [...next.allowedPeerIds],
882
+ blockedPeerIds: [...next.blockedPeerIds],
883
+ };
884
+ log(`Routing preferences reloaded: minTrust=${next.minTrustScore} maxInput=${next.maxInputUsdPerMillion} `
885
+ + `preferFree=${next.preferFreePeers} allow=${next.allowedPeerIds.length} block=${next.blockedPeerIds.length}`);
886
+ }
887
+ catch (err) {
888
+ log(`Routing preferences reload ignored: ${err instanceof Error ? err.message : String(err)}`);
889
+ }
890
+ }
757
891
  /** Stamp the last model-request activity time (dispatch or streamed frame). */
758
892
  _markModelActivity() {
759
893
  this._lastModelActivityAt = Date.now();
@@ -1309,9 +1443,9 @@ export class BuyerProxy {
1309
1443
  res.end(JSON.stringify({ ok: false, error: 'Invalid JSON body' }));
1310
1444
  return;
1311
1445
  }
1312
- if (model.length > 0 && !parsePeerPinnedService(model)) {
1446
+ if (model.length > 0 && !isValidRoutedModelTarget(model)) {
1313
1447
  res.writeHead(400, { 'content-type': 'application/json' });
1314
- res.end(JSON.stringify({ ok: false, error: 'model must be "<peerId>@<service>" (or empty to clear)' }));
1448
+ res.end(JSON.stringify({ ok: false, error: 'model must be "<service>", "<peerId>@<service>", or empty to clear' }));
1315
1449
  return;
1316
1450
  }
1317
1451
  this._defaultRoutedModel = model.length > 0 ? model : null;
@@ -1326,6 +1460,24 @@ export class BuyerProxy {
1326
1460
  res.end(JSON.stringify({ ok: true, conversations: this._conversations.list() }));
1327
1461
  return;
1328
1462
  }
1463
+ const conversationMatch = path.match(/^\/_antseed\/conversations\/(.+)$/);
1464
+ if (conversationMatch && method === 'GET') {
1465
+ let id = '';
1466
+ try {
1467
+ id = decodeURIComponent(conversationMatch[1] ?? '');
1468
+ }
1469
+ catch {
1470
+ res.writeHead(400, { 'content-type': 'application/json' });
1471
+ res.end(JSON.stringify({ ok: false, error: 'Invalid conversation id' }));
1472
+ return;
1473
+ }
1474
+ const conversation = this._conversations.get(id);
1475
+ res.writeHead(conversation ? 200 : 404, { 'content-type': 'application/json' });
1476
+ res.end(JSON.stringify(conversation
1477
+ ? { ok: true, conversation }
1478
+ : { ok: false, error: 'Unknown conversation' }));
1479
+ return;
1480
+ }
1329
1481
  if (path === '/_antseed/conversations/update' && method === 'POST') {
1330
1482
  const chunks = [];
1331
1483
  let totalSize = 0;
@@ -1367,9 +1519,9 @@ export class BuyerProxy {
1367
1519
  }
1368
1520
  if ('pinnedModel' in parsed) {
1369
1521
  const pin = typeof parsed.pinnedModel === 'string' ? parsed.pinnedModel.trim() : '';
1370
- if (pin.length > 0 && !parsePeerPinnedService(pin)) {
1522
+ if (pin.length > 0 && !isValidRoutedModelTarget(pin)) {
1371
1523
  res.writeHead(400, { 'content-type': 'application/json' });
1372
- res.end(JSON.stringify({ ok: false, error: 'pinnedModel must be "<peerId>@<service>" (or empty to clear)' }));
1524
+ res.end(JSON.stringify({ ok: false, error: 'pinnedModel must be "<service>", "<peerId>@<service>", or empty to clear' }));
1373
1525
  return;
1374
1526
  }
1375
1527
  // 'user' marks a seller the user chose for this specific chat — the
@@ -1546,6 +1698,62 @@ export class BuyerProxy {
1546
1698
  }
1547
1699
  return;
1548
1700
  }
1701
+ // Hot-wallet deposit watcher (auto-sweep). The desktop and `antseed
1702
+ // deposit` drive the daemon's single watcher through these instead of
1703
+ // running a second signer against the same wallet.
1704
+ if (path === '/_antseed/deposits/status' && method === 'GET') {
1705
+ res.writeHead(200, { 'content-type': 'application/json' });
1706
+ res.end(JSON.stringify({
1707
+ ok: true,
1708
+ watcher: this._depositWatcher !== null,
1709
+ status: this._depositWatcher?.status() ?? null,
1710
+ }));
1711
+ return;
1712
+ }
1713
+ if (path === '/_antseed/deposits/watch' && method === 'POST') {
1714
+ const chunks = [];
1715
+ let totalSize = 0;
1716
+ for await (const chunk of req) {
1717
+ totalSize += chunk.length;
1718
+ if (totalSize > 1024) {
1719
+ res.writeHead(413, { 'content-type': 'application/json' });
1720
+ res.end(JSON.stringify({ ok: false, error: 'Request body too large' }));
1721
+ return;
1722
+ }
1723
+ chunks.push(chunk);
1724
+ }
1725
+ let mode;
1726
+ try {
1727
+ const body = JSON.parse(Buffer.concat(chunks).toString() || '{}');
1728
+ mode = String(body.mode ?? 'active');
1729
+ }
1730
+ catch {
1731
+ res.writeHead(400, { 'content-type': 'application/json' });
1732
+ res.end(JSON.stringify({ ok: false, error: 'Invalid JSON body' }));
1733
+ return;
1734
+ }
1735
+ if (mode !== 'active' && mode !== 'background') {
1736
+ res.writeHead(400, { 'content-type': 'application/json' });
1737
+ res.end(JSON.stringify({ ok: false, error: 'mode must be "active" or "background"' }));
1738
+ return;
1739
+ }
1740
+ const watcher = this._depositWatcher;
1741
+ if (!watcher) {
1742
+ res.writeHead(503, { 'content-type': 'application/json' });
1743
+ res.end(JSON.stringify({
1744
+ ok: false,
1745
+ error: 'Deposit watcher unavailable — payments are disabled or this chain has no deposit relay.',
1746
+ }));
1747
+ return;
1748
+ }
1749
+ if (mode === 'active')
1750
+ watcher.promote();
1751
+ else
1752
+ watcher.demote();
1753
+ res.writeHead(200, { 'content-type': 'application/json' });
1754
+ res.end(JSON.stringify({ ok: true, status: watcher.status() }));
1755
+ return;
1756
+ }
1549
1757
  const sweepReceiptMatch = path.match(/^\/_antseed\/sweep\/(0x[0-9a-fA-F]{64})$/);
1550
1758
  if (sweepReceiptMatch && method === 'GET') {
1551
1759
  const receipt = this._sweepReceipts.get(sweepReceiptMatch[1].toLowerCase()) ?? null;
@@ -1556,6 +1764,63 @@ export class BuyerProxy {
1556
1764
  res.writeHead(404, { 'content-type': 'application/json' });
1557
1765
  res.end(JSON.stringify({ ok: false, error: 'Unknown control-plane endpoint' }));
1558
1766
  }
1767
+ /**
1768
+ * GET /v1/models[?type=text|images] and GET /v1/models/:id — answered
1769
+ * locally from the discovered-peer cache, aggregated across the network. The
1770
+ * `x-antseed-request-id` response header keeps the port-reuse probe in
1771
+ * `buyer start` recognizing this as an AntSeed proxy.
1772
+ */
1773
+ async _handleNetworkModels(res, rawPath) {
1774
+ const url = new URL(rawPath, 'http://localhost');
1775
+ const responseHeaders = { 'content-type': 'application/json', 'x-antseed-request-id': randomUUID() };
1776
+ const peers = await this._getPeers();
1777
+ const models = buildNetworkModels(peers, this._now(), {
1778
+ routingPreferences: this._routingPreferences,
1779
+ peerHealth: this._peerHealth,
1780
+ });
1781
+ const modelIdRaw = url.pathname.replace(/^\/v1\/models\/?/i, '');
1782
+ if (modelIdRaw.length > 0) {
1783
+ let modelId;
1784
+ try {
1785
+ modelId = decodeURIComponent(modelIdRaw).trim();
1786
+ }
1787
+ catch {
1788
+ modelId = modelIdRaw.trim();
1789
+ }
1790
+ const modelKey = canonicalModelKey(modelId);
1791
+ const model = models.find((entry) => canonicalModelKey(entry.id) === modelKey);
1792
+ if (!model) {
1793
+ res.writeHead(404, responseHeaders);
1794
+ res.end(JSON.stringify({
1795
+ error: {
1796
+ message: `Model "${modelId}" was not found on the network.`,
1797
+ type: 'invalid_request_error',
1798
+ code: 'model_not_found',
1799
+ },
1800
+ }));
1801
+ return;
1802
+ }
1803
+ res.writeHead(200, responseHeaders);
1804
+ res.end(JSON.stringify(model));
1805
+ return;
1806
+ }
1807
+ const typeFilter = parseModelTypeFilter(url.searchParams.get('type'));
1808
+ if (typeFilter === 'invalid') {
1809
+ res.writeHead(400, responseHeaders);
1810
+ res.end(JSON.stringify({
1811
+ error: {
1812
+ message: `Unknown model type "${url.searchParams.get('type') ?? ''}" — expected "text" or "images".`,
1813
+ type: 'invalid_request_error',
1814
+ param: 'type',
1815
+ },
1816
+ }));
1817
+ return;
1818
+ }
1819
+ const data = typeFilter === 'all' ? models : models.filter((entry) => entry.type === typeFilter);
1820
+ log(`GET /v1/models answered locally: ${data.length} models across ${peers.length} peers${typeFilter ? ` (type=${typeFilter})` : ''}`);
1821
+ res.writeHead(200, responseHeaders);
1822
+ res.end(JSON.stringify({ object: 'list', data }));
1823
+ }
1559
1824
  async _handleRequest(req, res) {
1560
1825
  const method = req.method ?? 'GET';
1561
1826
  const path = req.url ?? '/';
@@ -1577,6 +1842,13 @@ export class BuyerProxy {
1577
1842
  res.end(JSON.stringify({ error: { message: 'Not found', type: 'invalid_request_error' } }));
1578
1843
  return;
1579
1844
  }
1845
+ // `/v1/models` is answered locally from the discovered-peer cache: one
1846
+ // entry per model across the whole network, with the peers serving it.
1847
+ // Routed to a single pinned seller it would only cover that seller's
1848
+ // services — and would require a pin just to browse the network.
1849
+ if (method === 'GET' && normalizedPath.startsWith('/v1/models')) {
1850
+ return this._handleNetworkModels(res, path);
1851
+ }
1580
1852
  // Collect request body
1581
1853
  const chunks = [];
1582
1854
  for await (const chunk of req) {
@@ -1622,12 +1894,10 @@ export class BuyerProxy {
1622
1894
  // _reloadSessionOverrides() cannot change routing mid-request.
1623
1895
  const effectivePinnedPeer = this._pinnedPeer;
1624
1896
  // Per-chat routing: completion requests carry a stable per-conversation
1625
- // identity (see conversation-identity.ts). A chat pinned to a model
1626
- // overrides the session default when resolving the `antseed` alias;
1627
- // subagent sessions inherit their parent chat's pin. The default route
1628
- // only steers a chat's first request — the model that serves it becomes
1629
- // the chat's own pin (ConversationStore.touch), so changing the default
1630
- // later applies to new chats only.
1897
+ // identity (see conversation-identity.ts). Explicit chat pins remain hard;
1898
+ // automatically selected routes become soft affinity, so later turns stay
1899
+ // on the same seller unless it is cooling, unavailable, or fails retryably.
1900
+ // Subagent sessions inherit their parent chat's route.
1631
1901
  const isConversationRequest = method === 'POST' && isCompletionRequestPath(path);
1632
1902
  const conversationBody = isConversationRequest
1633
1903
  ? parseRequestBodyObject(serializedReq.body, serializedReq.headers)
@@ -1638,13 +1908,22 @@ export class BuyerProxy {
1638
1908
  // Internal marker from the system proxy: source profile used only for
1639
1909
  // local conversation attribution. Stripped here so it never reaches a seller.
1640
1910
  delete serializedReq.headers[SYSTEM_PROXY_SOURCE_HEADER];
1641
- const chatPinnedModel = conversationIdentity
1642
- ? this._conversations.getPinnedModel(conversationIdentity.tool, conversationIdentity.sessionKey)
1643
- ?? (conversationIdentity.parentSessionKey
1644
- ? this._conversations.getPinnedModel(conversationIdentity.tool, conversationIdentity.parentSessionKey)
1645
- : null)
1911
+ const trackedConversationKey = conversationIdentity
1912
+ ? conversationIdentity.parentSessionKey ?? conversationIdentity.sessionKey
1913
+ : null;
1914
+ const storedConversation = conversationIdentity && trackedConversationKey
1915
+ ? this._conversations.get(`${conversationIdentity.tool}:${trackedConversationKey}`)
1646
1916
  : null;
1917
+ const storedAutoRoute = storedConversation?.peerSource === 'auto' && storedConversation.pinnedModel
1918
+ ? parsePeerPinnedService(storedConversation.pinnedModel)
1919
+ : null;
1920
+ const chatPinnedModel = storedConversation?.peerSource === 'user'
1921
+ ? storedConversation.pinnedModel
1922
+ : storedAutoRoute?.service ?? storedConversation?.pinnedModel ?? null;
1923
+ const preferredPeerHeader = normalizePeerId(serializedReq.headers['x-antseed-prefer-peer'] ?? '');
1924
+ const preferredConversationPeerId = preferredPeerHeader ?? storedAutoRoute?.peerId ?? null;
1647
1925
  const effectiveRoutedModel = chatPinnedModel ?? this._defaultRoutedModel;
1926
+ let trackedConversationId = storedConversation?.id ?? null;
1648
1927
  // Resolve the `antseed` model alias to the session's default route first,
1649
1928
  // so the regular `<peerId>@<service>` pin rewrite below picks up the
1650
1929
  // substituted value. Tool configs written by the desktop carry the alias
@@ -1657,7 +1936,7 @@ export class BuyerProxy {
1657
1936
  error: {
1658
1937
  type: 'no_default_route',
1659
1938
  code: 'no_default_route',
1660
- message: `Model "${ROUTED_MODEL_ALIAS}" routes to the model selected in AntStation, but no route is set. `
1939
+ message: `Model "${ROUTED_MODEL_ALIAS}" routes to the model selected in VPR, but no route is set. `
1661
1940
  + 'Pick a model in the desktop app, or request "<peerId>@<model>" explicitly.',
1662
1941
  param: 'model',
1663
1942
  },
@@ -1713,6 +1992,13 @@ export class BuyerProxy {
1713
1992
  snippet,
1714
1993
  lastModel: titleTurn ? null : resolvedModel,
1715
1994
  });
1995
+ const explicitConversationPin = resolvedModel && (parsePeerPinnedService(rawModel)
1996
+ || (aliasResult.substituted && parsePeerPinnedService(effectiveRoutedModel ?? ''))
1997
+ || (chatPinOverrideApplied && parsePeerPinnedService(chatPinnedModel ?? '')));
1998
+ if (explicitConversationPin) {
1999
+ this._conversations.setPinnedModel(tracked.id, resolvedModel, 'user');
2000
+ }
2001
+ trackedConversationId = tracked.id;
1716
2002
  // Bind the request to the chat so its cost can be attributed when the
1717
2003
  // payment layer signs for it (see _attributeSpend).
1718
2004
  this._trackRequestConversation(serializedReq.requestId, tracked.id);
@@ -1749,21 +2035,10 @@ export class BuyerProxy {
1749
2035
  const explicitProvider = getExplicitProviderOverride(serializedReq);
1750
2036
  const explicitPeerId = getExplicitPeerIdOverride(serializedReq, effectivePinnedPeer ?? undefined, bodyPinnedPeer);
1751
2037
  log(`Routing hints: provider=${explicitProvider ?? 'auto'} pin-peer=${explicitPeerId ?? 'none'}`);
1752
- // Auto peer selection is disabled. Every request MUST target a specific
1753
- // peer, either via the per-request `x-antseed-pin-peer` header, a
1754
- // `<peerId>@<model>` model prefix, or a session-wide pin set by
1755
- // `antseed buyer connection set --peer <peerId>`.
1756
- //
1757
- // Surface the error in the structured shape OpenAI/Anthropic SDKs expect
1758
- // (`{ error: { type, code, message, ... } }`) so callers see a proper
1759
- // .message on their error objects instead of a raw text/plain body. We
1760
- // use HTTP 400 — the request is missing required information the buyer
1761
- // cannot infer on its own — which is what SDK retry/error logic treats
1762
- // as a non-retryable client mistake.
1763
- if (!explicitPeerId) {
1764
- log('Request rejected: no peer pinned');
1765
- const errorMessage = 'No peer pinned. Auto-selection is disabled.\n'
1766
- + 'Pin a peer one of three ways:\n'
2038
+ if (!explicitPeerId && !requestedService) {
2039
+ log('Request rejected: no peer pinned and no model requested');
2040
+ const errorMessage = 'No model or peer was specified.\n'
2041
+ + 'Set the request model, or pin a peer one of three ways:\n'
1767
2042
  + ' • Per-request header: x-antseed-pin-peer: <peerId> (40-char hex EVM address)\n'
1768
2043
  + ' • Model name prefix: <peerId>@<model>\n'
1769
2044
  + ' • Session pin: antseed buyer connection set --peer <peerId>\n'
@@ -1771,10 +2046,10 @@ export class BuyerProxy {
1771
2046
  res.writeHead(400, { 'content-type': 'application/json' });
1772
2047
  res.end(JSON.stringify({
1773
2048
  error: {
1774
- type: 'no_peer_pinned',
1775
- code: 'no_peer_pinned',
2049
+ type: 'missing_routing_target',
2050
+ code: 'missing_routing_target',
1776
2051
  message: errorMessage,
1777
- param: 'x-antseed-pin-peer',
2052
+ param: 'model',
1778
2053
  help: {
1779
2054
  perRequestHeader: 'x-antseed-pin-peer: <peerId>',
1780
2055
  modelPrefix: '<peerId>@<model>',
@@ -1793,6 +2068,146 @@ export class BuyerProxy {
1793
2068
  res.end('No sellers available on the network. Is a seeder running?');
1794
2069
  return;
1795
2070
  }
2071
+ if (!explicitPeerId && requestedService) {
2072
+ const selectModelPeers = (candidateSources) => selectCandidatePeersForRouting(candidateSources, requestProtocol, requestedService, explicitProvider, 'strict');
2073
+ let discoveredPeers = peers;
2074
+ let { candidatePeers: modelPeers, routePlanByPeerId: modelPlans } = selectModelPeers(discoveredPeers);
2075
+ const cacheAgeMs = Date.now() - this._cacheLastUpdatedAtMs;
2076
+ if (modelPeers.length === 0 || cacheAgeMs > this._peerCacheTtlMs) {
2077
+ discoveredPeers = await this._getPeers({ forceRefresh: true });
2078
+ ({ candidatePeers: modelPeers, routePlanByPeerId: modelPlans } = selectModelPeers(discoveredPeers));
2079
+ }
2080
+ const router = this._node.router;
2081
+ const policyRouter = router;
2082
+ const routeCandidates = modelPeers
2083
+ .map((peer) => {
2084
+ const plan = modelPlans.get(peer.peerId)
2085
+ ?? resolvePeerRoutePlan(peer, requestProtocol, requestedService, explicitProvider, 'strict');
2086
+ if (!plan?.serviceId)
2087
+ return null;
2088
+ const offer = findAdvertisedServiceOffer(peer, plan.provider, plan.serviceId);
2089
+ if (!offer)
2090
+ return null;
2091
+ const requestForPolicy = withRoutedModel(serializedReq, plan.serviceId);
2092
+ if (!peerAllowedByPolicy(policyRouter, requestForPolicy, peer))
2093
+ return null;
2094
+ return {
2095
+ peer,
2096
+ serviceId: plan.serviceId,
2097
+ request: requestForPolicy,
2098
+ reputation: normalizedModelReputationScore(peer, this._now()) ?? -1,
2099
+ hasCachedInputPricing: offer.cachedInputUsdPerMillion !== undefined,
2100
+ inputUsdPerMillion: offer.inputUsdPerMillion ?? null,
2101
+ outputUsdPerMillion: offer.outputUsdPerMillion ?? null,
2102
+ minImageUsdPerImage: offer.minImageUsdPerImage ?? null,
2103
+ };
2104
+ })
2105
+ .filter((candidate) => candidate !== null);
2106
+ const now = this._now();
2107
+ const preferCachedPricing = routeCandidates.some((candidate) => candidate.hasCachedInputPricing);
2108
+ const ranked = routeCandidates.map((candidate) => {
2109
+ const health = this._peerHealth.get(candidate.peer.peerId);
2110
+ return {
2111
+ ...candidate,
2112
+ peerId: candidate.peer.peerId,
2113
+ effectiveReputationScore: effectiveModelReputationScore(candidate.reputation >= 0 ? candidate.reputation : null, candidate.hasCachedInputPricing, preferCachedPricing),
2114
+ peerCooldownUntil: health?.cooldownUntil ?? null,
2115
+ peerFailureStreak: health?.failureStreak ?? 0,
2116
+ };
2117
+ });
2118
+ let candidates;
2119
+ const routingPreferences = this._routingPreferences;
2120
+ if (routingPreferences) {
2121
+ candidates = rankModelRoutes(ranked, routingPreferences, now)
2122
+ .filter((candidate) => isModelRouteEligible(candidate, routingPreferences));
2123
+ }
2124
+ else {
2125
+ ranked.sort((a, b) => (b.effectiveReputationScore ?? -1) - (a.effectiveReputationScore ?? -1)
2126
+ || a.peer.peerId.localeCompare(b.peer.peerId));
2127
+ const ready = ranked.filter((candidate) => !isCoolingDown(this._peerHealth.get(candidate.peer.peerId), now));
2128
+ candidates = ready.length > 0 ? ready : ranked;
2129
+ }
2130
+ if (preferredConversationPeerId) {
2131
+ const preferredIndex = candidates.findIndex((candidate) => (candidate.peer.peerId.toLowerCase() === preferredConversationPeerId
2132
+ && !isCoolingDown(this._peerHealth.get(candidate.peer.peerId), now)));
2133
+ if (preferredIndex > 0) {
2134
+ const [preferred] = candidates.splice(preferredIndex, 1);
2135
+ if (preferred)
2136
+ candidates.unshift(preferred);
2137
+ }
2138
+ }
2139
+ if (candidates.length === 0) {
2140
+ res.writeHead(502, { 'content-type': 'application/json' });
2141
+ res.end(JSON.stringify({
2142
+ error: {
2143
+ type: 'model_not_found',
2144
+ code: 'model_not_found',
2145
+ message: `No policy-allowed peer currently serves model "${requestedService}".`,
2146
+ param: 'model',
2147
+ },
2148
+ }));
2149
+ return;
2150
+ }
2151
+ let lastRetry = null;
2152
+ let lastVerificationError = null;
2153
+ for (const [index, selected] of candidates.entries()) {
2154
+ if (this._verifier) {
2155
+ const makeReach = (chosenId) => makeVerifierReach(this._node, selected.peer, chosenId, clientAbortController.signal);
2156
+ const outcome = await this._verifyPeer(selected.peer, makeReach, clientAbortController.signal);
2157
+ if (!outcome.ok) {
2158
+ lastVerificationError = `Peer ${selected.peer.peerId.slice(0, 12)}... failed required verification (${outcome.reason ?? 'failed'}).`;
2159
+ log(`${lastVerificationError} Trying the next model peer.`);
2160
+ continue;
2161
+ }
2162
+ }
2163
+ for (let peerAttempt = 0; peerAttempt < MODEL_RATE_LIMIT_MAX_ATTEMPTS_PER_PEER; peerAttempt += 1) {
2164
+ log(`Auto-selected peer ${selected.peer.peerId.slice(0, 12)}... for model="${requestedService}" `
2165
+ + `service="${selected.serviceId}" reputation=${selected.reputation} `
2166
+ + `effective=${selected.effectiveReputationScore ?? 'unknown'} peer=${index + 1}/${candidates.length} `
2167
+ + `attempt=${peerAttempt + 1}/${MODEL_RATE_LIMIT_MAX_ATTEMPTS_PER_PEER}`);
2168
+ const result = await this._dispatchToPeer(res, selected.request, selected.peer, modelPlans, requestProtocol, selected.serviceId, explicitProvider, router, RETRYABLE_STATUS_CODES, clientAbortController.signal);
2169
+ if (result.done) {
2170
+ if (trackedConversationId) {
2171
+ this._conversations.recordRoutedModel(trackedConversationId, `${selected.peer.peerId}@${selected.serviceId}`);
2172
+ }
2173
+ return;
2174
+ }
2175
+ lastRetry = result;
2176
+ if (result.responseHeaders[ANTSEED_FAULT_ATTRIBUTION_HEADER]?.toLowerCase() === 'buyer') {
2177
+ res.writeHead(result.statusCode, result.responseHeaders);
2178
+ res.end(result.responseBody);
2179
+ return;
2180
+ }
2181
+ const retrySamePeer = result.statusCode === 429
2182
+ && peerAttempt + 1 < MODEL_RATE_LIMIT_MAX_ATTEMPTS_PER_PEER;
2183
+ if (!retrySamePeer)
2184
+ break;
2185
+ const delayMs = rateLimitRetryDelayMs(result.responseHeaders, peerAttempt);
2186
+ log(`Peer ${selected.peer.peerId.slice(0, 12)}... is rate-limited; retrying in ${delayMs}ms.`);
2187
+ if (!await waitForRetry(delayMs, clientAbortController.signal))
2188
+ return;
2189
+ }
2190
+ if (index + 1 < candidates.length) {
2191
+ log(`Peer ${selected.peer.peerId.slice(0, 12)}... failed retryably; trying next model peer.`);
2192
+ }
2193
+ }
2194
+ if (lastRetry) {
2195
+ res.writeHead(lastRetry.statusCode, lastRetry.responseHeaders);
2196
+ res.end(lastRetry.responseBody);
2197
+ }
2198
+ else {
2199
+ res.writeHead(502, { 'content-type': 'application/json' });
2200
+ res.end(JSON.stringify({
2201
+ error: {
2202
+ type: 'peer_verification_failed',
2203
+ code: 'peer_verification_failed',
2204
+ message: lastVerificationError ?? `No verified peer currently serves model "${requestedService}".`,
2205
+ },
2206
+ }));
2207
+ }
2208
+ return;
2209
+ }
2210
+ const pinnedPeerId = explicitPeerId;
1796
2211
  // Narrow the candidate set to just the pinned peer (if we already know
1797
2212
  // about it) before running the per-peer protocol/service match. This
1798
2213
  // avoids wasting work — and spamming "Service strict-miss" log lines —
@@ -1800,7 +2215,7 @@ export class BuyerProxy {
1800
2215
  // fall through with the full list so the "not in candidate set → force
1801
2216
  // refresh" path still works.
1802
2217
  const narrowToPinned = (sources) => {
1803
- const match = sources.find((p) => p.peerId.toLowerCase() === explicitPeerId);
2218
+ const match = sources.find((p) => p.peerId.toLowerCase() === pinnedPeerId);
1804
2219
  return match ? [match] : sources;
1805
2220
  };
1806
2221
  const selectPeers = (candidateSources) => selectCandidatePeersForRouting(narrowToPinned(candidateSources), requestProtocol, requestedService, explicitProvider, 'lenient');
@@ -1821,7 +2236,7 @@ export class BuyerProxy {
1821
2236
  let routingPeers = candidatePeers;
1822
2237
  let routingPlans = routePlanByPeerId;
1823
2238
  let discoveredPeers = peers;
1824
- const isPinnedDiscovered = () => discoveredPeers.some((peer) => peer.peerId.toLowerCase() === explicitPeerId);
2239
+ const isPinnedDiscovered = () => discoveredPeers.some((peer) => peer.peerId.toLowerCase() === pinnedPeerId);
1825
2240
  // Single refresh guard covers all three doubts about the cache: pin
1826
2241
  // missing, candidate filter empty, or cache past TTL.
1827
2242
  const cacheAgeMs = Date.now() - this._cacheLastUpdatedAtMs;
@@ -1837,16 +2252,16 @@ export class BuyerProxy {
1837
2252
  ? 'model peer prefix'
1838
2253
  : '--peer flag or session pin';
1839
2254
  const diagnostics = this._formatPeerSelectionDiagnostics(discoveredPeers);
1840
- log(`Pinned peer ${explicitPeerId.slice(0, 12)}... not discoverable in DHT (${logSource})`);
2255
+ log(`Pinned peer ${pinnedPeerId.slice(0, 12)}... not discoverable in DHT (${logSource})`);
1841
2256
  res.writeHead(502, { 'content-type': 'text/plain' });
1842
- res.end(`Pinned peer ${explicitPeerId.slice(0, 12)}... is not reachable right now. `
2257
+ res.end(`Pinned peer ${pinnedPeerId.slice(0, 12)}... is not reachable right now. `
1843
2258
  + 'It may be offline, not announcing, or temporarily unreachable. '
1844
2259
  + 'Pick a different service in Discover or try again later. '
1845
2260
  + diagnostics);
1846
2261
  return;
1847
2262
  }
1848
2263
  if (routingPeers.length === 0) {
1849
- const pinnedPeer = discoveredPeers.find((peer) => peer.peerId.toLowerCase() === explicitPeerId) ?? null;
2264
+ const pinnedPeer = discoveredPeers.find((peer) => peer.peerId.toLowerCase() === pinnedPeerId) ?? null;
1850
2265
  const protocolLabel = requestProtocol ? `protocol=${requestProtocol}` : 'protocol=unknown';
1851
2266
  const providerLabel = explicitProvider ? `provider=${explicitProvider}` : 'provider=auto';
1852
2267
  const serviceLabel = requestedService ? `service=${requestedService}` : 'service=none';
@@ -1857,41 +2272,40 @@ export class BuyerProxy {
1857
2272
  .filter((provider) => provider.length > 0);
1858
2273
  if (!providers.includes(explicitProvider)) {
1859
2274
  const providerList = providers.length > 0 ? providers.join(', ') : 'none';
1860
- log(`Pinned peer ${explicitPeerId.slice(0, 12)}... does not offer explicit provider=${explicitProvider}`);
2275
+ log(`Pinned peer ${pinnedPeerId.slice(0, 12)}... does not offer explicit provider=${explicitProvider}`);
1861
2276
  res.writeHead(502, { 'content-type': 'text/plain' });
1862
- res.end(`Pinned peer ${explicitPeerId.slice(0, 12)}... does not offer provider=${explicitProvider}. `
2277
+ res.end(`Pinned peer ${pinnedPeerId.slice(0, 12)}... does not offer provider=${explicitProvider}. `
1863
2278
  + `Available providers: ${providerList}. `
1864
2279
  + 'Remove or change the x-antseed-provider header, or pick a different peer. '
1865
2280
  + diagnostics);
1866
2281
  return;
1867
2282
  }
1868
2283
  }
1869
- log(`Pinned peer ${explicitPeerId.slice(0, 12)}... filtered out by protocol/service match`);
2284
+ log(`Pinned peer ${pinnedPeerId.slice(0, 12)}... filtered out by protocol/service match`);
1870
2285
  res.writeHead(502, { 'content-type': 'text/plain' });
1871
- res.end(`Pinned peer ${explicitPeerId.slice(0, 12)}... does not support this request `
2286
+ res.end(`Pinned peer ${pinnedPeerId.slice(0, 12)}... does not support this request `
1872
2287
  + `(${protocolLabel}, ${providerLabel}, ${serviceLabel}). `
1873
2288
  + `Pick a different service in Discover. ${diagnostics}`);
1874
2289
  return;
1875
2290
  }
1876
2291
  log(`Routing candidates: ${routingPeers.length} peer(s)`);
1877
2292
  const router = this._node.router;
1878
- const selectedPeer = routingPeers.find((p) => p.peerId.toLowerCase() === explicitPeerId) ?? null;
2293
+ const selectedPeer = routingPeers.find((p) => p.peerId.toLowerCase() === pinnedPeerId) ?? null;
1879
2294
  // Defence in depth: the discovered+narrowed checks above should make this
1880
2295
  // branch unreachable. Keep a structured fallback so we don't silently hang
1881
2296
  // if an invariant breaks.
1882
2297
  if (!selectedPeer) {
1883
- log(`Invariant: pinned peer ${explicitPeerId.slice(0, 12)}... present in DHT but missing from narrowed candidate list`);
2298
+ log(`Invariant: pinned peer ${pinnedPeerId.slice(0, 12)}... present in DHT but missing from narrowed candidate list`);
1884
2299
  res.writeHead(502, { 'content-type': 'text/plain' });
1885
- res.end(`Pinned peer ${explicitPeerId.slice(0, 12)}... is currently unreachable. Try again in a moment.`);
2300
+ res.end(`Pinned peer ${pinnedPeerId.slice(0, 12)}... is currently unreachable. Try again in a moment.`);
1886
2301
  return;
1887
2302
  }
1888
2303
  const policyRouter = router;
1889
- const policyAllowed = policyRouter?.allowsPeerForPolicy
1890
- ? policyRouter.allowsPeerForPolicy(serializedReq, selectedPeer)
1891
- : policyRouter?.allowsPeerForPricing
1892
- ? policyRouter.allowsPeerForPricing(serializedReq, selectedPeer)
1893
- : true;
1894
- if (!policyAllowed) {
2304
+ const selectedPlan = routingPlans.get(selectedPeer.peerId)
2305
+ ?? resolvePeerRoutePlan(selectedPeer, requestProtocol, requestedService, explicitProvider, 'lenient');
2306
+ const pinnedServiceId = selectedPlan?.serviceId ?? requestedService;
2307
+ const pinnedRequest = pinnedServiceId ? withRoutedModel(serializedReq, pinnedServiceId) : serializedReq;
2308
+ if (!peerAllowedByPolicy(policyRouter, pinnedRequest, selectedPeer)) {
1895
2309
  log(`Pinned peer ${selectedPeer.peerId.slice(0, 12)}... filtered out by buyer routing policy`);
1896
2310
  res.writeHead(502, { 'content-type': 'text/plain' });
1897
2311
  res.end(`Pinned peer ${selectedPeer.peerId.slice(0, 12)}... is outside your buyer routing policy. `
@@ -1916,10 +2330,13 @@ export class BuyerProxy {
1916
2330
  }
1917
2331
  }
1918
2332
  log(`Using pinned peer ${selectedPeer.peerId.slice(0, 12)}...`);
1919
- const result = await this._dispatchToPeer(res, serializedReq, selectedPeer, routingPlans, requestProtocol, requestedService, explicitProvider, router, RETRYABLE_STATUS_CODES, clientAbortController.signal);
2333
+ const result = await this._dispatchToPeer(res, pinnedRequest, selectedPeer, routingPlans, requestProtocol, requestedService, explicitProvider, router, RETRYABLE_STATUS_CODES, clientAbortController.signal);
2334
+ if (result.done && trackedConversationId && pinnedServiceId) {
2335
+ this._conversations.recordRoutedModel(trackedConversationId, `${selectedPeer.peerId}@${pinnedServiceId}`);
2336
+ }
1920
2337
  if (!result.done) {
1921
2338
  // Pinned peer returned a retryable error. We never retry against another
1922
- // peer auto-selection is disabled so surface the error to the client.
2339
+ // peer for an explicit pin, so surface the error to the client.
1923
2340
  res.writeHead(result.statusCode, result.responseHeaders);
1924
2341
  res.end(result.responseBody);
1925
2342
  }
@@ -2001,7 +2418,9 @@ export class BuyerProxy {
2001
2418
  + `did not announce for this service: ${unannounced.join(', ')} — the upstream may ignore or reject them`);
2002
2419
  }
2003
2420
  }
2004
- const { 'x-antseed-pin-peer': _pinPeer, 'x-antseed-prefer-peer': _preferPeer, ...headersForPeer } = serializedReq.headers;
2421
+ const { 'x-antseed-pin-peer': _pinPeer, 'x-antseed-prefer-peer': _preferPeer, 'x-vpr-session-id': _vprSession,
2422
+ // Legacy desktop builds (pre AntStation → VPR rename) still send this.
2423
+ 'x-antstation-session-id': _antstationSession, ...headersForPeer } = serializedReq.headers;
2005
2424
  let requestForPeer = {
2006
2425
  ...serializedReq,
2007
2426
  headers: {
@@ -2009,6 +2428,9 @@ export class BuyerProxy {
2009
2428
  'x-antseed-provider': selectedRoutePlan.provider,
2010
2429
  },
2011
2430
  };
2431
+ if (selectedRoutePlan.serviceId) {
2432
+ requestForPeer = withRoutedModel(requestForPeer, selectedRoutePlan.serviceId);
2433
+ }
2012
2434
  const clientWantsStreaming = requestWantsStreaming(serializedReq.headers, serializedReq.body);
2013
2435
  let adaptResponse = null;
2014
2436
  let streamResponseAdapter = null;