@antseed/cli 0.1.141 → 0.1.143

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 (85) hide show
  1. package/dist/cli/commands/buyer/sweep.d.ts.map +1 -1
  2. package/dist/cli/commands/buyer/sweep.js +11 -5
  3. package/dist/cli/commands/buyer/sweep.js.map +1 -1
  4. package/dist/cli/commands/payments.d.ts.map +1 -1
  5. package/dist/cli/commands/payments.js +37 -20
  6. package/dist/cli/commands/payments.js.map +1 -1
  7. package/dist/cli/commands/system-proxy/ca-trust-state.d.ts +8 -0
  8. package/dist/cli/commands/system-proxy/ca-trust-state.d.ts.map +1 -0
  9. package/dist/cli/commands/system-proxy/ca-trust-state.js +20 -0
  10. package/dist/cli/commands/system-proxy/ca-trust-state.js.map +1 -0
  11. package/dist/cli/commands/system-proxy/index.d.ts.map +1 -1
  12. package/dist/cli/commands/system-proxy/index.js +2 -0
  13. package/dist/cli/commands/system-proxy/index.js.map +1 -1
  14. package/dist/cli/commands/system-proxy/install-ca.d.ts.map +1 -1
  15. package/dist/cli/commands/system-proxy/install-ca.js +9 -0
  16. package/dist/cli/commands/system-proxy/install-ca.js.map +1 -1
  17. package/dist/cli/commands/system-proxy/start.d.ts.map +1 -1
  18. package/dist/cli/commands/system-proxy/start.js +3 -1
  19. package/dist/cli/commands/system-proxy/start.js.map +1 -1
  20. package/dist/cli/commands/wrapped-tools.d.ts.map +1 -1
  21. package/dist/cli/commands/wrapped-tools.js +7 -0
  22. package/dist/cli/commands/wrapped-tools.js.map +1 -1
  23. package/dist/cli/commands/wrapped-tools.test.js +6 -1
  24. package/dist/cli/commands/wrapped-tools.test.js.map +1 -1
  25. package/dist/proxy/buyer-proxy.d.ts +38 -1
  26. package/dist/proxy/buyer-proxy.d.ts.map +1 -1
  27. package/dist/proxy/buyer-proxy.js +340 -23
  28. package/dist/proxy/buyer-proxy.js.map +1 -1
  29. package/dist/proxy/buyer-proxy.test.js +382 -11
  30. package/dist/proxy/buyer-proxy.test.js.map +1 -1
  31. package/dist/proxy/conversation-identity.d.ts +79 -0
  32. package/dist/proxy/conversation-identity.d.ts.map +1 -0
  33. package/dist/proxy/conversation-identity.js +302 -0
  34. package/dist/proxy/conversation-identity.js.map +1 -0
  35. package/dist/proxy/conversation-identity.test.d.ts +2 -0
  36. package/dist/proxy/conversation-identity.test.d.ts.map +1 -0
  37. package/dist/proxy/conversation-identity.test.js +236 -0
  38. package/dist/proxy/conversation-identity.test.js.map +1 -0
  39. package/dist/proxy/conversation-store.d.ts +96 -0
  40. package/dist/proxy/conversation-store.d.ts.map +1 -0
  41. package/dist/proxy/conversation-store.js +242 -0
  42. package/dist/proxy/conversation-store.js.map +1 -0
  43. package/dist/proxy/conversation-store.test.d.ts +2 -0
  44. package/dist/proxy/conversation-store.test.d.ts.map +1 -0
  45. package/dist/proxy/conversation-store.test.js +277 -0
  46. package/dist/proxy/conversation-store.test.js.map +1 -0
  47. package/dist/proxy/count-tokens.d.ts +20 -0
  48. package/dist/proxy/count-tokens.d.ts.map +1 -0
  49. package/dist/proxy/count-tokens.js +75 -0
  50. package/dist/proxy/count-tokens.js.map +1 -0
  51. package/dist/proxy/count-tokens.test.d.ts +2 -0
  52. package/dist/proxy/count-tokens.test.d.ts.map +1 -0
  53. package/dist/proxy/count-tokens.test.js +56 -0
  54. package/dist/proxy/count-tokens.test.js.map +1 -0
  55. package/dist/proxy/request-utils.d.ts +38 -0
  56. package/dist/proxy/request-utils.d.ts.map +1 -1
  57. package/dist/proxy/request-utils.js +84 -8
  58. package/dist/proxy/request-utils.js.map +1 -1
  59. package/dist/proxy/telemetry.d.ts +6 -0
  60. package/dist/proxy/telemetry.d.ts.map +1 -1
  61. package/dist/proxy/telemetry.js +53 -13
  62. package/dist/proxy/telemetry.js.map +1 -1
  63. package/dist/system-proxy/ca-manager.d.ts +67 -0
  64. package/dist/system-proxy/ca-manager.d.ts.map +1 -1
  65. package/dist/system-proxy/ca-manager.js +258 -39
  66. package/dist/system-proxy/ca-manager.js.map +1 -1
  67. package/dist/system-proxy/ca-manager.test.js +50 -1
  68. package/dist/system-proxy/ca-manager.test.js.map +1 -1
  69. package/dist/system-proxy/profiles.d.ts +2 -1
  70. package/dist/system-proxy/profiles.d.ts.map +1 -1
  71. package/dist/system-proxy/profiles.js +55 -4
  72. package/dist/system-proxy/profiles.js.map +1 -1
  73. package/dist/system-proxy/proxy-server.d.ts +18 -1
  74. package/dist/system-proxy/proxy-server.d.ts.map +1 -1
  75. package/dist/system-proxy/proxy-server.js +91 -38
  76. package/dist/system-proxy/proxy-server.js.map +1 -1
  77. package/dist/system-proxy/proxy-server.test.js +142 -13
  78. package/dist/system-proxy/proxy-server.test.js.map +1 -1
  79. package/dist/system-proxy/system-proxy.d.ts +1 -0
  80. package/dist/system-proxy/system-proxy.d.ts.map +1 -1
  81. package/dist/system-proxy/system-proxy.js +57 -15
  82. package/dist/system-proxy/system-proxy.js.map +1 -1
  83. package/dist/system-proxy/types.d.ts +17 -4
  84. package/dist/system-proxy/types.d.ts.map +1 -1
  85. package/package.json +4 -4
@@ -1 +1 @@
1
- {"version":3,"file":"buyer-proxy.d.ts","sourceRoot":"","sources":["../../src/proxy/buyer-proxy.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"buyer-proxy.d.ts","sourceRoot":"","sources":["../../src/proxy/buyer-proxy.ts"],"names":[],"mappings":"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"}
@@ -5,14 +5,17 @@ import { readFile, writeFile, rename, mkdir } from 'node:fs/promises';
5
5
  import { join } from 'node:path';
6
6
  import { ANTSEED_ATTEST_PATH, computeOnChainReputationScore, decodeSweepRequest, } from '@antseed/node';
7
7
  import { createStreamingAdapter, detectRequestServiceApiProtocol, transformRequest, transformResponse, } from './service-api-adapter.js';
8
- import { DEBUG, log, extractRequestedService, summarizeRequestShape, summarizeErrorResponse, requestWantsStreaming, rewritePeerPinnedServiceInBody, } from './request-utils.js';
8
+ import { DEBUG, log, extractRequestedService, summarizeRequestShape, summarizeErrorResponse, requestWantsStreaming, parsePeerPinnedService, rewritePeerPinnedServiceInBody, substituteRoutedModelAlias, overrideRoutedModelInBody, ROUTED_MODEL_ALIAS, SYSTEM_PROXY_SOURCE_HEADER, SYSTEM_ROUTED_MODEL_HEADER, } from './request-utils.js';
9
9
  import { getExplicitProviderOverride, getExplicitPeerIdOverride, resolvePeerRoutePlan, selectCandidatePeersForRouting, } from './routing.js';
10
10
  import { computeResponseTelemetry, attachAntseedTelemetryHeaders, attachStreamingAntseedHeaders, } from './telemetry.js';
11
11
  import { DEFAULT_BUYER_PEER_REFRESH_INTERVAL_MS } from '../config/defaults.js';
12
+ import { extractConversationIdentity, extractFirstUserSnippet, isCompletionRequestPath, isTitleGenerationRequest, parseRequestBodyObject, } from './conversation-identity.js';
13
+ import { ConversationStore } from './conversation-store.js';
14
+ import { estimateAnthropicPromptTokens, isCountTokensPath } from './count-tokens.js';
12
15
  import { getCachedVerdict, runVerifier, verifierSupportFingerprint } from '../plugins/verifier.js';
13
16
  // Re-export for backward compatibility (used by tests and other consumers)
14
17
  export { selectCandidatePeersForRouting } from './routing.js';
15
- export { parsePeerPinnedService, rewritePeerPinnedServiceInBody } from './request-utils.js';
18
+ export { parsePeerPinnedService, rewritePeerPinnedServiceInBody, substituteRoutedModelAlias, ROUTED_MODEL_ALIAS } from './request-utils.js';
16
19
  const RETRYABLE_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504]);
17
20
  function isControlPlaneServicesPath(path) {
18
21
  return path.toLowerCase().startsWith('/v1/models');
@@ -48,6 +51,12 @@ export function isModelNotFoundResponse(response) {
48
51
  */
49
52
  const CARRY_FORWARD_TTL_MS = 2 * 60 * 60_000;
50
53
  const PEER_FAILURE_WINDOW_MS = 5 * 60_000;
54
+ /**
55
+ * Requests kept in the spend-attribution map. Entries outlive their request on
56
+ * purpose (a seller-initiated auth can land after the response), so this is
57
+ * sized for in-flight plus recently-finished traffic, not for concurrency.
58
+ */
59
+ const MAX_TRACKED_REQUEST_CONVERSATIONS = 512;
51
60
  /** Min gap between background peer refreshes triggered by model_not_found responses. */
52
61
  const MODEL_NOT_FOUND_REFRESH_THROTTLE_MS = 30_000;
53
62
  /** Verification is expensive; bound how many verdicts we retain (TTL = peer-cache TTL). */
@@ -310,6 +319,20 @@ export class BuyerProxy {
310
319
  _stateFile;
311
320
  _stateFileWatching = false;
312
321
  _pinnedPeer;
322
+ /**
323
+ * Route substituted for the `antseed` model alias (`<peerId>@<service>`).
324
+ * Set via `POST /_antseed/route` (the desktop keeps it on the current VPR
325
+ * selection) and persisted in buyer.state.json like the session peer pin.
326
+ */
327
+ _defaultRoutedModel = null;
328
+ _conversations;
329
+ /**
330
+ * Wall-clock of the last model-request activity (dispatch or streamed
331
+ * frame). Exposed on /_antseed/buyer-usage so the desktop pill can show a
332
+ * live "traffic" signal without depending on debug logging. A single field
333
+ * write per frame — negligible next to the routing/payment/stream work.
334
+ */
335
+ _lastModelActivityAt = 0;
313
336
  _verifier;
314
337
  _verifyCache = new Map();
315
338
  _stateWatchDebounce = null;
@@ -319,11 +342,24 @@ export class BuyerProxy {
319
342
  _cacheMutationEpoch = 0;
320
343
  _peerRefreshPromise = null;
321
344
  _lastStaleCacheLogAtMs = 0;
345
+ _startedAtMs = 0;
346
+ /** After a network switch the DHT routing table can stay populated with stale
347
+ nodes, so repeated empty sweeps are the reachability signal — not node count. */
348
+ _consecutiveEmptyDiscoveries = 0;
322
349
  _lastModelNotFoundRefreshAtMs = 0;
323
350
  _bgRefreshHandle = null;
324
351
  _peerFailures = new Map();
325
352
  /** Latest relayer receipt per sweep authNonce, for CLI progress polling. */
326
353
  _sweepReceipts = new Map();
354
+ /**
355
+ * requestId -> the conversation that issued it, so the node's per-request
356
+ * spend events can be attributed to a chat. Only the proxy knows this
357
+ * mapping; the payment layer sees a seller and a request id.
358
+ *
359
+ * `counted` guards requestCount: one request can produce several spend
360
+ * deltas (buyer- and seller-initiated auth both advance the cumulative).
361
+ */
362
+ _requestConversations = new Map();
327
363
  constructor(config) {
328
364
  this._node = config.node;
329
365
  this._verifier = config.verifier;
@@ -332,6 +368,7 @@ export class BuyerProxy {
332
368
  this._peerCacheTtlMs = Math.max(0, config.peerCacheTtlMs ?? Math.max(6 * 60_000, this._bgRefreshIntervalMs + 60_000));
333
369
  this._stateDir = config.dataDir;
334
370
  this._stateFile = join(config.dataDir, 'buyer.state.json');
371
+ this._conversations = new ConversationStore(config.dataDir);
335
372
  this._pinnedPeer = config.pinnedPeerId?.toLowerCase() ?? null;
336
373
  this._server = createServer((req, res) => {
337
374
  this._handleRequest(req, res).catch((err) => {
@@ -353,6 +390,12 @@ export class BuyerProxy {
353
390
  }
354
391
  });
355
392
  }
393
+ const spendEventNode = this._node;
394
+ if (typeof spendEventNode.on === 'function') {
395
+ spendEventNode.on('payment:spend', (event) => {
396
+ this._attributeSpend(event);
397
+ });
398
+ }
356
399
  const eventNode = this._node;
357
400
  if (typeof eventNode.on === 'function') {
358
401
  eventNode.on('peers:discovered', (peers) => {
@@ -363,18 +406,47 @@ export class BuyerProxy {
363
406
  });
364
407
  }
365
408
  }
409
+ /** Roll a signed spend delta into the conversation that triggered it. */
410
+ _attributeSpend(event) {
411
+ if (!event.requestId)
412
+ return;
413
+ const entry = this._requestConversations.get(event.requestId);
414
+ if (!entry)
415
+ return;
416
+ this._conversations.addSpend(entry.convId, {
417
+ amountUsdc: event.amountUsdc,
418
+ inputTokens: event.inputTokens,
419
+ cachedInputTokens: event.cachedInputTokens,
420
+ outputTokens: event.outputTokens,
421
+ }, !entry.counted);
422
+ entry.counted = true;
423
+ }
424
+ /**
425
+ * Remember which chat a request belongs to for the life of the request plus
426
+ * a grace window — the seller-initiated auth path can land just after the
427
+ * response is returned. Bounded so a leaked id can't grow the map forever.
428
+ */
429
+ _trackRequestConversation(requestId, convId) {
430
+ this._requestConversations.set(requestId, { convId, counted: false });
431
+ while (this._requestConversations.size > MAX_TRACKED_REQUEST_CONVERSATIONS) {
432
+ const oldest = this._requestConversations.keys().next().value;
433
+ if (oldest === undefined)
434
+ break;
435
+ this._requestConversations.delete(oldest);
436
+ }
437
+ }
366
438
  async start() {
439
+ this._startedAtMs = Date.now();
367
440
  // Hydrate the in-memory peer cache from the persisted state file BEFORE
368
441
  // the server starts accepting requests. This lets the first request after
369
442
  // startup route from the warm cache without blocking on DHT discovery.
370
443
  // The background refresh still runs to pick up fresh peers and IP changes.
371
444
  await this._hydratePeersFromStateFile();
372
- // If the CLI didn't pass --peer, adopt whatever a previous
373
- // `antseed buyer connection set --peer` wrote to buyer.state.json so the
374
- // pin survives daemon restart.
375
- if (this._pinnedPeer === null) {
376
- await this._reloadSessionOverrides();
377
- }
445
+ // Adopt persisted session overrides (peer pin, default routed model) so
446
+ // they survive daemon restart. A --peer CLI flag beats the persisted pin
447
+ // at startup; runtime `connection set` writes still take over via the
448
+ // state-file watcher.
449
+ await this._reloadSessionOverrides({ preservePeerPin: this._pinnedPeer !== null });
378
450
  await new Promise((resolve, reject) => {
379
451
  this._server.once('error', reject);
380
452
  this._server.listen(this._port, '127.0.0.1', () => {
@@ -428,6 +500,7 @@ export class BuyerProxy {
428
500
  this._bgRefreshHandle = null;
429
501
  }
430
502
  await this._writeStateFile('stopped');
503
+ await this._conversations.flush();
431
504
  return new Promise((resolve) => {
432
505
  this._server.close(() => resolve());
433
506
  });
@@ -455,20 +528,28 @@ export class BuyerProxy {
455
528
  // watcher setup failed; non-fatal
456
529
  }
457
530
  }
458
- async _reloadSessionOverrides() {
531
+ async _reloadSessionOverrides(opts = {}) {
459
532
  try {
460
533
  const raw = await readFile(this._stateFile, 'utf-8');
461
534
  const parsed = JSON.parse(raw);
462
- const pinnedPeer = typeof parsed.pinnedPeerId === 'string' && parsed.pinnedPeerId.trim().length > 0
463
- ? parsed.pinnedPeerId.trim().toLowerCase()
464
- : null;
465
- this._pinnedPeer = pinnedPeer;
466
- log(`Session overrides reloaded: peer=${pinnedPeer ?? 'none'}`);
535
+ if (!opts.preservePeerPin) {
536
+ const pinnedPeer = typeof parsed.pinnedPeerId === 'string' && parsed.pinnedPeerId.trim().length > 0
537
+ ? parsed.pinnedPeerId.trim().toLowerCase()
538
+ : null;
539
+ this._pinnedPeer = pinnedPeer;
540
+ }
541
+ const routedModel = typeof parsed.defaultRoutedModel === 'string' ? parsed.defaultRoutedModel.trim() : '';
542
+ this._defaultRoutedModel = parsePeerPinnedService(routedModel) ? routedModel : null;
543
+ log(`Session overrides reloaded: peer=${this._pinnedPeer ?? 'none'} route=${this._defaultRoutedModel ?? 'none'}`);
467
544
  }
468
545
  catch {
469
546
  // state file unreadable; keep current values
470
547
  }
471
548
  }
549
+ /** Stamp the last model-request activity time (dispatch or streamed frame). */
550
+ _markModelActivity() {
551
+ this._lastModelActivityAt = Date.now();
552
+ }
472
553
  /** Serialised read-modify-write to buyer.state.json. Returns the queued write promise. */
473
554
  _mergeStateFile(patch) {
474
555
  this._stateWriteChain = this._stateWriteChain.then(async () => {
@@ -494,11 +575,11 @@ export class BuyerProxy {
494
575
  return this._stateWriteChain;
495
576
  }
496
577
  async _writeStateFile(state) {
497
- // When stopping, preserve whatever pinnedPeerId is already
578
+ // When stopping, preserve whatever session overrides are already
498
579
  // in the file — the debounce may have been cancelled before
499
580
  // _reloadSessionOverrides could commit the latest CLI-written values.
500
581
  const sessionOverrides = state === 'connected'
501
- ? { pinnedPeerId: this._pinnedPeer }
582
+ ? { pinnedPeerId: this._pinnedPeer, defaultRoutedModel: this._defaultRoutedModel }
502
583
  : {};
503
584
  await this._mergeStateFile({
504
585
  state,
@@ -695,9 +776,11 @@ export class BuyerProxy {
695
776
  this._peerRefreshPromise = (async () => {
696
777
  const peers = await this._discoverPeersFromNetwork();
697
778
  if (peers.length > 0) {
779
+ this._consecutiveEmptyDiscoveries = 0;
698
780
  this._replacePeers(peers);
699
781
  return peers;
700
782
  }
783
+ this._consecutiveEmptyDiscoveries += 1;
701
784
  const fallbackPeers = previousCachedPeers.length > 0 && this._cacheMutationEpoch === mutationEpochAtStart
702
785
  ? [...previousCachedPeers]
703
786
  : [];
@@ -765,6 +848,20 @@ export class BuyerProxy {
765
848
  res.end();
766
849
  return;
767
850
  }
851
+ if (path === '/_antseed/status' && method === 'GET') {
852
+ // Network reachability snapshot for UI diagnostics.
853
+ res.writeHead(200, { 'content-type': 'application/json' });
854
+ res.end(JSON.stringify({
855
+ ok: true,
856
+ dhtNodeCount: this._node.dhtNodeCount,
857
+ consecutiveEmptyDiscoveries: this._consecutiveEmptyDiscoveries,
858
+ peerCount: this._cachedPeers.length,
859
+ peersUpdatedAt: this._cacheLastUpdatedAtMs > 0 ? this._cacheLastUpdatedAtMs : null,
860
+ startedAt: this._startedAtMs,
861
+ uptimeMs: this._startedAtMs > 0 ? Date.now() - this._startedAtMs : 0,
862
+ }));
863
+ return;
864
+ }
768
865
  if (path === '/_antseed/peers/refresh' && method === 'POST') {
769
866
  try {
770
867
  const peers = await this._refreshPeersNow();
@@ -797,6 +894,107 @@ export class BuyerProxy {
797
894
  res.end(JSON.stringify({ ok: true, peers: payload }));
798
895
  return;
799
896
  }
897
+ if (path === '/_antseed/route' && method === 'GET') {
898
+ res.writeHead(200, { 'content-type': 'application/json' });
899
+ res.end(JSON.stringify({ ok: true, model: this._defaultRoutedModel }));
900
+ return;
901
+ }
902
+ if (path === '/_antseed/route' && method === 'POST') {
903
+ const chunks = [];
904
+ let totalSize = 0;
905
+ for await (const chunk of req) {
906
+ totalSize += chunk.length;
907
+ if (totalSize > 8192) {
908
+ res.writeHead(413, { 'content-type': 'application/json' });
909
+ res.end(JSON.stringify({ ok: false, error: 'Request body too large' }));
910
+ return;
911
+ }
912
+ chunks.push(chunk);
913
+ }
914
+ let model;
915
+ try {
916
+ const body = JSON.parse(Buffer.concat(chunks).toString());
917
+ model = typeof body.model === 'string' ? body.model.trim() : '';
918
+ }
919
+ catch {
920
+ res.writeHead(400, { 'content-type': 'application/json' });
921
+ res.end(JSON.stringify({ ok: false, error: 'Invalid JSON body' }));
922
+ return;
923
+ }
924
+ if (model.length > 0 && !parsePeerPinnedService(model)) {
925
+ res.writeHead(400, { 'content-type': 'application/json' });
926
+ res.end(JSON.stringify({ ok: false, error: 'model must be "<peerId>@<service>" (or empty to clear)' }));
927
+ return;
928
+ }
929
+ this._defaultRoutedModel = model.length > 0 ? model : null;
930
+ await this._mergeStateFile({ defaultRoutedModel: this._defaultRoutedModel });
931
+ log(`Default routed model set: ${this._defaultRoutedModel ?? 'none'}`);
932
+ res.writeHead(200, { 'content-type': 'application/json' });
933
+ res.end(JSON.stringify({ ok: true, model: this._defaultRoutedModel }));
934
+ return;
935
+ }
936
+ if (path === '/_antseed/conversations' && method === 'GET') {
937
+ res.writeHead(200, { 'content-type': 'application/json' });
938
+ res.end(JSON.stringify({ ok: true, conversations: this._conversations.list() }));
939
+ return;
940
+ }
941
+ if (path === '/_antseed/conversations/update' && method === 'POST') {
942
+ const chunks = [];
943
+ let totalSize = 0;
944
+ for await (const chunk of req) {
945
+ totalSize += chunk.length;
946
+ if (totalSize > 8192) {
947
+ res.writeHead(413, { 'content-type': 'application/json' });
948
+ res.end(JSON.stringify({ ok: false, error: 'Request body too large' }));
949
+ return;
950
+ }
951
+ chunks.push(chunk);
952
+ }
953
+ let parsed;
954
+ try {
955
+ parsed = JSON.parse(Buffer.concat(chunks).toString());
956
+ }
957
+ catch {
958
+ res.writeHead(400, { 'content-type': 'application/json' });
959
+ res.end(JSON.stringify({ ok: false, error: 'Invalid JSON body' }));
960
+ return;
961
+ }
962
+ const id = typeof parsed.id === 'string' ? parsed.id : '';
963
+ if (!id) {
964
+ res.writeHead(400, { 'content-type': 'application/json' });
965
+ res.end(JSON.stringify({ ok: false, error: 'id is required' }));
966
+ return;
967
+ }
968
+ if (parsed.delete === true) {
969
+ const removed = this._conversations.remove(id);
970
+ res.writeHead(removed ? 200 : 404, { 'content-type': 'application/json' });
971
+ res.end(JSON.stringify(removed ? { ok: true } : { ok: false, error: 'Unknown conversation' }));
972
+ return;
973
+ }
974
+ let conversation = this._conversations.get(id);
975
+ if (!conversation) {
976
+ res.writeHead(404, { 'content-type': 'application/json' });
977
+ res.end(JSON.stringify({ ok: false, error: 'Unknown conversation' }));
978
+ return;
979
+ }
980
+ if ('pinnedModel' in parsed) {
981
+ const pin = typeof parsed.pinnedModel === 'string' ? parsed.pinnedModel.trim() : '';
982
+ if (pin.length > 0 && !parsePeerPinnedService(pin)) {
983
+ res.writeHead(400, { 'content-type': 'application/json' });
984
+ res.end(JSON.stringify({ ok: false, error: 'pinnedModel must be "<peerId>@<service>" (or empty to clear)' }));
985
+ return;
986
+ }
987
+ conversation = this._conversations.setPinnedModel(id, pin.length > 0 ? pin : null);
988
+ log(`Conversation ${id.slice(0, 40)} pin: ${pin || 'cleared'}`);
989
+ }
990
+ if ('label' in parsed) {
991
+ const label = typeof parsed.label === 'string' ? parsed.label : null;
992
+ conversation = this._conversations.setLabel(id, label);
993
+ }
994
+ res.writeHead(200, { 'content-type': 'application/json' });
995
+ res.end(JSON.stringify({ ok: true, conversation }));
996
+ return;
997
+ }
800
998
  if (path === '/_antseed/connect' && method === 'POST') {
801
999
  const chunks = [];
802
1000
  let totalSize = 0;
@@ -857,7 +1055,7 @@ export class BuyerProxy {
857
1055
  if (path.startsWith('/_antseed/buyer-usage') && method === 'GET') {
858
1056
  const totals = this._node.getBuyerUsageTotals();
859
1057
  res.writeHead(200, { 'content-type': 'application/json' });
860
- res.end(JSON.stringify({ ok: true, totals }));
1058
+ res.end(JSON.stringify({ ok: true, totals, lastActivityAt: this._lastModelActivityAt || null }));
861
1059
  return;
862
1060
  }
863
1061
  const meteringMatch = path.match(/^\/_antseed\/metering\/(.+)$/);
@@ -868,8 +1066,11 @@ export class BuyerProxy {
868
1066
  res.end(JSON.stringify(stats));
869
1067
  return;
870
1068
  }
871
- // Broadcast a signed deposit-sweep request over the daemon's existing
872
- // seller connections (see docs/protocol/spec/09-deposit-sweep.md).
1069
+ // Offer a signed deposit-sweep request to the daemon's connected relayers
1070
+ // one at a time (see docs/protocol/spec/09-deposit-sweep.md) — sequential
1071
+ // dispatch keeps relayers from racing the same nonce and burning gas on
1072
+ // reverted transactions. Responds only after a relayer accepts, every
1073
+ // candidate declines, or the offer round runs out of peers.
873
1074
  if (path === '/_antseed/sweep' && method === 'POST') {
874
1075
  const chunks = [];
875
1076
  let totalSize = 0;
@@ -885,10 +1086,10 @@ export class BuyerProxy {
885
1086
  try {
886
1087
  // Re-validate through the wire codec — same rules as inbound P2P frames.
887
1088
  const payload = decodeSweepRequest(new Uint8Array(Buffer.concat(chunks)));
888
- const sent = this._node.broadcastSweepRequest(payload);
889
- log(`Sweep request ${payload.nonce.slice(0, 10)}... broadcast to ${sent} peer(s)`);
1089
+ const { offered, accepted } = await this._node.dispatchSweepRequest(payload);
1090
+ log(`Sweep request ${payload.nonce.slice(0, 10)}... offered to ${offered} peer(s), accepted=${accepted}`);
890
1091
  res.writeHead(200, { 'content-type': 'application/json' });
891
- res.end(JSON.stringify({ ok: true, sent }));
1092
+ res.end(JSON.stringify({ ok: true, sent: offered, accepted }));
892
1093
  }
893
1094
  catch (err) {
894
1095
  const message = err instanceof Error ? err.message : String(err);
@@ -979,6 +1180,17 @@ export class BuyerProxy {
979
1180
  chunks.push(chunk);
980
1181
  }
981
1182
  const body = Buffer.concat(chunks);
1183
+ // `/v1/messages/count_tokens` sits under the completion prefix but is not
1184
+ // a turn — Anthropic tools call it before most turns to size the context.
1185
+ // Answering it locally keeps it off the wire: routed to a seller it costs
1186
+ // a full inference and answers in a shape the caller cannot read.
1187
+ if (method === 'POST' && isCountTokensPath(normalizedPath)) {
1188
+ const inputTokens = estimateAnthropicPromptTokens(new Uint8Array(body));
1189
+ log(`count_tokens answered locally: ${inputTokens} tokens`);
1190
+ res.writeHead(200, { 'content-type': 'application/json' });
1191
+ res.end(JSON.stringify({ input_tokens: inputTokens }));
1192
+ return;
1193
+ }
982
1194
  // Build serialized request
983
1195
  const headers = {};
984
1196
  for (const [key, value] of Object.entries(req.headers)) {
@@ -991,6 +1203,11 @@ export class BuyerProxy {
991
1203
  }
992
1204
  // Remove host header (points to localhost, not the seller)
993
1205
  delete headers['host'];
1206
+ // Internal marker from the system proxy: the body's model was assigned
1207
+ // by the proxy's route rewrite, not chosen by the tool. Stripped here so
1208
+ // it never reaches a seller.
1209
+ const systemRoutedModel = headers[SYSTEM_ROUTED_MODEL_HEADER] === '1';
1210
+ delete headers[SYSTEM_ROUTED_MODEL_HEADER];
994
1211
  let serializedReq = {
995
1212
  requestId: randomUUID(),
996
1213
  method,
@@ -998,9 +1215,106 @@ export class BuyerProxy {
998
1215
  headers,
999
1216
  body: new Uint8Array(body),
1000
1217
  };
1001
- // Snapshot the session peer pin before any await so a concurrent
1218
+ // Snapshot the session overrides before any await so a concurrent
1002
1219
  // _reloadSessionOverrides() cannot change routing mid-request.
1003
1220
  const effectivePinnedPeer = this._pinnedPeer;
1221
+ // Per-chat routing: completion requests carry a stable per-conversation
1222
+ // identity (see conversation-identity.ts). A chat pinned to a model
1223
+ // overrides the session default when resolving the `antseed` alias;
1224
+ // subagent sessions inherit their parent chat's pin. The default route
1225
+ // only steers a chat's first request — the model that serves it becomes
1226
+ // the chat's own pin (ConversationStore.touch), so changing the default
1227
+ // later applies to new chats only.
1228
+ const isConversationRequest = method === 'POST' && isCompletionRequestPath(path);
1229
+ const conversationBody = isConversationRequest
1230
+ ? parseRequestBodyObject(serializedReq.body, serializedReq.headers)
1231
+ : null;
1232
+ const conversationIdentity = isConversationRequest
1233
+ ? extractConversationIdentity(serializedReq.headers, conversationBody)
1234
+ : null;
1235
+ // Internal marker from the system proxy: source profile used only for
1236
+ // local conversation attribution. Stripped here so it never reaches a seller.
1237
+ delete serializedReq.headers[SYSTEM_PROXY_SOURCE_HEADER];
1238
+ const chatPinnedModel = conversationIdentity
1239
+ ? this._conversations.getPinnedModel(conversationIdentity.tool, conversationIdentity.sessionKey)
1240
+ ?? (conversationIdentity.parentSessionKey
1241
+ ? this._conversations.getPinnedModel(conversationIdentity.tool, conversationIdentity.parentSessionKey)
1242
+ : null)
1243
+ : null;
1244
+ const effectiveRoutedModel = chatPinnedModel ?? this._defaultRoutedModel;
1245
+ // Resolve the `antseed` model alias to the session's default route first,
1246
+ // so the regular `<peerId>@<service>` pin rewrite below picks up the
1247
+ // substituted value. Tool configs written by the desktop carry the alias
1248
+ // so route changes apply to running sessions without config rewrites.
1249
+ const aliasResult = substituteRoutedModelAlias(serializedReq.body, serializedReq.headers, effectiveRoutedModel);
1250
+ if (aliasResult.aliasRequested && !aliasResult.substituted) {
1251
+ log(`Request rejected: model alias "${ROUTED_MODEL_ALIAS}" with no default route set`);
1252
+ res.writeHead(400, { 'content-type': 'application/json' });
1253
+ res.end(JSON.stringify({
1254
+ error: {
1255
+ type: 'no_default_route',
1256
+ code: 'no_default_route',
1257
+ message: `Model "${ROUTED_MODEL_ALIAS}" routes to the model selected in AntStation, but no route is set. `
1258
+ + 'Pick a model in the desktop app, or request "<peerId>@<model>" explicitly.',
1259
+ param: 'model',
1260
+ },
1261
+ }));
1262
+ return;
1263
+ }
1264
+ if (aliasResult.substituted) {
1265
+ serializedReq = { ...serializedReq, body: aliasResult.body, headers: aliasResult.headers };
1266
+ log(`Model alias applied: ${ROUTED_MODEL_ALIAS} -> ${effectiveRoutedModel}${chatPinnedModel ? ' (chat pin)' : ''}`);
1267
+ }
1268
+ // System-proxy-intercepted tools can't carry the alias — the proxy
1269
+ // rewrites their upstream model names to its connect-time route and
1270
+ // marks the request. A chat pin must still win there, exactly as it
1271
+ // does for alias-carrying configs; otherwise pinning a chat in the
1272
+ // desktop silently does nothing for intercepted apps.
1273
+ let chatPinOverrideApplied = false;
1274
+ if (systemRoutedModel && !aliasResult.aliasRequested && chatPinnedModel) {
1275
+ const pinOverride = overrideRoutedModelInBody(serializedReq.body, serializedReq.headers, chatPinnedModel);
1276
+ if (pinOverride.overridden) {
1277
+ serializedReq = { ...serializedReq, body: pinOverride.body, headers: pinOverride.headers };
1278
+ chatPinOverrideApplied = true;
1279
+ log(`Chat pin applied to system-routed model: -> ${chatPinnedModel}`);
1280
+ }
1281
+ }
1282
+ // Track the conversation (subagent traffic rolls up into the parent
1283
+ // chat). The snippet is only extracted the first time a chat is seen.
1284
+ // A thread the tool opened for its own housekeeping is routed and paid
1285
+ // for normally, but is not a chat — Codex titles every new chat from one.
1286
+ if (conversationIdentity?.isUserThread) {
1287
+ const rawModel = typeof conversationBody?.['model'] === 'string' ? conversationBody['model'] : '';
1288
+ const resolvedModel = aliasResult.substituted
1289
+ ? effectiveRoutedModel
1290
+ : chatPinOverrideApplied
1291
+ ? chatPinnedModel
1292
+ : (parsePeerPinnedService(rawModel) ? rawModel : null);
1293
+ const trackedKey = conversationIdentity.parentSessionKey ?? conversationIdentity.sessionKey;
1294
+ const known = this._conversations.get(`${conversationIdentity.tool}:${trackedKey}`);
1295
+ // A title turn runs on the tool's own small model and races ahead of the
1296
+ // first real turn, so it must not become the chat's model: that pin
1297
+ // outranks the default route for every later turn, which would strand
1298
+ // the whole session on a model the user never picked.
1299
+ const titleTurn = isTitleGenerationRequest(conversationBody);
1300
+ const snippet = known?.snippet ? null : extractFirstUserSnippet(conversationBody);
1301
+ // Some tools (T3/Claude) fire title-only requests when a new chat opens.
1302
+ // Route them normally, but do not create a blank AntSeed conversation row
1303
+ // until the first genuine user turn arrives.
1304
+ if (known || !titleTurn) {
1305
+ const tracked = this._conversations.touch({
1306
+ tool: conversationIdentity.tool,
1307
+ sessionKey: trackedKey,
1308
+ // Extract until a label sticks: a tool's title-generation request can
1309
+ // race ahead of the first real turn and create the row snippet-less.
1310
+ snippet,
1311
+ lastModel: titleTurn ? null : resolvedModel,
1312
+ });
1313
+ // Bind the request to the chat so its cost can be attributed when the
1314
+ // payment layer signs for it (see _attributeSpend).
1315
+ this._trackRequestConversation(serializedReq.requestId, tracked.id);
1316
+ }
1317
+ }
1004
1318
  const { body: servicePinBody, headers: servicePinHeaders, pinnedPeerId: bodyPinnedPeer, } = rewritePeerPinnedServiceInBody(serializedReq.body, serializedReq.headers);
1005
1319
  if (servicePinBody !== serializedReq.body) {
1006
1320
  serializedReq = { ...serializedReq, body: servicePinBody, headers: servicePinHeaders };
@@ -1335,6 +1649,7 @@ export class BuyerProxy {
1335
1649
  log(`Outbound request shape: ${summarizeRequestShape(requestForPeer)}`);
1336
1650
  }
1337
1651
  log(`Routing to peer ${selectedPeer.peerId.slice(0, 12)}...`);
1652
+ this._markModelActivity();
1338
1653
  // Forward through P2P
1339
1654
  const wantsStreaming = clientWantsStreaming;
1340
1655
  const startTime = Date.now();
@@ -1364,6 +1679,7 @@ export class BuyerProxy {
1364
1679
  onResponseChunk: (chunk) => {
1365
1680
  if (!streamed)
1366
1681
  return;
1682
+ this._markModelActivity();
1367
1683
  const adaptedChunks = streamResponseAdapter
1368
1684
  ? streamResponseAdapter.adaptChunk(chunk)
1369
1685
  : [chunk];
@@ -1447,6 +1763,7 @@ export class BuyerProxy {
1447
1763
  response = inject402PeerId(response, selectedPeer.peerId);
1448
1764
  }
1449
1765
  const latencyMs = Date.now() - startTime;
1766
+ this._markModelActivity();
1450
1767
  log(`Response: ${response.statusCode} (${latencyMs}ms, ${response.body.length} bytes)`);
1451
1768
  if (response.statusCode >= 400) {
1452
1769
  const prefix = adaptResponse ? 'Upstream adapted error detail' : 'Upstream error detail';