@antseed/cli 0.1.147 → 0.1.149

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 (46) hide show
  1. package/README.md +18 -3
  2. package/dist/cli/commands/buyer/start.d.ts +1 -0
  3. package/dist/cli/commands/buyer/start.d.ts.map +1 -1
  4. package/dist/cli/commands/buyer/start.js +10 -9
  5. package/dist/cli/commands/buyer/start.js.map +1 -1
  6. package/dist/cli/commands/buyer/start.test.js +30 -1
  7. package/dist/cli/commands/buyer/start.test.js.map +1 -1
  8. package/dist/cli/commands/network/browse.d.ts.map +1 -1
  9. package/dist/cli/commands/network/browse.js +65 -114
  10. package/dist/cli/commands/network/browse.js.map +1 -1
  11. package/dist/config/defaults.d.ts.map +1 -1
  12. package/dist/config/defaults.js +6 -0
  13. package/dist/config/defaults.js.map +1 -1
  14. package/dist/config/loader.d.ts.map +1 -1
  15. package/dist/config/loader.js +39 -0
  16. package/dist/config/loader.js.map +1 -1
  17. package/dist/config/loader.test.js +34 -0
  18. package/dist/config/loader.test.js.map +1 -1
  19. package/dist/config/types.d.ts +3 -0
  20. package/dist/config/types.d.ts.map +1 -1
  21. package/dist/config/validation.d.ts.map +1 -1
  22. package/dist/config/validation.js +17 -0
  23. package/dist/config/validation.js.map +1 -1
  24. package/dist/proxy/buyer-proxy.d.ts +20 -2
  25. package/dist/proxy/buyer-proxy.d.ts.map +1 -1
  26. package/dist/proxy/buyer-proxy.js +408 -58
  27. package/dist/proxy/buyer-proxy.js.map +1 -1
  28. package/dist/proxy/buyer-proxy.test.js +828 -56
  29. package/dist/proxy/buyer-proxy.test.js.map +1 -1
  30. package/dist/proxy/conversation-store.d.ts +6 -6
  31. package/dist/proxy/conversation-store.d.ts.map +1 -1
  32. package/dist/proxy/conversation-store.js +16 -0
  33. package/dist/proxy/conversation-store.js.map +1 -1
  34. package/dist/proxy/network-models.d.ts +76 -0
  35. package/dist/proxy/network-models.d.ts.map +1 -0
  36. package/dist/proxy/network-models.js +229 -0
  37. package/dist/proxy/network-models.js.map +1 -0
  38. package/dist/proxy/network-models.test.d.ts +2 -0
  39. package/dist/proxy/network-models.test.d.ts.map +1 -0
  40. package/dist/proxy/network-models.test.js +734 -0
  41. package/dist/proxy/network-models.test.js.map +1 -0
  42. package/dist/proxy/routing.d.ts +3 -1
  43. package/dist/proxy/routing.d.ts.map +1 -1
  44. package/dist/proxy/routing.js +69 -16
  45. package/dist/proxy/routing.js.map +1 -1
  46. package/package.json +5 -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;
@@ -561,8 +626,16 @@ export class BuyerProxy {
561
626
  this._peerCacheTtlMs = Math.max(0, config.peerCacheTtlMs ?? Math.max(6 * 60_000, this._bgRefreshIntervalMs + 60_000));
562
627
  this._stateDir = config.dataDir;
563
628
  this._stateFile = join(config.dataDir, 'buyer.state.json');
629
+ this._configPath = config.configPath ?? null;
564
630
  this._conversations = new ConversationStore(config.dataDir);
565
631
  this._pinnedPeer = config.pinnedPeerId?.toLowerCase() ?? null;
632
+ this._routingPreferences = config.routingPreferences
633
+ ? {
634
+ ...config.routingPreferences,
635
+ allowedPeerIds: [...config.routingPreferences.allowedPeerIds],
636
+ blockedPeerIds: [...config.routingPreferences.blockedPeerIds],
637
+ }
638
+ : null;
566
639
  this._now = config.now ?? (() => Date.now());
567
640
  this._server = createServer((req, res) => {
568
641
  this._handleRequest(req, res).catch((err) => {
@@ -661,6 +734,7 @@ export class BuyerProxy {
661
734
  this._startIncrementalDiscoverySweep();
662
735
  await this._writeStateFile('connected');
663
736
  this._watchStateFile();
737
+ this._watchConfigFile();
664
738
  }
665
739
  async _hydratePeersFromStateFile() {
666
740
  try {
@@ -699,6 +773,14 @@ export class BuyerProxy {
699
773
  unwatchFile(this._stateFile);
700
774
  this._stateFileWatching = false;
701
775
  }
776
+ if (this._configWatchDebounce) {
777
+ clearTimeout(this._configWatchDebounce);
778
+ this._configWatchDebounce = null;
779
+ }
780
+ if (this._configFileWatching && this._configPath) {
781
+ unwatchFile(this._configPath);
782
+ this._configFileWatching = false;
783
+ }
702
784
  if (this._bgRefreshHandle) {
703
785
  clearInterval(this._bgRefreshHandle);
704
786
  this._bgRefreshHandle = null;
@@ -747,13 +829,51 @@ export class BuyerProxy {
747
829
  this._pinnedPeer = pinnedPeer;
748
830
  }
749
831
  const routedModel = typeof parsed.defaultRoutedModel === 'string' ? parsed.defaultRoutedModel.trim() : '';
750
- this._defaultRoutedModel = parsePeerPinnedService(routedModel) ? routedModel : null;
832
+ this._defaultRoutedModel = routedModel.length > 0 && isValidRoutedModelTarget(routedModel) ? routedModel : null;
751
833
  log(`Session overrides reloaded: peer=${this._pinnedPeer ?? 'none'} route=${this._defaultRoutedModel ?? 'none'}`);
752
834
  }
753
835
  catch {
754
836
  // state file unreadable; keep current values
755
837
  }
756
838
  }
839
+ _watchConfigFile() {
840
+ if (!this._configPath)
841
+ return;
842
+ try {
843
+ watchFile(this._configPath, { persistent: false, interval: 500 }, (curr, prev) => {
844
+ if (curr.mtimeMs === prev.mtimeMs && curr.ino === prev.ino)
845
+ return;
846
+ if (this._configWatchDebounce)
847
+ clearTimeout(this._configWatchDebounce);
848
+ this._configWatchDebounce = setTimeout(() => {
849
+ this._configWatchDebounce = null;
850
+ void this._reloadRoutingPreferences().catch(() => { });
851
+ }, 50);
852
+ });
853
+ this._configFileWatching = true;
854
+ }
855
+ catch {
856
+ // Config watcher failure is non-fatal; startup preferences remain active.
857
+ }
858
+ }
859
+ async _reloadRoutingPreferences() {
860
+ if (!this._configPath)
861
+ return;
862
+ try {
863
+ const config = await loadConfig(this._configPath);
864
+ const next = config.buyer.routingPreferences;
865
+ this._routingPreferences = {
866
+ ...next,
867
+ allowedPeerIds: [...next.allowedPeerIds],
868
+ blockedPeerIds: [...next.blockedPeerIds],
869
+ };
870
+ log(`Routing preferences reloaded: minTrust=${next.minTrustScore} maxInput=${next.maxInputUsdPerMillion} `
871
+ + `preferFree=${next.preferFreePeers} allow=${next.allowedPeerIds.length} block=${next.blockedPeerIds.length}`);
872
+ }
873
+ catch (err) {
874
+ log(`Routing preferences reload ignored: ${err instanceof Error ? err.message : String(err)}`);
875
+ }
876
+ }
757
877
  /** Stamp the last model-request activity time (dispatch or streamed frame). */
758
878
  _markModelActivity() {
759
879
  this._lastModelActivityAt = Date.now();
@@ -1309,9 +1429,9 @@ export class BuyerProxy {
1309
1429
  res.end(JSON.stringify({ ok: false, error: 'Invalid JSON body' }));
1310
1430
  return;
1311
1431
  }
1312
- if (model.length > 0 && !parsePeerPinnedService(model)) {
1432
+ if (model.length > 0 && !isValidRoutedModelTarget(model)) {
1313
1433
  res.writeHead(400, { 'content-type': 'application/json' });
1314
- res.end(JSON.stringify({ ok: false, error: 'model must be "<peerId>@<service>" (or empty to clear)' }));
1434
+ res.end(JSON.stringify({ ok: false, error: 'model must be "<service>", "<peerId>@<service>", or empty to clear' }));
1315
1435
  return;
1316
1436
  }
1317
1437
  this._defaultRoutedModel = model.length > 0 ? model : null;
@@ -1326,6 +1446,24 @@ export class BuyerProxy {
1326
1446
  res.end(JSON.stringify({ ok: true, conversations: this._conversations.list() }));
1327
1447
  return;
1328
1448
  }
1449
+ const conversationMatch = path.match(/^\/_antseed\/conversations\/(.+)$/);
1450
+ if (conversationMatch && method === 'GET') {
1451
+ let id = '';
1452
+ try {
1453
+ id = decodeURIComponent(conversationMatch[1] ?? '');
1454
+ }
1455
+ catch {
1456
+ res.writeHead(400, { 'content-type': 'application/json' });
1457
+ res.end(JSON.stringify({ ok: false, error: 'Invalid conversation id' }));
1458
+ return;
1459
+ }
1460
+ const conversation = this._conversations.get(id);
1461
+ res.writeHead(conversation ? 200 : 404, { 'content-type': 'application/json' });
1462
+ res.end(JSON.stringify(conversation
1463
+ ? { ok: true, conversation }
1464
+ : { ok: false, error: 'Unknown conversation' }));
1465
+ return;
1466
+ }
1329
1467
  if (path === '/_antseed/conversations/update' && method === 'POST') {
1330
1468
  const chunks = [];
1331
1469
  let totalSize = 0;
@@ -1367,9 +1505,9 @@ export class BuyerProxy {
1367
1505
  }
1368
1506
  if ('pinnedModel' in parsed) {
1369
1507
  const pin = typeof parsed.pinnedModel === 'string' ? parsed.pinnedModel.trim() : '';
1370
- if (pin.length > 0 && !parsePeerPinnedService(pin)) {
1508
+ if (pin.length > 0 && !isValidRoutedModelTarget(pin)) {
1371
1509
  res.writeHead(400, { 'content-type': 'application/json' });
1372
- res.end(JSON.stringify({ ok: false, error: 'pinnedModel must be "<peerId>@<service>" (or empty to clear)' }));
1510
+ res.end(JSON.stringify({ ok: false, error: 'pinnedModel must be "<service>", "<peerId>@<service>", or empty to clear' }));
1373
1511
  return;
1374
1512
  }
1375
1513
  // 'user' marks a seller the user chose for this specific chat — the
@@ -1556,6 +1694,63 @@ export class BuyerProxy {
1556
1694
  res.writeHead(404, { 'content-type': 'application/json' });
1557
1695
  res.end(JSON.stringify({ ok: false, error: 'Unknown control-plane endpoint' }));
1558
1696
  }
1697
+ /**
1698
+ * GET /v1/models[?type=text|images] and GET /v1/models/:id — answered
1699
+ * locally from the discovered-peer cache, aggregated across the network. The
1700
+ * `x-antseed-request-id` response header keeps the port-reuse probe in
1701
+ * `buyer start` recognizing this as an AntSeed proxy.
1702
+ */
1703
+ async _handleNetworkModels(res, rawPath) {
1704
+ const url = new URL(rawPath, 'http://localhost');
1705
+ const responseHeaders = { 'content-type': 'application/json', 'x-antseed-request-id': randomUUID() };
1706
+ const peers = await this._getPeers();
1707
+ const models = buildNetworkModels(peers, this._now(), {
1708
+ routingPreferences: this._routingPreferences,
1709
+ peerHealth: this._peerHealth,
1710
+ });
1711
+ const modelIdRaw = url.pathname.replace(/^\/v1\/models\/?/i, '');
1712
+ if (modelIdRaw.length > 0) {
1713
+ let modelId;
1714
+ try {
1715
+ modelId = decodeURIComponent(modelIdRaw).trim();
1716
+ }
1717
+ catch {
1718
+ modelId = modelIdRaw.trim();
1719
+ }
1720
+ const modelKey = canonicalModelKey(modelId);
1721
+ const model = models.find((entry) => canonicalModelKey(entry.id) === modelKey);
1722
+ if (!model) {
1723
+ res.writeHead(404, responseHeaders);
1724
+ res.end(JSON.stringify({
1725
+ error: {
1726
+ message: `Model "${modelId}" was not found on the network.`,
1727
+ type: 'invalid_request_error',
1728
+ code: 'model_not_found',
1729
+ },
1730
+ }));
1731
+ return;
1732
+ }
1733
+ res.writeHead(200, responseHeaders);
1734
+ res.end(JSON.stringify(model));
1735
+ return;
1736
+ }
1737
+ const typeFilter = parseModelTypeFilter(url.searchParams.get('type'));
1738
+ if (typeFilter === 'invalid') {
1739
+ res.writeHead(400, responseHeaders);
1740
+ res.end(JSON.stringify({
1741
+ error: {
1742
+ message: `Unknown model type "${url.searchParams.get('type') ?? ''}" — expected "text" or "images".`,
1743
+ type: 'invalid_request_error',
1744
+ param: 'type',
1745
+ },
1746
+ }));
1747
+ return;
1748
+ }
1749
+ const data = typeFilter === 'all' ? models : models.filter((entry) => entry.type === typeFilter);
1750
+ log(`GET /v1/models answered locally: ${data.length} models across ${peers.length} peers${typeFilter ? ` (type=${typeFilter})` : ''}`);
1751
+ res.writeHead(200, responseHeaders);
1752
+ res.end(JSON.stringify({ object: 'list', data }));
1753
+ }
1559
1754
  async _handleRequest(req, res) {
1560
1755
  const method = req.method ?? 'GET';
1561
1756
  const path = req.url ?? '/';
@@ -1577,6 +1772,13 @@ export class BuyerProxy {
1577
1772
  res.end(JSON.stringify({ error: { message: 'Not found', type: 'invalid_request_error' } }));
1578
1773
  return;
1579
1774
  }
1775
+ // `/v1/models` is answered locally from the discovered-peer cache: one
1776
+ // entry per model across the whole network, with the peers serving it.
1777
+ // Routed to a single pinned seller it would only cover that seller's
1778
+ // services — and would require a pin just to browse the network.
1779
+ if (method === 'GET' && normalizedPath.startsWith('/v1/models')) {
1780
+ return this._handleNetworkModels(res, path);
1781
+ }
1580
1782
  // Collect request body
1581
1783
  const chunks = [];
1582
1784
  for await (const chunk of req) {
@@ -1622,12 +1824,10 @@ export class BuyerProxy {
1622
1824
  // _reloadSessionOverrides() cannot change routing mid-request.
1623
1825
  const effectivePinnedPeer = this._pinnedPeer;
1624
1826
  // 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.
1827
+ // identity (see conversation-identity.ts). Explicit chat pins remain hard;
1828
+ // automatically selected routes become soft affinity, so later turns stay
1829
+ // on the same seller unless it is cooling, unavailable, or fails retryably.
1830
+ // Subagent sessions inherit their parent chat's route.
1631
1831
  const isConversationRequest = method === 'POST' && isCompletionRequestPath(path);
1632
1832
  const conversationBody = isConversationRequest
1633
1833
  ? parseRequestBodyObject(serializedReq.body, serializedReq.headers)
@@ -1638,13 +1838,22 @@ export class BuyerProxy {
1638
1838
  // Internal marker from the system proxy: source profile used only for
1639
1839
  // local conversation attribution. Stripped here so it never reaches a seller.
1640
1840
  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)
1841
+ const trackedConversationKey = conversationIdentity
1842
+ ? conversationIdentity.parentSessionKey ?? conversationIdentity.sessionKey
1646
1843
  : null;
1844
+ const storedConversation = conversationIdentity && trackedConversationKey
1845
+ ? this._conversations.get(`${conversationIdentity.tool}:${trackedConversationKey}`)
1846
+ : null;
1847
+ const storedAutoRoute = storedConversation?.peerSource === 'auto' && storedConversation.pinnedModel
1848
+ ? parsePeerPinnedService(storedConversation.pinnedModel)
1849
+ : null;
1850
+ const chatPinnedModel = storedConversation?.peerSource === 'user'
1851
+ ? storedConversation.pinnedModel
1852
+ : storedAutoRoute?.service ?? storedConversation?.pinnedModel ?? null;
1853
+ const preferredPeerHeader = normalizePeerId(serializedReq.headers['x-antseed-prefer-peer'] ?? '');
1854
+ const preferredConversationPeerId = preferredPeerHeader ?? storedAutoRoute?.peerId ?? null;
1647
1855
  const effectiveRoutedModel = chatPinnedModel ?? this._defaultRoutedModel;
1856
+ let trackedConversationId = storedConversation?.id ?? null;
1648
1857
  // Resolve the `antseed` model alias to the session's default route first,
1649
1858
  // so the regular `<peerId>@<service>` pin rewrite below picks up the
1650
1859
  // substituted value. Tool configs written by the desktop carry the alias
@@ -1713,6 +1922,13 @@ export class BuyerProxy {
1713
1922
  snippet,
1714
1923
  lastModel: titleTurn ? null : resolvedModel,
1715
1924
  });
1925
+ const explicitConversationPin = resolvedModel && (parsePeerPinnedService(rawModel)
1926
+ || (aliasResult.substituted && parsePeerPinnedService(effectiveRoutedModel ?? ''))
1927
+ || (chatPinOverrideApplied && parsePeerPinnedService(chatPinnedModel ?? '')));
1928
+ if (explicitConversationPin) {
1929
+ this._conversations.setPinnedModel(tracked.id, resolvedModel, 'user');
1930
+ }
1931
+ trackedConversationId = tracked.id;
1716
1932
  // Bind the request to the chat so its cost can be attributed when the
1717
1933
  // payment layer signs for it (see _attributeSpend).
1718
1934
  this._trackRequestConversation(serializedReq.requestId, tracked.id);
@@ -1749,21 +1965,10 @@ export class BuyerProxy {
1749
1965
  const explicitProvider = getExplicitProviderOverride(serializedReq);
1750
1966
  const explicitPeerId = getExplicitPeerIdOverride(serializedReq, effectivePinnedPeer ?? undefined, bodyPinnedPeer);
1751
1967
  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'
1968
+ if (!explicitPeerId && !requestedService) {
1969
+ log('Request rejected: no peer pinned and no model requested');
1970
+ const errorMessage = 'No model or peer was specified.\n'
1971
+ + 'Set the request model, or pin a peer one of three ways:\n'
1767
1972
  + ' • Per-request header: x-antseed-pin-peer: <peerId> (40-char hex EVM address)\n'
1768
1973
  + ' • Model name prefix: <peerId>@<model>\n'
1769
1974
  + ' • Session pin: antseed buyer connection set --peer <peerId>\n'
@@ -1771,10 +1976,10 @@ export class BuyerProxy {
1771
1976
  res.writeHead(400, { 'content-type': 'application/json' });
1772
1977
  res.end(JSON.stringify({
1773
1978
  error: {
1774
- type: 'no_peer_pinned',
1775
- code: 'no_peer_pinned',
1979
+ type: 'missing_routing_target',
1980
+ code: 'missing_routing_target',
1776
1981
  message: errorMessage,
1777
- param: 'x-antseed-pin-peer',
1982
+ param: 'model',
1778
1983
  help: {
1779
1984
  perRequestHeader: 'x-antseed-pin-peer: <peerId>',
1780
1985
  modelPrefix: '<peerId>@<model>',
@@ -1793,6 +1998,146 @@ export class BuyerProxy {
1793
1998
  res.end('No sellers available on the network. Is a seeder running?');
1794
1999
  return;
1795
2000
  }
2001
+ if (!explicitPeerId && requestedService) {
2002
+ const selectModelPeers = (candidateSources) => selectCandidatePeersForRouting(candidateSources, requestProtocol, requestedService, explicitProvider, 'strict');
2003
+ let discoveredPeers = peers;
2004
+ let { candidatePeers: modelPeers, routePlanByPeerId: modelPlans } = selectModelPeers(discoveredPeers);
2005
+ const cacheAgeMs = Date.now() - this._cacheLastUpdatedAtMs;
2006
+ if (modelPeers.length === 0 || cacheAgeMs > this._peerCacheTtlMs) {
2007
+ discoveredPeers = await this._getPeers({ forceRefresh: true });
2008
+ ({ candidatePeers: modelPeers, routePlanByPeerId: modelPlans } = selectModelPeers(discoveredPeers));
2009
+ }
2010
+ const router = this._node.router;
2011
+ const policyRouter = router;
2012
+ const routeCandidates = modelPeers
2013
+ .map((peer) => {
2014
+ const plan = modelPlans.get(peer.peerId)
2015
+ ?? resolvePeerRoutePlan(peer, requestProtocol, requestedService, explicitProvider, 'strict');
2016
+ if (!plan?.serviceId)
2017
+ return null;
2018
+ const offer = findAdvertisedServiceOffer(peer, plan.provider, plan.serviceId);
2019
+ if (!offer)
2020
+ return null;
2021
+ const requestForPolicy = withRoutedModel(serializedReq, plan.serviceId);
2022
+ if (!peerAllowedByPolicy(policyRouter, requestForPolicy, peer))
2023
+ return null;
2024
+ return {
2025
+ peer,
2026
+ serviceId: plan.serviceId,
2027
+ request: requestForPolicy,
2028
+ reputation: normalizedModelReputationScore(peer, this._now()) ?? -1,
2029
+ hasCachedInputPricing: offer.cachedInputUsdPerMillion !== undefined,
2030
+ inputUsdPerMillion: offer.inputUsdPerMillion ?? null,
2031
+ outputUsdPerMillion: offer.outputUsdPerMillion ?? null,
2032
+ minImageUsdPerImage: offer.minImageUsdPerImage ?? null,
2033
+ };
2034
+ })
2035
+ .filter((candidate) => candidate !== null);
2036
+ const now = this._now();
2037
+ const preferCachedPricing = routeCandidates.some((candidate) => candidate.hasCachedInputPricing);
2038
+ const ranked = routeCandidates.map((candidate) => {
2039
+ const health = this._peerHealth.get(candidate.peer.peerId);
2040
+ return {
2041
+ ...candidate,
2042
+ peerId: candidate.peer.peerId,
2043
+ effectiveReputationScore: effectiveModelReputationScore(candidate.reputation >= 0 ? candidate.reputation : null, candidate.hasCachedInputPricing, preferCachedPricing),
2044
+ peerCooldownUntil: health?.cooldownUntil ?? null,
2045
+ peerFailureStreak: health?.failureStreak ?? 0,
2046
+ };
2047
+ });
2048
+ let candidates;
2049
+ const routingPreferences = this._routingPreferences;
2050
+ if (routingPreferences) {
2051
+ candidates = rankModelRoutes(ranked, routingPreferences, now)
2052
+ .filter((candidate) => isModelRouteEligible(candidate, routingPreferences));
2053
+ }
2054
+ else {
2055
+ ranked.sort((a, b) => (b.effectiveReputationScore ?? -1) - (a.effectiveReputationScore ?? -1)
2056
+ || a.peer.peerId.localeCompare(b.peer.peerId));
2057
+ const ready = ranked.filter((candidate) => !isCoolingDown(this._peerHealth.get(candidate.peer.peerId), now));
2058
+ candidates = ready.length > 0 ? ready : ranked;
2059
+ }
2060
+ if (preferredConversationPeerId) {
2061
+ const preferredIndex = candidates.findIndex((candidate) => (candidate.peer.peerId.toLowerCase() === preferredConversationPeerId
2062
+ && !isCoolingDown(this._peerHealth.get(candidate.peer.peerId), now)));
2063
+ if (preferredIndex > 0) {
2064
+ const [preferred] = candidates.splice(preferredIndex, 1);
2065
+ if (preferred)
2066
+ candidates.unshift(preferred);
2067
+ }
2068
+ }
2069
+ if (candidates.length === 0) {
2070
+ res.writeHead(502, { 'content-type': 'application/json' });
2071
+ res.end(JSON.stringify({
2072
+ error: {
2073
+ type: 'model_not_found',
2074
+ code: 'model_not_found',
2075
+ message: `No policy-allowed peer currently serves model "${requestedService}".`,
2076
+ param: 'model',
2077
+ },
2078
+ }));
2079
+ return;
2080
+ }
2081
+ let lastRetry = null;
2082
+ let lastVerificationError = null;
2083
+ for (const [index, selected] of candidates.entries()) {
2084
+ if (this._verifier) {
2085
+ const makeReach = (chosenId) => makeVerifierReach(this._node, selected.peer, chosenId, clientAbortController.signal);
2086
+ const outcome = await this._verifyPeer(selected.peer, makeReach, clientAbortController.signal);
2087
+ if (!outcome.ok) {
2088
+ lastVerificationError = `Peer ${selected.peer.peerId.slice(0, 12)}... failed required verification (${outcome.reason ?? 'failed'}).`;
2089
+ log(`${lastVerificationError} Trying the next model peer.`);
2090
+ continue;
2091
+ }
2092
+ }
2093
+ for (let peerAttempt = 0; peerAttempt < MODEL_RATE_LIMIT_MAX_ATTEMPTS_PER_PEER; peerAttempt += 1) {
2094
+ log(`Auto-selected peer ${selected.peer.peerId.slice(0, 12)}... for model="${requestedService}" `
2095
+ + `service="${selected.serviceId}" reputation=${selected.reputation} `
2096
+ + `effective=${selected.effectiveReputationScore ?? 'unknown'} peer=${index + 1}/${candidates.length} `
2097
+ + `attempt=${peerAttempt + 1}/${MODEL_RATE_LIMIT_MAX_ATTEMPTS_PER_PEER}`);
2098
+ const result = await this._dispatchToPeer(res, selected.request, selected.peer, modelPlans, requestProtocol, selected.serviceId, explicitProvider, router, RETRYABLE_STATUS_CODES, clientAbortController.signal);
2099
+ if (result.done) {
2100
+ if (trackedConversationId) {
2101
+ this._conversations.recordRoutedModel(trackedConversationId, `${selected.peer.peerId}@${selected.serviceId}`);
2102
+ }
2103
+ return;
2104
+ }
2105
+ lastRetry = result;
2106
+ if (result.responseHeaders[ANTSEED_FAULT_ATTRIBUTION_HEADER]?.toLowerCase() === 'buyer') {
2107
+ res.writeHead(result.statusCode, result.responseHeaders);
2108
+ res.end(result.responseBody);
2109
+ return;
2110
+ }
2111
+ const retrySamePeer = result.statusCode === 429
2112
+ && peerAttempt + 1 < MODEL_RATE_LIMIT_MAX_ATTEMPTS_PER_PEER;
2113
+ if (!retrySamePeer)
2114
+ break;
2115
+ const delayMs = rateLimitRetryDelayMs(result.responseHeaders, peerAttempt);
2116
+ log(`Peer ${selected.peer.peerId.slice(0, 12)}... is rate-limited; retrying in ${delayMs}ms.`);
2117
+ if (!await waitForRetry(delayMs, clientAbortController.signal))
2118
+ return;
2119
+ }
2120
+ if (index + 1 < candidates.length) {
2121
+ log(`Peer ${selected.peer.peerId.slice(0, 12)}... failed retryably; trying next model peer.`);
2122
+ }
2123
+ }
2124
+ if (lastRetry) {
2125
+ res.writeHead(lastRetry.statusCode, lastRetry.responseHeaders);
2126
+ res.end(lastRetry.responseBody);
2127
+ }
2128
+ else {
2129
+ res.writeHead(502, { 'content-type': 'application/json' });
2130
+ res.end(JSON.stringify({
2131
+ error: {
2132
+ type: 'peer_verification_failed',
2133
+ code: 'peer_verification_failed',
2134
+ message: lastVerificationError ?? `No verified peer currently serves model "${requestedService}".`,
2135
+ },
2136
+ }));
2137
+ }
2138
+ return;
2139
+ }
2140
+ const pinnedPeerId = explicitPeerId;
1796
2141
  // Narrow the candidate set to just the pinned peer (if we already know
1797
2142
  // about it) before running the per-peer protocol/service match. This
1798
2143
  // avoids wasting work — and spamming "Service strict-miss" log lines —
@@ -1800,7 +2145,7 @@ export class BuyerProxy {
1800
2145
  // fall through with the full list so the "not in candidate set → force
1801
2146
  // refresh" path still works.
1802
2147
  const narrowToPinned = (sources) => {
1803
- const match = sources.find((p) => p.peerId.toLowerCase() === explicitPeerId);
2148
+ const match = sources.find((p) => p.peerId.toLowerCase() === pinnedPeerId);
1804
2149
  return match ? [match] : sources;
1805
2150
  };
1806
2151
  const selectPeers = (candidateSources) => selectCandidatePeersForRouting(narrowToPinned(candidateSources), requestProtocol, requestedService, explicitProvider, 'lenient');
@@ -1821,7 +2166,7 @@ export class BuyerProxy {
1821
2166
  let routingPeers = candidatePeers;
1822
2167
  let routingPlans = routePlanByPeerId;
1823
2168
  let discoveredPeers = peers;
1824
- const isPinnedDiscovered = () => discoveredPeers.some((peer) => peer.peerId.toLowerCase() === explicitPeerId);
2169
+ const isPinnedDiscovered = () => discoveredPeers.some((peer) => peer.peerId.toLowerCase() === pinnedPeerId);
1825
2170
  // Single refresh guard covers all three doubts about the cache: pin
1826
2171
  // missing, candidate filter empty, or cache past TTL.
1827
2172
  const cacheAgeMs = Date.now() - this._cacheLastUpdatedAtMs;
@@ -1837,16 +2182,16 @@ export class BuyerProxy {
1837
2182
  ? 'model peer prefix'
1838
2183
  : '--peer flag or session pin';
1839
2184
  const diagnostics = this._formatPeerSelectionDiagnostics(discoveredPeers);
1840
- log(`Pinned peer ${explicitPeerId.slice(0, 12)}... not discoverable in DHT (${logSource})`);
2185
+ log(`Pinned peer ${pinnedPeerId.slice(0, 12)}... not discoverable in DHT (${logSource})`);
1841
2186
  res.writeHead(502, { 'content-type': 'text/plain' });
1842
- res.end(`Pinned peer ${explicitPeerId.slice(0, 12)}... is not reachable right now. `
2187
+ res.end(`Pinned peer ${pinnedPeerId.slice(0, 12)}... is not reachable right now. `
1843
2188
  + 'It may be offline, not announcing, or temporarily unreachable. '
1844
2189
  + 'Pick a different service in Discover or try again later. '
1845
2190
  + diagnostics);
1846
2191
  return;
1847
2192
  }
1848
2193
  if (routingPeers.length === 0) {
1849
- const pinnedPeer = discoveredPeers.find((peer) => peer.peerId.toLowerCase() === explicitPeerId) ?? null;
2194
+ const pinnedPeer = discoveredPeers.find((peer) => peer.peerId.toLowerCase() === pinnedPeerId) ?? null;
1850
2195
  const protocolLabel = requestProtocol ? `protocol=${requestProtocol}` : 'protocol=unknown';
1851
2196
  const providerLabel = explicitProvider ? `provider=${explicitProvider}` : 'provider=auto';
1852
2197
  const serviceLabel = requestedService ? `service=${requestedService}` : 'service=none';
@@ -1857,41 +2202,40 @@ export class BuyerProxy {
1857
2202
  .filter((provider) => provider.length > 0);
1858
2203
  if (!providers.includes(explicitProvider)) {
1859
2204
  const providerList = providers.length > 0 ? providers.join(', ') : 'none';
1860
- log(`Pinned peer ${explicitPeerId.slice(0, 12)}... does not offer explicit provider=${explicitProvider}`);
2205
+ log(`Pinned peer ${pinnedPeerId.slice(0, 12)}... does not offer explicit provider=${explicitProvider}`);
1861
2206
  res.writeHead(502, { 'content-type': 'text/plain' });
1862
- res.end(`Pinned peer ${explicitPeerId.slice(0, 12)}... does not offer provider=${explicitProvider}. `
2207
+ res.end(`Pinned peer ${pinnedPeerId.slice(0, 12)}... does not offer provider=${explicitProvider}. `
1863
2208
  + `Available providers: ${providerList}. `
1864
2209
  + 'Remove or change the x-antseed-provider header, or pick a different peer. '
1865
2210
  + diagnostics);
1866
2211
  return;
1867
2212
  }
1868
2213
  }
1869
- log(`Pinned peer ${explicitPeerId.slice(0, 12)}... filtered out by protocol/service match`);
2214
+ log(`Pinned peer ${pinnedPeerId.slice(0, 12)}... filtered out by protocol/service match`);
1870
2215
  res.writeHead(502, { 'content-type': 'text/plain' });
1871
- res.end(`Pinned peer ${explicitPeerId.slice(0, 12)}... does not support this request `
2216
+ res.end(`Pinned peer ${pinnedPeerId.slice(0, 12)}... does not support this request `
1872
2217
  + `(${protocolLabel}, ${providerLabel}, ${serviceLabel}). `
1873
2218
  + `Pick a different service in Discover. ${diagnostics}`);
1874
2219
  return;
1875
2220
  }
1876
2221
  log(`Routing candidates: ${routingPeers.length} peer(s)`);
1877
2222
  const router = this._node.router;
1878
- const selectedPeer = routingPeers.find((p) => p.peerId.toLowerCase() === explicitPeerId) ?? null;
2223
+ const selectedPeer = routingPeers.find((p) => p.peerId.toLowerCase() === pinnedPeerId) ?? null;
1879
2224
  // Defence in depth: the discovered+narrowed checks above should make this
1880
2225
  // branch unreachable. Keep a structured fallback so we don't silently hang
1881
2226
  // if an invariant breaks.
1882
2227
  if (!selectedPeer) {
1883
- log(`Invariant: pinned peer ${explicitPeerId.slice(0, 12)}... present in DHT but missing from narrowed candidate list`);
2228
+ log(`Invariant: pinned peer ${pinnedPeerId.slice(0, 12)}... present in DHT but missing from narrowed candidate list`);
1884
2229
  res.writeHead(502, { 'content-type': 'text/plain' });
1885
- res.end(`Pinned peer ${explicitPeerId.slice(0, 12)}... is currently unreachable. Try again in a moment.`);
2230
+ res.end(`Pinned peer ${pinnedPeerId.slice(0, 12)}... is currently unreachable. Try again in a moment.`);
1886
2231
  return;
1887
2232
  }
1888
2233
  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) {
2234
+ const selectedPlan = routingPlans.get(selectedPeer.peerId)
2235
+ ?? resolvePeerRoutePlan(selectedPeer, requestProtocol, requestedService, explicitProvider, 'lenient');
2236
+ const pinnedServiceId = selectedPlan?.serviceId ?? requestedService;
2237
+ const pinnedRequest = pinnedServiceId ? withRoutedModel(serializedReq, pinnedServiceId) : serializedReq;
2238
+ if (!peerAllowedByPolicy(policyRouter, pinnedRequest, selectedPeer)) {
1895
2239
  log(`Pinned peer ${selectedPeer.peerId.slice(0, 12)}... filtered out by buyer routing policy`);
1896
2240
  res.writeHead(502, { 'content-type': 'text/plain' });
1897
2241
  res.end(`Pinned peer ${selectedPeer.peerId.slice(0, 12)}... is outside your buyer routing policy. `
@@ -1916,10 +2260,13 @@ export class BuyerProxy {
1916
2260
  }
1917
2261
  }
1918
2262
  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);
2263
+ const result = await this._dispatchToPeer(res, pinnedRequest, selectedPeer, routingPlans, requestProtocol, requestedService, explicitProvider, router, RETRYABLE_STATUS_CODES, clientAbortController.signal);
2264
+ if (result.done && trackedConversationId && pinnedServiceId) {
2265
+ this._conversations.recordRoutedModel(trackedConversationId, `${selectedPeer.peerId}@${pinnedServiceId}`);
2266
+ }
1920
2267
  if (!result.done) {
1921
2268
  // Pinned peer returned a retryable error. We never retry against another
1922
- // peer auto-selection is disabled so surface the error to the client.
2269
+ // peer for an explicit pin, so surface the error to the client.
1923
2270
  res.writeHead(result.statusCode, result.responseHeaders);
1924
2271
  res.end(result.responseBody);
1925
2272
  }
@@ -2001,7 +2348,7 @@ export class BuyerProxy {
2001
2348
  + `did not announce for this service: ${unannounced.join(', ')} — the upstream may ignore or reject them`);
2002
2349
  }
2003
2350
  }
2004
- const { 'x-antseed-pin-peer': _pinPeer, 'x-antseed-prefer-peer': _preferPeer, ...headersForPeer } = serializedReq.headers;
2351
+ const { 'x-antseed-pin-peer': _pinPeer, 'x-antseed-prefer-peer': _preferPeer, 'x-antstation-session-id': _antstationSession, ...headersForPeer } = serializedReq.headers;
2005
2352
  let requestForPeer = {
2006
2353
  ...serializedReq,
2007
2354
  headers: {
@@ -2009,6 +2356,9 @@ export class BuyerProxy {
2009
2356
  'x-antseed-provider': selectedRoutePlan.provider,
2010
2357
  },
2011
2358
  };
2359
+ if (selectedRoutePlan.serviceId) {
2360
+ requestForPeer = withRoutedModel(requestForPeer, selectedRoutePlan.serviceId);
2361
+ }
2012
2362
  const clientWantsStreaming = requestWantsStreaming(serializedReq.headers, serializedReq.body);
2013
2363
  let adaptResponse = null;
2014
2364
  let streamResponseAdapter = null;