@antseed/cli 0.1.196 → 0.1.198

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 (115) hide show
  1. package/dist/cli/commands/buyer/start.d.ts.map +1 -1
  2. package/dist/cli/commands/buyer/start.js +1 -0
  3. package/dist/cli/commands/buyer/start.js.map +1 -1
  4. package/dist/cli/commands/gateway/activity.d.ts.map +1 -1
  5. package/dist/cli/commands/gateway/activity.js +26 -5
  6. package/dist/cli/commands/gateway/activity.js.map +1 -1
  7. package/dist/config/types.d.ts +2 -0
  8. package/dist/config/types.d.ts.map +1 -1
  9. package/dist/gateway/accounting.d.ts +118 -6
  10. package/dist/gateway/accounting.d.ts.map +1 -1
  11. package/dist/gateway/accounting.js +319 -21
  12. package/dist/gateway/accounting.js.map +1 -1
  13. package/dist/gateway/console-api/handlers/favorites.d.ts +9 -0
  14. package/dist/gateway/console-api/handlers/favorites.d.ts.map +1 -0
  15. package/dist/gateway/console-api/handlers/favorites.js +44 -0
  16. package/dist/gateway/console-api/handlers/favorites.js.map +1 -0
  17. package/dist/gateway/console-api/handlers/favorites.test.d.ts +2 -0
  18. package/dist/gateway/console-api/handlers/favorites.test.d.ts.map +1 -0
  19. package/dist/gateway/console-api/handlers/favorites.test.js +41 -0
  20. package/dist/gateway/console-api/handlers/favorites.test.js.map +1 -0
  21. package/dist/gateway/console-api/handlers/index.d.ts.map +1 -1
  22. package/dist/gateway/console-api/handlers/index.js +2 -0
  23. package/dist/gateway/console-api/handlers/index.js.map +1 -1
  24. package/dist/gateway/console-api/handlers/network-mapping.d.ts +7 -1
  25. package/dist/gateway/console-api/handlers/network-mapping.d.ts.map +1 -1
  26. package/dist/gateway/console-api/handlers/network-mapping.js +46 -0
  27. package/dist/gateway/console-api/handlers/network-mapping.js.map +1 -1
  28. package/dist/gateway/console-api/handlers/network.d.ts.map +1 -1
  29. package/dist/gateway/console-api/handlers/network.js +19 -3
  30. package/dist/gateway/console-api/handlers/network.js.map +1 -1
  31. package/dist/gateway/console-api/handlers/network.test.js +70 -8
  32. package/dist/gateway/console-api/handlers/network.test.js.map +1 -1
  33. package/dist/gateway/console-api/handlers/usage.d.ts.map +1 -1
  34. package/dist/gateway/console-api/handlers/usage.js +2 -1
  35. package/dist/gateway/console-api/handlers/usage.js.map +1 -1
  36. package/dist/gateway/console-api/types.d.ts +75 -1
  37. package/dist/gateway/console-api/types.d.ts.map +1 -1
  38. package/dist/gateway/console-api/types.js.map +1 -1
  39. package/dist/gateway/gateway.test.js +829 -3
  40. package/dist/gateway/gateway.test.js.map +1 -1
  41. package/dist/gateway/request-errors.test.js +30 -0
  42. package/dist/gateway/request-errors.test.js.map +1 -1
  43. package/dist/gateway/routing-through-gateway.test.js +120 -4
  44. package/dist/gateway/routing-through-gateway.test.js.map +1 -1
  45. package/dist/gateway/runtime.d.ts.map +1 -1
  46. package/dist/gateway/runtime.js +4 -0
  47. package/dist/gateway/runtime.js.map +1 -1
  48. package/dist/gateway/server.d.ts +20 -0
  49. package/dist/gateway/server.d.ts.map +1 -1
  50. package/dist/gateway/server.js +181 -25
  51. package/dist/gateway/server.js.map +1 -1
  52. package/dist/gateway/services/network.d.ts +6 -1
  53. package/dist/gateway/services/network.d.ts.map +1 -1
  54. package/dist/gateway/services/network.js +12 -1
  55. package/dist/gateway/services/network.js.map +1 -1
  56. package/dist/gateway/services/usage.d.ts +2 -0
  57. package/dist/gateway/services/usage.d.ts.map +1 -1
  58. package/dist/gateway/services/usage.js +19 -5
  59. package/dist/gateway/services/usage.js.map +1 -1
  60. package/dist/gateway/spend-feed.d.ts +4 -1
  61. package/dist/gateway/spend-feed.d.ts.map +1 -1
  62. package/dist/gateway/spend-feed.js +7 -0
  63. package/dist/gateway/spend-feed.js.map +1 -1
  64. package/dist/gateway/store-data.test.js +1 -1
  65. package/dist/gateway/store-data.test.js.map +1 -1
  66. package/dist/gateway/store-migrations.d.ts.map +1 -1
  67. package/dist/gateway/store-migrations.js +40 -0
  68. package/dist/gateway/store-migrations.js.map +1 -1
  69. package/dist/gateway/store.d.ts +63 -1
  70. package/dist/gateway/store.d.ts.map +1 -1
  71. package/dist/gateway/store.js +142 -21
  72. package/dist/gateway/store.js.map +1 -1
  73. package/dist/gateway/video-jobs.d.ts +12 -0
  74. package/dist/gateway/video-jobs.d.ts.map +1 -0
  75. package/dist/gateway/video-jobs.js +24 -0
  76. package/dist/gateway/video-jobs.js.map +1 -0
  77. package/dist/proxy/buyer-proxy.d.ts +15 -0
  78. package/dist/proxy/buyer-proxy.d.ts.map +1 -1
  79. package/dist/proxy/buyer-proxy.js +166 -19
  80. package/dist/proxy/buyer-proxy.js.map +1 -1
  81. package/dist/proxy/buyer-proxy.test.js +158 -1
  82. package/dist/proxy/buyer-proxy.test.js.map +1 -1
  83. package/dist/proxy/gateway-policy.test.js +75 -1
  84. package/dist/proxy/gateway-policy.test.js.map +1 -1
  85. package/dist/proxy/request-utils.d.ts +4 -0
  86. package/dist/proxy/request-utils.d.ts.map +1 -1
  87. package/dist/proxy/request-utils.js +4 -0
  88. package/dist/proxy/request-utils.js.map +1 -1
  89. package/dist/proxy/resource-routes.d.ts +2 -0
  90. package/dist/proxy/resource-routes.d.ts.map +1 -1
  91. package/dist/proxy/resource-routes.js +4 -0
  92. package/dist/proxy/resource-routes.js.map +1 -1
  93. package/dist/proxy/route-selection.d.ts +79 -0
  94. package/dist/proxy/route-selection.d.ts.map +1 -0
  95. package/dist/proxy/route-selection.js +79 -0
  96. package/dist/proxy/route-selection.js.map +1 -0
  97. package/dist/proxy/spend-attribution.d.ts +17 -1
  98. package/dist/proxy/spend-attribution.d.ts.map +1 -1
  99. package/dist/proxy/spend-attribution.js +45 -10
  100. package/dist/proxy/spend-attribution.js.map +1 -1
  101. package/dist/proxy/spend-attribution.test.d.ts +2 -0
  102. package/dist/proxy/spend-attribution.test.d.ts.map +1 -0
  103. package/dist/proxy/spend-attribution.test.js +63 -0
  104. package/dist/proxy/spend-attribution.test.js.map +1 -0
  105. package/dist/proxy/telemetry.d.ts +15 -0
  106. package/dist/proxy/telemetry.d.ts.map +1 -1
  107. package/dist/proxy/telemetry.js +26 -3
  108. package/dist/proxy/telemetry.js.map +1 -1
  109. package/dist/proxy/video-download.d.ts +9 -1
  110. package/dist/proxy/video-download.d.ts.map +1 -1
  111. package/dist/proxy/video-download.js +41 -6
  112. package/dist/proxy/video-download.js.map +1 -1
  113. package/dist/proxy/video-download.test.js +33 -3
  114. package/dist/proxy/video-download.test.js.map +1 -1
  115. package/package.json +7 -7
@@ -8,14 +8,15 @@ import { buildRoutingServices, findPinnedRoutingService } from './routing-servic
8
8
  import { readFileSync, watchFile, unwatchFile } from 'node:fs';
9
9
  import { readFile, writeFile, rename, mkdir, readdir, stat, unlink } from 'node:fs/promises';
10
10
  import { join } from 'node:path';
11
- import { ANTSEED_BUYER_FAULT_ERROR_CODE, ANTSEED_FAULT_ATTRIBUTION_HEADER, ANTSEED_ATTEST_PATH, adaptPeerFaultErrorResponse, computeTrustScore, decodeSweepRequest, faultAttributionOf, faultCodeOf, isModelRouteCoolingDown, isModelRouteEligible, modelRouteReputationScore, modelRouteTotalPrice, normalizedModelReputationScore, peerSupportsCooperativeClose, isRoutingSelection, DEFAULT_BUYER_IDENTITY, rankModelRoutes, SellerHealthBook, orderAdaptiveRoutes, adaptiveFirstTokenTimeoutMs, sanitizePeerDisplayName, } from '@antseed/node';
11
+ import { ANTSEED_BUYER_FAULT_ERROR_CODE, ANTSEED_FAULT_ATTRIBUTION_HEADER, ANTSEED_ATTEST_PATH, adaptPeerFaultErrorResponse, computeTrustScore, decodeSweepRequest, faultAttributionOf, faultCodeOf, isModelRouteCoolingDown, isModelRouteEligible, modelRouteReputationScore, modelRouteTotalPrice, normalizedModelReputationScore, peerSupportsCooperativeClose, isRoutingSelection, DEFAULT_BUYER_IDENTITY, rankModelRoutes, SellerHealthBook, orderAdaptiveRoutes, adaptiveFirstTokenTimeoutMs, sanitizePeerDisplayName, VIDEO_JOB_RETIRED_HEADER, } from '@antseed/node';
12
+ import { encodeServedSelection, ROUTE_SELECTION_HEADER } from './route-selection.js';
12
13
  import { canonicalModelKey } from '@antseed/node/model-identity';
13
14
  import { createStreamingAdapter, detectRequestServiceApiProtocol, extractRequestBodyFields, transformRequest, transformResponse, } from './service-api-adapter.js';
14
- import { DEBUG, log, extractRequestedService, summarizeRequestShape, summarizeErrorResponse, requestWantsStreaming, parsePeerPinnedService, rewritePeerPinnedServiceInBody, substituteRoutedModelAlias, overrideRoutedModelInBody, ROUTED_MODEL_ALIAS, SYSTEM_PROXY_SOURCE_HEADER, SYSTEM_ROUTED_MODEL_HEADER, BUYER_IDENTITY_HEADER, normalizePeerId, takeRoutingOptions, } from './request-utils.js';
15
+ import { DEBUG, log, extractRequestedService, summarizeRequestShape, summarizeErrorResponse, requestWantsStreaming, parsePeerPinnedService, rewritePeerPinnedServiceInBody, substituteRoutedModelAlias, overrideRoutedModelInBody, ROUTED_MODEL_ALIAS, SYSTEM_PROXY_SOURCE_HEADER, SYSTEM_ROUTED_MODEL_HEADER, BUYER_IDENTITY_HEADER, MAX_VIDEO_COST_HEADER, normalizePeerId, takeRoutingOptions, } from './request-utils.js';
15
16
  import { BuyerIdentityLoader } from '../buyer-identities/loader.js';
16
17
  import { buildNetworkModels, effectiveModelReputationScore, parseModelTypeFilter, } from './network-models.js';
17
18
  import { findMissingRequiredParameters, findUnannouncedRequestParameters, findAdvertisedServiceOffer, findAdvertisedServiceProtocols, getExplicitProviderOverride, getExplicitPeerIdOverride, parseRequiredParametersHeader, REQUIRED_PARAMETERS_HEADER, resolvePeerRoutePlan, selectCandidatePeersForRouting, } from './routing.js';
18
- import { computeResponseTelemetry, attachAntseedTelemetryHeaders, attachStreamingAntseedHeaders, } from './telemetry.js';
19
+ import { computeResponseTelemetry, attachAntseedTelemetryHeaders, ROUTER_PEER_ID_HEADER, ROUTER_RANK_HEADER, ROUTER_SERVICE_HEADER, withoutRouterHeaders, attachStreamingAntseedHeaders, } from './telemetry.js';
19
20
  import { DEFAULT_BUYER_PEER_REFRESH_INTERVAL_MS } from '../config/defaults.js';
20
21
  import { extractConversationIdentity, extractFirstUserSnippet, isCompletionRequestPath, isTitleGenerationRequest, parseRequestBodyObject, } from './conversation-identity.js';
21
22
  import { ConversationStore } from './conversation-store.js';
@@ -310,7 +311,8 @@ function dispatchFailure(statusCode, responseHeaders, body) {
310
311
  function writeDispatchFailure(res, result) {
311
312
  if (res.headersSent || res.writableEnded)
312
313
  return;
313
- res.writeHead(result.statusCode, result.responseHeaders);
314
+ // A refusal can carry the seller's own headers: never let it name a router.
315
+ res.writeHead(result.statusCode, withoutRouterHeaders(result.responseHeaders));
314
316
  res.end(result.responseBody);
315
317
  }
316
318
  function isBuyerFault(result) {
@@ -434,6 +436,10 @@ export function sanitizePeerBuyerFaultMarker(response) {
434
436
  ? { ...response, body: Buffer.from(JSON.stringify(parsed)) }
435
437
  : response;
436
438
  }
439
+ /** A non-negative integer amount of USDC base units, or null when absent or malformed. */
440
+ function parseUsdcBaseUnits(value) {
441
+ return value && /^\d{1,15}$/.test(value) ? BigInt(value) : null;
442
+ }
437
443
  function buyerFaultStatusCode(faultCode) {
438
444
  switch (faultCode) {
439
445
  case 'invalid-request': return 400;
@@ -810,6 +816,12 @@ export class BuyerProxy {
810
816
  _routingPreferences;
811
817
  _stateWriteChain = Promise.resolve();
812
818
  _resourceRoutes = new ResourceRoutes();
819
+ /** Retrieves of one video job run one at a time, so a job retired as failed cannot be delivered by a retrieve already under way. */
820
+ _videoJobQueues = new Map();
821
+ /** Jobs whose retirement is being saved: left out of the saved routes, still routable in memory until the save succeeds. */
822
+ /** The saved video routes existed but could not be read at start (see `start`). */
823
+ _resourceRoutesUnread = false;
824
+ _retiringVideoJobs = new Set();
813
825
  _cachedPeers = [];
814
826
  _cacheLastUpdatedAtMs = 0;
815
827
  _cacheMutationEpoch = 0;
@@ -1311,7 +1323,9 @@ export class BuyerProxy {
1311
1323
  now,
1312
1324
  affinityKey: input.affinityKey ?? null,
1313
1325
  ...this._adaptiveRouting,
1314
- ...(input.deterministic ? { spreadTolerance: 0, explorationRate: 0 } : {}),
1326
+ // The preview keeps the real sharing pool (so it can show who shares the
1327
+ // traffic) but always draws its first seller and never probes.
1328
+ ...(input.deterministic ? { random: () => 0, explorationRate: 0 } : {}),
1315
1329
  });
1316
1330
  // Without routing preferences the buyer ranks by reputation, as before;
1317
1331
  // health only moves sellers that fail often behind the others.
@@ -1340,6 +1354,7 @@ export class BuyerProxy {
1340
1354
  // Conversation affinity is a soft preference: it only reorders sellers the
1341
1355
  // policy already allows, and yields to an explicit per-model route.
1342
1356
  const preferredPeerId = input.preferredPeerId;
1357
+ let conversationFirst = false;
1343
1358
  if (preferredPeerId && !findModelRoute(effectivePolicy, requestedService)) {
1344
1359
  const preferredIndex = candidates.findIndex((candidate) => (candidate.peer.peerId.toLowerCase() === preferredPeerId
1345
1360
  && !isCoolingDown(this._peerHealth.get(candidate.peer.peerId), now)
@@ -1349,7 +1364,17 @@ export class BuyerProxy {
1349
1364
  if (preferred)
1350
1365
  candidates.unshift(preferred);
1351
1366
  }
1352
- }
1367
+ conversationFirst = preferredIndex >= 0;
1368
+ }
1369
+ // How the eligible sellers were put in order. Only the buyer's adaptive
1370
+ // order, unsorted by the policy and with no route chain, spreads traffic.
1371
+ const policySort = effectivePolicy?.sort && effectivePolicy.sort !== 'balanced' ? effectivePolicy.sort : null;
1372
+ const selectionOrdering = policySort ?? (routingPreferences ? 'expected-cost' : 'trust');
1373
+ const spreads = selectionOrdering === 'expected-cost' && !chainPosition?.size;
1374
+ const pool = spreads ? adaptive.pool : [];
1375
+ const poolWeight = (candidate) => 1 / Math.max(healthOf(candidate).score, 1e-9) ** 2;
1376
+ const poolTotal = pool.reduce((sum, candidate) => sum + poolWeight(candidate), 0);
1377
+ const selections = new Map();
1353
1378
  candidates.forEach((candidate, index) => {
1354
1379
  const verdict = verdicts.get(candidate.peerId);
1355
1380
  if (!verdict)
@@ -1364,8 +1389,8 @@ export class BuyerProxy {
1364
1389
  verdict.reasons.push('cooling down (tried last)');
1365
1390
  else if (health.unhealthy)
1366
1391
  verdict.reasons.push(`failing lately: ${health.stats?.lastFailureReason ?? 'failed requests'} (tried after healthy sellers)`);
1367
- if (adaptive.pool.length > 1 && adaptive.pool.includes(candidate))
1368
- verdict.reasons.push(`shares traffic with ${adaptive.pool.length - 1} other seller(s)`);
1392
+ if (pool.length > 1 && pool.includes(candidate))
1393
+ verdict.reasons.push(`shares traffic with ${pool.length - 1} other seller(s)`);
1369
1394
  if (modelRouteTotalPrice(candidate) === 0)
1370
1395
  verdict.reasons.push('free');
1371
1396
  verdict.reasons.push(`success ~${Math.round(health.successProbability * 100)}%${health.stats ? '' : ' (no data yet)'}`);
@@ -1375,8 +1400,49 @@ export class BuyerProxy {
1375
1400
  const throughput = this._peerThroughput(candidate.peerId, candidate.serviceId);
1376
1401
  if (throughput !== null)
1377
1402
  verdict.reasons.push(`~${Math.round(throughput)} tok/s`);
1403
+ const healthKey = this._healthServiceKey(candidate.serviceId);
1404
+ const stats = health.stats;
1405
+ const inPool = pool.includes(candidate);
1406
+ const maxConcurrency = typeof candidate.peer.maxConcurrency === 'number' && candidate.peer.maxConcurrency > 0 ? candidate.peer.maxConcurrency : null;
1407
+ const selection = {
1408
+ expectedCostUsd: health.score,
1409
+ expectedPriceUsd: health.expectedPriceUsd,
1410
+ expectedSeconds: health.expectedSeconds,
1411
+ successRate: health.successProbability,
1412
+ observations: stats ? stats.successes + stats.failures : 0,
1413
+ record: !stats ? 'none' : this._sellerHealth.hasRecord(candidate.peerId, healthKey) ? 'model' : 'other-models',
1414
+ firstTokenMs: latency,
1415
+ tokensPerSecond: throughput,
1416
+ lastSuccessAt: stats && stats.lastSuccessAt > 0 ? stats.lastSuccessAt : null,
1417
+ lastFailureAt: stats && stats.lastFailureAt > 0 ? stats.lastFailureAt : null,
1418
+ lastFailureReason: stats?.lastFailureReason ?? null,
1419
+ failing: health.unhealthy,
1420
+ coolingDown: isCooling(candidate),
1421
+ stale: health.stale,
1422
+ ordering: selectionOrdering,
1423
+ sharesTraffic: inPool && pool.length > 1,
1424
+ trafficShare: inPool && pool.length > 1 && poolTotal > 0 ? poolWeight(candidate) / poolTotal : null,
1425
+ poolSize: pool.length,
1426
+ channel: modelRouteTotalPrice(candidate) === 0 ? 'free' : openChannels ? (openChannels.has(candidate.peerId.toLowerCase()) ? 'open' : 'new') : null,
1427
+ inFlight: this._inFlightBySeller.get(candidate.peerId.toLowerCase()) ?? 0,
1428
+ maxConcurrency,
1429
+ routeChainPosition: position !== undefined ? position + 1 : null,
1430
+ };
1431
+ verdict.selection = selection;
1432
+ selections.set(candidate, selection);
1378
1433
  });
1379
- return { candidates, verdicts: [...verdicts.values()], effectivePolicy, explored: adaptive.explored };
1434
+ // How the first seller came to be first, for the request's record.
1435
+ const first = candidates[0];
1436
+ let firstPick = 'order';
1437
+ if (first) {
1438
+ if (first === adaptive.explored)
1439
+ firstPick = 'exploration';
1440
+ else if (conversationFirst || (input.affinityKey && pool.length > 1 && pool.includes(first) && first === adaptive.ordered[0]))
1441
+ firstPick = 'conversation';
1442
+ else if (first === adaptive.ordered[0] && pool.includes(first))
1443
+ firstPick = pool.length > 1 ? 'spread' : 'best';
1444
+ }
1445
+ return { candidates, verdicts: [...verdicts.values()], effectivePolicy, explored: adaptive.explored, selections, firstPick };
1380
1446
  }
1381
1447
  /**
1382
1448
  * Without routing preferences: best effective reputation first, and only
@@ -1434,6 +1500,7 @@ export class BuyerProxy {
1434
1500
  inputUsdPerMillion: verdict.offer.inputUsdPerMillion ?? null,
1435
1501
  outputUsdPerMillion: verdict.offer.outputUsdPerMillion ?? null,
1436
1502
  trustScore: normalizedModelReputationScore(verdict.peer),
1503
+ selection: modelAllowed && verdict.eligible ? verdict.selection ?? null : null,
1437
1504
  }))
1438
1505
  .sort((a, b) => (a.rank ?? Number.POSITIVE_INFINITY) - (b.rank ?? Number.POSITIVE_INFINITY)
1439
1506
  || a.peerId.localeCompare(b.peerId));
@@ -1648,7 +1715,13 @@ export class BuyerProxy {
1648
1715
  const state = JSON.parse(await readFile(this._stateFile, 'utf8'));
1649
1716
  this._resourceRoutes.hydrate(state.resourceRoutes);
1650
1717
  }
1651
- catch { }
1718
+ catch (error) {
1719
+ // Routes that could not be read may come back on a later start: until then no job is said to be retired.
1720
+ if (error.code !== 'ENOENT') {
1721
+ this._resourceRoutesUnread = true;
1722
+ console.warn(`[buyer-proxy] Could not read saved video routes: ${error instanceof Error ? error.message : String(error)}`);
1723
+ }
1724
+ }
1652
1725
  // Adopt persisted session overrides (peer pin, default routed model) so
1653
1726
  // they survive daemon restart. A --peer CLI flag beats the persisted pin
1654
1727
  // at startup; runtime `connection set` writes still take over via the
@@ -1909,8 +1982,50 @@ export class BuyerProxy {
1909
1982
  }).catch(() => { });
1910
1983
  return this._stateWriteChain;
1911
1984
  }
1985
+ async _serializeVideoJob(job, run) {
1986
+ const previous = this._videoJobQueues.get(job) ?? Promise.resolve();
1987
+ const current = previous.then(run, run);
1988
+ const tail = current.catch(() => { });
1989
+ this._videoJobQueues.set(job, tail);
1990
+ try {
1991
+ await current;
1992
+ }
1993
+ finally {
1994
+ if (this._videoJobQueues.get(job) === tail)
1995
+ this._videoJobQueues.delete(job);
1996
+ }
1997
+ }
1998
+ /**
1999
+ * Retires a video job that failed or that its seller no longer has: later
2000
+ * retrieves get `video_route_not_found` and never reach the seller, so it can
2001
+ * never be delivered or charged, and a gateway may release the price it held
2002
+ * for it. False (route kept) when the retirement could not be saved, so a
2003
+ * restart cannot bring the job back.
2004
+ */
2005
+ async _retireVideoJob(protocol, resourceId) {
2006
+ if (!this._resourceRoutes.resolve(protocol, resourceId))
2007
+ return true;
2008
+ // The route stays usable until the retirement is saved: a retrieve arriving meanwhile waits its turn
2009
+ // (retrieves of one job run one at a time) instead of seeing the job as gone before that is durable.
2010
+ const key = `${protocol}\n${resourceId}`;
2011
+ this._retiringVideoJobs.add(key);
2012
+ try {
2013
+ await this._persistResourceRoutes();
2014
+ this._resourceRoutes.forget(protocol, resourceId);
2015
+ return true;
2016
+ }
2017
+ catch (error) {
2018
+ console.error('[proxy] Retired video route was not saved; the job is kept:', error);
2019
+ return false;
2020
+ }
2021
+ finally {
2022
+ this._retiringVideoJobs.delete(key);
2023
+ }
2024
+ }
1912
2025
  async _persistResourceRoutes() {
1913
- const write = this._stateWriteChain.then(() => mergeJsonStateFile(this._stateDir, this._stateFile, { resourceRoutes: this._resourceRoutes.snapshot() }));
2026
+ const write = this._stateWriteChain.then(() => mergeJsonStateFile(this._stateDir, this._stateFile, {
2027
+ resourceRoutes: this._resourceRoutes.snapshot().filter((route) => !this._retiringVideoJobs.has(`${route.protocol}\n${route.resourceId}`)),
2028
+ }));
1914
2029
  this._stateWriteChain = write.catch(() => { });
1915
2030
  return write;
1916
2031
  }
@@ -3247,7 +3362,10 @@ export class BuyerProxy {
3247
3362
  // Re-classify with the body: some APIs (Venice) carry the job ID in it.
3248
3363
  const prepared = prepareVideoRequest(nativeVideoRoute(serializedReq) ?? nativeVideo, serializedReq.headers, this._resourceRoutes);
3249
3364
  if ('error' in prepared) {
3250
- res.writeHead(prepared.error.statusCode, { 'content-type': 'application/json' });
3365
+ // No route for the job: this buyer can never deliver or charge it. Unless its saved routes could not be
3366
+ // read at start, when the job may still be there on the next one.
3367
+ const retired = prepared.error.body.error.code === 'video_route_not_found' && !this._resourceRoutesUnread ? { [VIDEO_JOB_RETIRED_HEADER]: '1' } : {};
3368
+ res.writeHead(prepared.error.statusCode, { 'content-type': 'application/json', ...retired });
3251
3369
  res.end(JSON.stringify(prepared.error.body));
3252
3370
  return;
3253
3371
  }
@@ -3386,6 +3504,11 @@ export class BuyerProxy {
3386
3504
  const conversationId = this._trackRouterConversation(context);
3387
3505
  if (conversationId && selection.service)
3388
3506
  this._conversations.setLastRouter(conversationId, `${selection.service.peerId}@${selection.service.serviceId}`);
3507
+ // Merged into whatever answer this request gets, so a gateway can tell routed requests (and failed rankings) apart.
3508
+ if (selection.service) {
3509
+ res.setHeader(ROUTER_PEER_ID_HEADER, selection.service.peerId.toLowerCase());
3510
+ res.setHeader(ROUTER_SERVICE_HEADER, selection.service.serviceId);
3511
+ }
3389
3512
  let recommendations;
3390
3513
  try {
3391
3514
  const client = this._modelRoutingClient;
@@ -3403,7 +3526,7 @@ export class BuyerProxy {
3403
3526
  signal: AbortSignal.any([signal, AbortSignal.timeout(120_000)]),
3404
3527
  onRoutingRequest: requestId => {
3405
3528
  if (spendAttributionTag)
3406
- this._spendAttribution.track(requestId, spendAttributionTag);
3529
+ this._spendAttribution.track(requestId, spendAttributionTag, 'routing');
3407
3530
  if (conversationId)
3408
3531
  this._trackRequestConversation(requestId, conversationId, undefined, 'routing');
3409
3532
  },
@@ -3426,10 +3549,11 @@ export class BuyerProxy {
3426
3549
  return;
3427
3550
  }
3428
3551
  let lastFailure = null;
3429
- for (const recommendation of recommendations) {
3552
+ for (const [index, recommendation] of recommendations.entries()) {
3430
3553
  // Never switch sellers once the user has disconnected or a response has started.
3431
3554
  if (signal.aborted || res.headersSent || res.writableEnded)
3432
3555
  return;
3556
+ res.setHeader(ROUTER_RANK_HEADER, String(index + 1));
3433
3557
  // Each attempt has its own billing ID, but all attempts count as one chat turn.
3434
3558
  const attempt = requestForRecommendation(request, recommendation, randomUUID());
3435
3559
  if (spendAttributionTag)
@@ -3703,7 +3827,7 @@ export class BuyerProxy {
3703
3827
  ({ candidatePeers: modelPeers, routePlanByPeerId: modelPlans } = selectModelPeers(discoveredPeers));
3704
3828
  }
3705
3829
  const router = this._node.router;
3706
- const { candidates, effectivePolicy, explored } = this._planModelRoutes({
3830
+ const { candidates, effectivePolicy, explored, selections, firstPick } = this._planModelRoutes({
3707
3831
  modelPeers,
3708
3832
  modelPlans,
3709
3833
  request: serializedReq,
@@ -3798,6 +3922,18 @@ export class BuyerProxy {
3798
3922
  continue;
3799
3923
  }
3800
3924
  }
3925
+ // Why this seller serves the request, for the gateway's request log. A
3926
+ // seller that fails is replaced by the next one's before any header goes out.
3927
+ const selection = selections.get(selected);
3928
+ if (selection && !res.headersSent) {
3929
+ res.setHeader(ROUTE_SELECTION_HEADER, encodeServedSelection({
3930
+ ...selection,
3931
+ rank: index + 1,
3932
+ of: candidates.length,
3933
+ attempt: index,
3934
+ pick: index === 0 ? firstPick : failedSellers.size > 0 || lastRetry !== null ? 'failover' : 'order',
3935
+ }));
3936
+ }
3801
3937
  for (let peerAttempt = 0; peerAttempt < MODEL_RATE_LIMIT_MAX_ATTEMPTS_PER_PEER; peerAttempt += 1) {
3802
3938
  log(`Auto-selected peer ${selected.peer.peerId.slice(0, 12)}... for model="${requestedService}" `
3803
3939
  + `service="${selected.serviceId}" reputation=${selected.reputation} `
@@ -4139,7 +4275,7 @@ export class BuyerProxy {
4139
4275
  + `did not announce for this service: ${unannounced.join(', ')} — the upstream may ignore or reject them`);
4140
4276
  }
4141
4277
  }
4142
- const { 'x-antseed-pin-peer': _pinPeer, 'x-antseed-prefer-peer': _preferPeer, [BUYER_IDENTITY_HEADER]: buyerIdentity, [REQUIRED_PARAMETERS_HEADER]: _requiredParameters, 'x-vpr-session-id': _vprSession,
4278
+ const { 'x-antseed-pin-peer': _pinPeer, 'x-antseed-prefer-peer': _preferPeer, [BUYER_IDENTITY_HEADER]: buyerIdentity, [MAX_VIDEO_COST_HEADER]: maxVideoCostHeader, [REQUIRED_PARAMETERS_HEADER]: _requiredParameters, 'x-vpr-session-id': _vprSession,
4143
4279
  // Legacy desktop builds (pre AntStation → VPR rename) still send this.
4144
4280
  'x-antstation-session-id': _antstationSession, ...headersForPeer } = serializedReq.headers;
4145
4281
  let requestForPeer = {
@@ -4213,9 +4349,18 @@ export class BuyerProxy {
4213
4349
  const wantsStreaming = clientWantsStreaming && !videoRoute;
4214
4350
  if (videoRoute?.action === 'retrieve') {
4215
4351
  // The seller hands a job only to the buyer that created it: retrieve with the same wallet.
4216
- await downloadVideo(requestForPeer, res, (request, callbacks, signal) => this._node.sendRequestStream(selectedPeer, request, callbacks, {
4217
- signal, pinned: true, ...(buyerIdentity ? { buyerIdentity } : {}),
4218
- }), requestSignal);
4352
+ const { protocol, resourceId } = videoRoute;
4353
+ await this._serializeVideoJob(`${protocol}\n${resourceId ?? ''}`, async () => {
4354
+ // Checked again in turn: a retrieve queued behind one that retired the job must not reach the seller.
4355
+ if (!resourceId || !this._resourceRoutes.resolve(protocol, resourceId)) {
4356
+ res.writeHead(404, { 'content-type': 'application/json', [VIDEO_JOB_RETIRED_HEADER]: '1' });
4357
+ res.end(JSON.stringify({ error: { code: 'video_route_not_found', message: 'Unknown video job' } }));
4358
+ return;
4359
+ }
4360
+ await downloadVideo(requestForPeer, res, (request, callbacks, signal) => this._node.sendRequestStream(selectedPeer, request, callbacks, {
4361
+ signal, pinned: true, ...(buyerIdentity ? { buyerIdentity } : {}),
4362
+ }), requestSignal, () => this._retireVideoJob(protocol, resourceId));
4363
+ });
4219
4364
  return { done: true };
4220
4365
  }
4221
4366
  const peerResponseProtocol = selectedRoutePlan.selection?.targetProtocol ?? requestProtocol;
@@ -4370,10 +4515,12 @@ export class BuyerProxy {
4370
4515
  return { done: true, emptyAnswer: answeredEmpty };
4371
4516
  }
4372
4517
  else {
4518
+ const maxVideoCostUsdc = videoRoute?.action === 'create' ? parseUsdcBaseUnits(maxVideoCostHeader) : null;
4373
4519
  const upstreamResponse = await this._node.sendRequest(selectedPeer, requestForPeer, {
4374
4520
  signal: attemptSignal,
4375
4521
  pinned,
4376
4522
  ...(buyerIdentity ? { buyerIdentity } : {}),
4523
+ ...(maxVideoCostUsdc !== null ? { maxVideoCostUsdc } : {}),
4377
4524
  });
4378
4525
  if (upstreamResponse.statusCode >= 400 && !adaptResponse) {
4379
4526
  log(`Upstream raw error detail: ${summarizeErrorResponse(upstreamResponse)}`);