@antseed/cli 0.1.194 → 0.1.195

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.
@@ -8,9 +8,9 @@ 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, 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, } from '@antseed/node';
12
12
  import { canonicalModelKey } from '@antseed/node/model-identity';
13
- import { createStreamingAdapter, detectRequestServiceApiProtocol, transformRequest, transformResponse, } from './service-api-adapter.js';
13
+ import { createStreamingAdapter, detectRequestServiceApiProtocol, extractRequestBodyFields, transformRequest, transformResponse, } from './service-api-adapter.js';
14
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
15
  import { BuyerIdentityLoader } from '../buyer-identities/loader.js';
16
16
  import { buildNetworkModels, effectiveModelReputationScore, parseModelTypeFilter, } from './network-models.js';
@@ -26,6 +26,7 @@ import { DEPOSIT_WATCH_ACTIVE_TTL_MS } from './deposit-watcher.js';
26
26
  import { DepositLog } from './deposit-log.js';
27
27
  import { recordPeerFailureEntry, clearPeerHealthEntry, isCoolingDown, parsePersistedPeerHealth, prunePeerHealth, serializePeerHealth, reasonEscalates, } from './peer-health.js';
28
28
  import { PeerAttributionTracker, HEARTBEAT_MS } from './peer-attribution.js';
29
+ import { hasStreamError, isEmptyAnswer, isSellerVerificationFailure } from './response-quality.js';
29
30
  import { estimateAnthropicPromptTokens, isCountTokensPath } from './count-tokens.js';
30
31
  import { runVerifier, selectVerifier, verifierSupportFingerprint } from '../plugins/verifier.js';
31
32
  import { TEE_VERIFIER_ID } from '@antseed/node/tee-status';
@@ -62,24 +63,8 @@ const BALANCE_FRESH_FLOOR_MS = 5_000;
62
63
  const CONTROL_SECRET_RECHECK_MS = 1_000;
63
64
  /** Floor between forced re-reads on a secret mismatch, so a stream of bad secrets can't hammer the disk. */
64
65
  const CONTROL_SECRET_FORCED_RECHECK_MS = 100;
65
- const LATENCY_EMA_ALPHA = 0.3;
66
- const MAX_LATENCY_ENTRIES = 4096;
67
- /** Responses with fewer output tokens than this say little about a seller's generation speed. */
68
- const MIN_THROUGHPUT_TOKENS = 20;
66
+ const SELLER_HEALTH_PERSIST_DELAY_MS = 5_000;
69
67
  const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000';
70
- /** Moves `key`'s EMA toward `sample`; the map stays least recently measured first and bounded. */
71
- function updateEma(map, key, sample) {
72
- if (!Number.isFinite(sample) || sample < 0)
73
- return;
74
- const previous = map.get(key);
75
- map.delete(key);
76
- map.set(key, previous === undefined ? sample : previous + LATENCY_EMA_ALPHA * (sample - previous));
77
- if (map.size > MAX_LATENCY_ENTRIES) {
78
- const oldest = map.keys().next().value;
79
- if (oldest !== undefined)
80
- map.delete(oldest);
81
- }
82
- }
83
68
  /** Exact USDC decimal string from base units, e.g. 1500000n -> "1.500000". */
84
69
  function formatUsdcBaseUnits(amount) {
85
70
  const sign = amount < 0n ? '-' : '';
@@ -114,10 +99,49 @@ function retryableStatusCodesFor(gatewayAutoRoute) {
114
99
  * Whether the routing loop should try the next seller. On automatic routing a
115
100
  * seller's model_not_found also moves on: its cached catalog was stale (the
116
101
  * seller stopped serving the model), and another seller may still serve it.
117
- * A pinned request keeps the pinned seller's answer.
102
+ * `extraAttempt` also moves on after any other 4xx except 402: a client error
103
+ * may be the seller's doing (it refuses a parameter other sellers of the
104
+ * model accept) or the request's, and only another seller can
105
+ * tell which. A pinned request keeps the pinned seller's answer.
106
+ */
107
+ function shouldTryNextPeer(statusCode, modelNotFound, pinned, gatewayAutoRoute, extraAttempt = false) {
108
+ return retryableStatusCodesFor(gatewayAutoRoute).has(statusCode)
109
+ || (modelNotFound && !pinned)
110
+ || (extraAttempt && !pinned && statusCode >= 400 && statusCode < 500 && statusCode !== 402);
111
+ }
112
+ function classifyFailedAttempt(result) {
113
+ if (result.emptyAnswer)
114
+ return { description: 'answered with no text or tool call', sellerOnly: false, weight: 1 };
115
+ if (result.timedOut)
116
+ return { description: result.errorMessage ?? 'no answer in time', sellerOnly: true, weight: 0.5 };
117
+ if (result.errorMessage)
118
+ return { description: `request failed (${result.errorMessage.split('\n')[0].slice(0, 120)})`, sellerOnly: true, weight: 1 };
119
+ // Busy is not broken: it costs the seller less, and only when another seller served.
120
+ if (result.statusCode === 429)
121
+ return { description: 'HTTP 429 (busy)', sellerOnly: false, weight: 0.5 };
122
+ const sellerOnly = result.statusCode >= 500 || result.statusCode === 401 || result.statusCode === 403 || result.statusCode === 408;
123
+ return { description: `HTTP ${result.statusCode}`, sellerOnly, weight: 1 };
124
+ }
125
+ /**
126
+ * The client's own prompt-cache key (OpenAI `prompt_cache_key`), which asks
127
+ * for requests sharing it to land where their cache is.
118
128
  */
119
- function shouldTryNextPeer(statusCode, modelNotFound, pinned, gatewayAutoRoute) {
120
- return retryableStatusCodesFor(gatewayAutoRoute).has(statusCode) || (modelNotFound && !pinned);
129
+ function promptCacheKey(request) {
130
+ const value = extractRequestBodyFields(request.headers, request.body)?.['prompt_cache_key'];
131
+ return typeof value === 'string' && value.trim() ? `cache:${value.trim().slice(0, 256)}` : null;
132
+ }
133
+ /**
134
+ * Size of a request, for weighing a seller's price against its speed: input
135
+ * from the body size (~4 bytes a token), output from the requested cap.
136
+ */
137
+ function requestShape(request) {
138
+ const fields = extractRequestBodyFields(request.headers, request.body);
139
+ const cap = [fields?.['max_tokens'], fields?.['max_completion_tokens'], fields?.['max_output_tokens']]
140
+ .find((value) => typeof value === 'number' && Number.isFinite(value) && value > 0);
141
+ return {
142
+ inputTokens: Math.round(request.body.length / 4),
143
+ outputTokens: Math.min(cap ?? 400, 2_000),
144
+ };
121
145
  }
122
146
  const MODEL_RATE_LIMIT_MAX_ATTEMPTS_PER_PEER = 3;
123
147
  const MODEL_RATE_LIMIT_RETRY_DELAYS_MS = [250, 750];
@@ -793,6 +817,15 @@ export class BuyerProxy {
793
817
  _peerHealth = new Map();
794
818
  /** Decides whether a failure is the peer's fault at all. */
795
819
  _attribution = new PeerAttributionTracker();
820
+ /**
821
+ * What this buyer has seen of each seller and model: how often requests
822
+ * succeed and how fast answers come. Automatic routing ranks by it.
823
+ */
824
+ _sellerHealth = new SellerHealthBook();
825
+ /** Requests in flight per seller, to stop sending a seller more than it says it can take at once. */
826
+ _inFlightBySeller = new Map();
827
+ _peerHealthPersistTimer = null;
828
+ _adaptiveRouting;
796
829
  _heartbeatHandle = null;
797
830
  _now;
798
831
  /** Latest relayer receipt per sweep authNonce, for CLI progress polling. */
@@ -824,14 +857,6 @@ export class BuyerProxy {
824
857
  _controlSecretCheckedAt = 0;
825
858
  _controlSecretForcedAt = 0;
826
859
  _loggedUnauthenticatedPolicy = false;
827
- /**
828
- * Per-seller speed measured by this proxy on streamed requests, as EMAs:
829
- * time to the first response frame (ms) and output tokens per second after
830
- * it. Kept apart so a long answer doesn't read as a slow seller. Latency
831
- * feeds `sort: 'latency'`; sellers without a measurement rank after measured ones.
832
- */
833
- _peerLatencyMs = new Map();
834
- _peerTokensPerSecond = new Map();
835
860
  _balanceReader;
836
861
  _balanceCache = new ChainReadCache();
837
862
  _restartHandler;
@@ -873,6 +898,7 @@ export class BuyerProxy {
873
898
  }
874
899
  : null;
875
900
  this._now = config.now ?? (() => Date.now());
901
+ this._adaptiveRouting = { ...config.adaptiveRouting };
876
902
  this._server = createServer((req, res) => {
877
903
  this._handleRequest(req, res).catch((err) => {
878
904
  log('Unhandled error:', err);
@@ -900,6 +926,17 @@ export class BuyerProxy {
900
926
  this._spendAttribution.record(event);
901
927
  });
902
928
  }
929
+ const responseAuthNode = this._node;
930
+ if (typeof responseAuthNode.on === 'function') {
931
+ responseAuthNode.on('response-auth', (event) => {
932
+ if (event.verified || !isSellerVerificationFailure(event.verificationError))
933
+ return;
934
+ // A forged or mismatched receipt can only be the seller's doing: it
935
+ // counts as several failed requests.
936
+ this._recordSellerFailure(event.sellerPeerId, event.advertisedService, `response failed verification (${event.verificationError})`, 3);
937
+ log(`Peer ${event.sellerPeerId.slice(0, 12)}... response failed verification (${event.verificationError}); counted against its ${event.advertisedService} health.`);
938
+ });
939
+ }
903
940
  const eventNode = this._node;
904
941
  if (typeof eventNode.on === 'function') {
905
942
  eventNode.on('peers:discovered', (peers) => {
@@ -1091,24 +1128,55 @@ export class BuyerProxy {
1091
1128
  teeCapable: parseVerifierCapabilities(peer.capabilities).supported.includes(TEE_VERIFIER_ID),
1092
1129
  };
1093
1130
  }
1094
- /** Latency is tracked per seller and model: one seller can serve a small model fast and a large one slowly. */
1095
- _latencyKey(peerId, serviceId) {
1096
- return `${peerId.toLowerCase()}|${serviceId ? policyModelKey(serviceId) : ''}`;
1131
+ /** Health is tracked per seller and model: one seller can serve a small model fast and a large one slowly. */
1132
+ _healthServiceKey(serviceId) {
1133
+ return serviceId ? policyModelKey(serviceId) : null;
1097
1134
  }
1098
1135
  _peerLatency(peerId, serviceId) {
1099
- return this._peerLatencyMs.get(this._latencyKey(peerId, serviceId)) ?? null;
1136
+ return this._sellerHealth.stats(peerId, this._healthServiceKey(serviceId), this._now())?.firstTokenMs ?? null;
1100
1137
  }
1101
1138
  _peerThroughput(peerId, serviceId) {
1102
- return this._peerTokensPerSecond.get(this._latencyKey(peerId, serviceId)) ?? null;
1139
+ return this._sellerHealth.stats(peerId, this._healthServiceKey(serviceId), this._now())?.tokensPerSecond ?? null;
1103
1140
  }
1104
- /** One streamed request: time to its first frame, then output tokens over the rest of the stream. */
1105
- _recordPeerSpeed(peerId, serviceId, firstFrameMs, streamMs, outputTokens) {
1106
- const key = this._latencyKey(peerId, serviceId);
1107
- updateEma(this._peerLatencyMs, key, firstFrameMs);
1108
- if (outputTokens >= MIN_THROUGHPUT_TOKENS && streamMs > 0) {
1109
- updateEma(this._peerTokensPerSecond, key, outputTokens / (streamMs / 1000));
1141
+ /**
1142
+ * Sellers the paying identity already has an open payment channel with, so
1143
+ * routing can spread traffic without reserving funds on new sellers. Null
1144
+ * when the node cannot say.
1145
+ */
1146
+ _openChannelPeers(buyerIdentity) {
1147
+ const node = this._node;
1148
+ if (typeof node.getActiveBuyerChannels !== 'function')
1149
+ return null;
1150
+ try {
1151
+ return new Set(node.getActiveBuyerChannels(buyerIdentity).map((channel) => channel.peerId.toLowerCase()));
1152
+ }
1153
+ catch {
1154
+ return null;
1110
1155
  }
1111
1156
  }
1157
+ _attemptTimeoutMs(timedLike, request) {
1158
+ return adaptiveFirstTokenTimeoutMs(this._sellerHealth.stats(timedLike.peerId, this._healthServiceKey(timedLike.serviceId), this._now()), requestShape(request).outputTokens, requestWantsStreaming(request.headers, request.body));
1159
+ }
1160
+ /** A seller answered: count the success and how fast the answer came. */
1161
+ _recordSellerSuccess(peerId, serviceId, timing) {
1162
+ this._sellerHealth.recordSuccess(peerId, this._healthServiceKey(serviceId), this._now(), timing);
1163
+ this._schedulePeerHealthPersist();
1164
+ }
1165
+ /** A seller failed a request: count it against that seller and model. */
1166
+ _recordSellerFailure(peerId, serviceId, reason, weight = 1) {
1167
+ this._sellerHealth.recordFailure(peerId, this._healthServiceKey(serviceId), this._now(), reason, weight);
1168
+ this._schedulePeerHealthPersist();
1169
+ }
1170
+ /** Seller health changes on every request: write it at most every few seconds, not per request. */
1171
+ _schedulePeerHealthPersist() {
1172
+ if (this._peerHealthPersistTimer)
1173
+ return;
1174
+ this._peerHealthPersistTimer = setTimeout(() => {
1175
+ this._peerHealthPersistTimer = null;
1176
+ void this._persistPeerHealthToState();
1177
+ }, SELLER_HEALTH_PERSIST_DELAY_MS);
1178
+ this._peerHealthPersistTimer.unref?.();
1179
+ }
1112
1180
  /**
1113
1181
  * Candidate selection and ranking for a model-only request. The real
1114
1182
  * routing path and `GET /_antseed/route-preview` both call this, so the
@@ -1212,6 +1280,33 @@ export class BuyerProxy {
1212
1280
  candidates = this._readyFirst(ranked, verdicts, now);
1213
1281
  }
1214
1282
  const isCooling = (candidate) => isModelRouteCoolingDown(candidate, now);
1283
+ const openChannels = this._openChannelPeers(request.headers[BUYER_IDENTITY_HEADER]);
1284
+ // Among the sellers the buyer's limits allow, rank by what this buyer has
1285
+ // seen them do: expected cost of a successful answer, spread across the
1286
+ // sellers close to the best. An explicit gateway sort or route chain
1287
+ // reorders afterwards, but still tries unhealthy sellers late.
1288
+ const adaptive = orderAdaptiveRoutes(candidates, (candidate) => ({
1289
+ peerId: candidate.peerId,
1290
+ serviceId: this._healthServiceKey(candidate.serviceId),
1291
+ inputUsdPerMillion: candidate.inputUsdPerMillion,
1292
+ outputUsdPerMillion: candidate.outputUsdPerMillion,
1293
+ requestUsd: candidate.minImageUsdPerImage,
1294
+ coolingDown: isCooling(candidate),
1295
+ ...(openChannels ? { channelOpen: modelRouteTotalPrice(candidate) === 0 || openChannels.has(candidate.peerId.toLowerCase()) } : {}),
1296
+ saturated: typeof candidate.peer.maxConcurrency === 'number' && candidate.peer.maxConcurrency > 0
1297
+ && (this._inFlightBySeller.get(candidate.peerId.toLowerCase()) ?? 0) >= candidate.peer.maxConcurrency,
1298
+ }), this._sellerHealth, requestShape(request), {
1299
+ now,
1300
+ affinityKey: input.affinityKey ?? null,
1301
+ ...this._adaptiveRouting,
1302
+ ...(input.deterministic ? { spreadTolerance: 0, explorationRate: 0 } : {}),
1303
+ });
1304
+ // Without routing preferences the buyer ranks by reputation, as before;
1305
+ // health only moves sellers that fail often behind the others.
1306
+ candidates = routingPreferences
1307
+ ? adaptive.ordered
1308
+ : [...candidates].sort((a, b) => Number(adaptive.scores.get(a).unhealthy) - Number(adaptive.scores.get(b).unhealthy));
1309
+ const healthOf = (candidate) => adaptive.scores.get(candidate);
1215
1310
  let chainPosition = null;
1216
1311
  if (effectivePolicy) {
1217
1312
  const ordering = orderByPolicy(candidates, effectivePolicy, requestedService, {
@@ -1220,6 +1315,7 @@ export class BuyerProxy {
1220
1315
  trustScore: (candidate) => (candidate.reputation >= 0 ? candidate.reputation : null),
1221
1316
  latencyMs: (candidate) => this._peerLatency(candidate.peerId, candidate.serviceId),
1222
1317
  coolingDown: isCooling,
1318
+ unhealthy: (candidate) => healthOf(candidate).unhealthy,
1223
1319
  });
1224
1320
  for (const candidate of candidates) {
1225
1321
  const reason = ordering.excluded.get(normalizePolicyPeerId(candidate.peerId));
@@ -1234,7 +1330,8 @@ export class BuyerProxy {
1234
1330
  const preferredPeerId = input.preferredPeerId;
1235
1331
  if (preferredPeerId && !findModelRoute(effectivePolicy, requestedService)) {
1236
1332
  const preferredIndex = candidates.findIndex((candidate) => (candidate.peer.peerId.toLowerCase() === preferredPeerId
1237
- && !isCoolingDown(this._peerHealth.get(candidate.peer.peerId), now)));
1333
+ && !isCoolingDown(this._peerHealth.get(candidate.peer.peerId), now)
1334
+ && !healthOf(candidate).unhealthy));
1238
1335
  if (preferredIndex > 0) {
1239
1336
  const [preferred] = candidates.splice(preferredIndex, 1);
1240
1337
  if (preferred)
@@ -1250,18 +1347,24 @@ export class BuyerProxy {
1250
1347
  const position = chainPosition?.get(normalizePolicyPeerId(candidate.peerId));
1251
1348
  if (position !== undefined)
1252
1349
  verdict.reasons.push(`route chain #${position + 1}`);
1350
+ const health = healthOf(candidate);
1253
1351
  if (isCooling(candidate))
1254
1352
  verdict.reasons.push('cooling down (tried last)');
1353
+ else if (health.unhealthy)
1354
+ verdict.reasons.push(`failing lately: ${health.stats?.lastFailureReason ?? 'failed requests'} (tried after healthy sellers)`);
1355
+ if (adaptive.pool.length > 1 && adaptive.pool.includes(candidate))
1356
+ verdict.reasons.push(`shares traffic with ${adaptive.pool.length - 1} other seller(s)`);
1255
1357
  if (modelRouteTotalPrice(candidate) === 0)
1256
1358
  verdict.reasons.push('free');
1359
+ verdict.reasons.push(`success ~${Math.round(health.successProbability * 100)}%${health.stats ? '' : ' (no data yet)'}`);
1257
1360
  const latency = this._peerLatency(candidate.peerId, candidate.serviceId);
1258
1361
  if (latency !== null)
1259
- verdict.reasons.push(`latency ~${Math.round(latency)}ms`);
1362
+ verdict.reasons.push(`first token ~${Math.round(latency)}ms`);
1260
1363
  const throughput = this._peerThroughput(candidate.peerId, candidate.serviceId);
1261
1364
  if (throughput !== null)
1262
1365
  verdict.reasons.push(`~${Math.round(throughput)} tok/s`);
1263
1366
  });
1264
- return { candidates, verdicts: [...verdicts.values()], effectivePolicy };
1367
+ return { candidates, verdicts: [...verdicts.values()], effectivePolicy, explored: adaptive.explored };
1265
1368
  }
1266
1369
  /**
1267
1370
  * Without routing preferences: best effective reputation first, and only
@@ -1305,6 +1408,7 @@ export class BuyerProxy {
1305
1408
  requiredParameters: [],
1306
1409
  requestPolicy,
1307
1410
  preferredPeerId: null,
1411
+ deterministic: true,
1308
1412
  });
1309
1413
  const effective = plan.effectivePolicy;
1310
1414
  const modelAllowed = policyAllowsModel(effective, model);
@@ -1573,6 +1677,7 @@ export class BuyerProxy {
1573
1677
  // impossibly distant, so a restart can never extend one. Nothing new can
1574
1678
  // escalate until a success re-establishes that the buyer is healthy.
1575
1679
  this._peerHealth = parsePersistedPeerHealth(parsed, this._now());
1680
+ this._sellerHealth = SellerHealthBook.parse(parsed.sellerHealth, this._now());
1576
1681
  const peers = parsePersistedPeers(parsed);
1577
1682
  if (peers.length === 0) {
1578
1683
  return;
@@ -1596,6 +1701,11 @@ export class BuyerProxy {
1596
1701
  async stop() {
1597
1702
  this._teeVerification.close();
1598
1703
  await this._teeControl.close();
1704
+ if (this._peerHealthPersistTimer) {
1705
+ clearTimeout(this._peerHealthPersistTimer);
1706
+ this._peerHealthPersistTimer = null;
1707
+ await this._persistPeerHealthToState().catch(() => { });
1708
+ }
1599
1709
  if (this._stateWatchDebounce) {
1600
1710
  clearTimeout(this._stateWatchDebounce);
1601
1711
  this._stateWatchDebounce = null;
@@ -2107,6 +2217,7 @@ export class BuyerProxy {
2107
2217
  this._peerHealth = prunePeerHealth(this._peerHealth, now);
2108
2218
  await this._mergeStateFile({
2109
2219
  peerHealth: serializePeerHealth(this._peerHealth),
2220
+ sellerHealth: this._sellerHealth.serialize(now),
2110
2221
  peerHealthUpdatedAt: now,
2111
2222
  });
2112
2223
  }
@@ -2305,6 +2416,18 @@ export class BuyerProxy {
2305
2416
  suppressedUntil: attribution.suppressedUntil,
2306
2417
  lastSuppressedBy: attribution.lastSuppressedBy,
2307
2418
  peers,
2419
+ // What automatic routing has learned of each seller and model.
2420
+ sellerHealth: this._sellerHealth.entries(now).map(({ peerId, serviceId, stats }) => ({
2421
+ peerId,
2422
+ serviceId,
2423
+ successes: Math.round(stats.successes * 100) / 100,
2424
+ failures: Math.round(stats.failures * 100) / 100,
2425
+ firstTokenMs: stats.firstTokenMs === null ? null : Math.round(stats.firstTokenMs),
2426
+ tokensPerSecond: stats.tokensPerSecond === null ? null : Math.round(stats.tokensPerSecond),
2427
+ lastSuccessAt: stats.lastSuccessAt,
2428
+ lastFailureAt: stats.lastFailureAt,
2429
+ lastFailureReason: stats.lastFailureReason,
2430
+ })),
2308
2431
  }));
2309
2432
  return;
2310
2433
  }
@@ -2339,6 +2462,8 @@ export class BuyerProxy {
2339
2462
  // Deliberately does not stamp a success: the user is asking us to give
2340
2463
  // the peer another chance, not asserting that it answered.
2341
2464
  this._rollbackPeerHealth([normalized], 'cleared by request');
2465
+ this._sellerHealth.forget(normalized);
2466
+ void this._persistPeerHealthToState();
2342
2467
  res.writeHead(200, { 'content-type': 'application/json' });
2343
2468
  res.end(JSON.stringify({ ok: true, peerId: normalized }));
2344
2469
  return;
@@ -3559,7 +3684,7 @@ export class BuyerProxy {
3559
3684
  ({ candidatePeers: modelPeers, routePlanByPeerId: modelPlans } = selectModelPeers(discoveredPeers));
3560
3685
  }
3561
3686
  const router = this._node.router;
3562
- const { candidates, effectivePolicy } = this._planModelRoutes({
3687
+ const { candidates, effectivePolicy, explored } = this._planModelRoutes({
3563
3688
  modelPeers,
3564
3689
  modelPlans,
3565
3690
  request: serializedReq,
@@ -3569,6 +3694,9 @@ export class BuyerProxy {
3569
3694
  requiredParameters,
3570
3695
  requestPolicy,
3571
3696
  preferredPeerId: preferredConversationPeerId,
3697
+ // Conversations the client identifies (session id, prompt-cache key)
3698
+ // stay on one seller so its prompt cache keeps paying off.
3699
+ affinityKey: routingConversationKey ?? promptCacheKey(serializedReq),
3572
3700
  });
3573
3701
  const requireVerified = effectivePolicy?.requireVerified === true;
3574
3702
  if (candidates.length === 0) {
@@ -3613,6 +3741,31 @@ export class BuyerProxy {
3613
3741
  // Set once a seller refused for lack of deposits: every other paid
3614
3742
  // seller would too, so only free ones are still worth trying.
3615
3743
  let unfunded = false;
3744
+ // Sellers that failed this request, whatever the failure: an error
3745
+ // status, a dropped request, an empty answer. If a later seller serves
3746
+ // the same request, they could have too, and each failure counts
3747
+ // against its seller's health. If none does, the request itself may be
3748
+ // at fault: only failures that can only be the seller's count, and only
3749
+ // while the buyer itself is known to be healthy.
3750
+ const failedSellers = new Map();
3751
+ const countFailures = (servedBy) => {
3752
+ const buyerHealthy = this._attribution.isBuyerHealthy(this._now());
3753
+ for (const [peerId, { serviceId, failure }] of failedSellers) {
3754
+ // A seller that recovered on its own retry (a 429, then an answer)
3755
+ // proved nothing against itself.
3756
+ if (peerId === servedBy)
3757
+ continue;
3758
+ if (servedBy === null && (!failure.sellerOnly || !buyerHealthy))
3759
+ continue;
3760
+ const detail = servedBy ? `${failure.description} (peer ${servedBy.slice(0, 12)}... served it)` : failure.description;
3761
+ this._recordSellerFailure(peerId, serviceId, detail, failure.weight);
3762
+ log(`Peer ${peerId.slice(0, 12)}... ${detail}; counted against its ${serviceId} health.`);
3763
+ }
3764
+ };
3765
+ // Failures that only maybe are the seller's (a client error, an empty
3766
+ // answer) get one extra seller to compare against, not the whole list:
3767
+ // when every seller fails, the client gets that answer quickly.
3768
+ let comparisonUsed = false;
3616
3769
  const isFree = (candidate) => modelRouteTotalPrice(candidate) === 0;
3617
3770
  for (const [index, selected] of candidates.entries()) {
3618
3771
  if (unfunded && !isFree(selected))
@@ -3631,11 +3784,29 @@ export class BuyerProxy {
3631
3784
  + `service="${selected.serviceId}" reputation=${selected.reputation} `
3632
3785
  + `effective=${selected.effectiveReputationScore ?? 'unknown'} peer=${index + 1}/${candidates.length} `
3633
3786
  + `attempt=${peerAttempt + 1}/${MODEL_RATE_LIMIT_MAX_ATTEMPTS_PER_PEER}`);
3634
- const result = await this._dispatchToPeer(res, selected.request, selected.peer, modelPlans, requestProtocol, selected.serviceId, explicitProvider, router, false, signal, requestPolicy !== null, routingConversationKey);
3787
+ const result = await this._dispatchToPeer(res, selected.request, selected.peer, modelPlans, requestProtocol, selected.serviceId, explicitProvider, router, false, signal, requestPolicy !== null, routingConversationKey, !comparisonUsed && index + 1 < candidates.length,
3788
+ // With another seller to fall back on, don't wait far longer than
3789
+ // this one usually takes; the last seller gets the full timeout. A
3790
+ // probe of a seller with no recent data waits no longer than the
3791
+ // seller it stands in for would.
3792
+ index + 1 < candidates.length && !nativeVideo
3793
+ ? this._attemptTimeoutMs(selected === explored ? candidates[index + 1] : selected, selected.request)
3794
+ : null);
3635
3795
  if (result.done) {
3796
+ // A stream that broke after its headers proves nothing for the
3797
+ // sellers before it, and counts against this one like any other
3798
+ // transport failure.
3799
+ if (result.interrupted === 'seller') {
3800
+ failedSellers.set(selected.peer.peerId, {
3801
+ serviceId: selected.serviceId,
3802
+ failure: { description: 'stream interrupted', sellerOnly: true, weight: 1 },
3803
+ });
3804
+ }
3805
+ const answered = res.statusCode < 400 && !result.emptyAnswer && !result.interrupted;
3806
+ countFailures(answered ? selected.peer.peerId : null);
3636
3807
  // Only a seller that answered becomes the chat's route: an error
3637
3808
  // must not pin later turns to it.
3638
- if (trackedConversationId && res.statusCode < 400) {
3809
+ if (trackedConversationId && answered) {
3639
3810
  this._conversations.recordRoutedModel(trackedConversationId, `${selected.peer.peerId}@${selected.serviceId}`, conversationPinAtRouting);
3640
3811
  }
3641
3812
  return result;
@@ -3645,6 +3816,15 @@ export class BuyerProxy {
3645
3816
  if (nativeVideo)
3646
3817
  return result;
3647
3818
  lastRetry = result;
3819
+ if (!isBuyerFault(result) && !result.modelNotFound && result.statusCode !== 402) {
3820
+ const failure = classifyFailedAttempt(result);
3821
+ failedSellers.set(selected.peer.peerId, { serviceId: selected.serviceId, failure });
3822
+ if (!failure.sellerOnly && result.statusCode !== 429) {
3823
+ comparisonUsed = true;
3824
+ log(`Peer ${selected.peer.peerId.slice(0, 12)}... ${failure.description}; asking one more seller.`);
3825
+ break;
3826
+ }
3827
+ }
3648
3828
  if (isBuyerFault(result)) {
3649
3829
  const freeLeft = candidates.slice(index + 1).some(isFree);
3650
3830
  if (result.statusCode === 402 && freeLeft) {
@@ -3667,6 +3847,7 @@ export class BuyerProxy {
3667
3847
  log(`Peer ${selected.peer.peerId.slice(0, 12)}... failed retryably; trying next model peer.`);
3668
3848
  }
3669
3849
  }
3850
+ countFailures(null);
3670
3851
  return lastRetry ?? dispatchFailure(502, { 'content-type': 'application/json' }, JSON.stringify({
3671
3852
  error: {
3672
3853
  type: 'peer_verification_failed',
@@ -3829,7 +4010,13 @@ export class BuyerProxy {
3829
4010
  if (result.done && trackedConversationId && pinnedServiceId) {
3830
4011
  this._conversations.recordRoutedModel(trackedConversationId, `${selectedPeer.peerId}@${pinnedServiceId}`, conversationPinAtRouting);
3831
4012
  }
3832
- // A pinned peer is never retried against another peer; the caller reports any failure.
4013
+ // A pinned peer is never retried against another peer; the caller reports
4014
+ // any failure. Failures only the seller can cause still count against it.
4015
+ if (!result.done && !isBuyerFault(result) && this._attribution.isBuyerHealthy(this._now())) {
4016
+ const failure = classifyFailedAttempt(result);
4017
+ if (failure.sellerOnly)
4018
+ this._recordSellerFailure(selectedPeer.peerId, pinnedServiceId ?? requestedService, failure.description, failure.weight);
4019
+ }
3833
4020
  return result;
3834
4021
  }
3835
4022
  async _verifyPeer(peer, makeReach, signal, requireVerified = false) {
@@ -3915,7 +4102,9 @@ export class BuyerProxy {
3915
4102
  * was sent to the client (success or non-retryable error), or retry info if the
3916
4103
  * caller should try another peer.
3917
4104
  */
3918
- async _dispatchToPeer(res, serializedReq, selectedPeer, routePlanByPeerId, requestProtocol, requestedService, explicitProvider, router, pinned, requestSignal, gatewayRouted = false, routingConversationKey = null) {
4105
+ async _dispatchToPeer(res, serializedReq, selectedPeer, routePlanByPeerId, requestProtocol, requestedService, explicitProvider, router, pinned, requestSignal, gatewayRouted = false, routingConversationKey = null, extraAttempt = false,
4106
+ /** Give up on this seller if it has not started answering by then, so the next one can be tried. */
4107
+ attemptTimeoutMs = null) {
3919
4108
  const gatewayAutoRoute = gatewayRouted && !pinned;
3920
4109
  const selectedRoutePlan = routePlanByPeerId.get(selectedPeer.peerId)
3921
4110
  ?? resolvePeerRoutePlan(selectedPeer, requestProtocol, requestedService, explicitProvider, 'lenient');
@@ -4013,16 +4202,29 @@ export class BuyerProxy {
4013
4202
  const peerResponseProtocol = selectedRoutePlan.selection?.targetProtocol ?? requestProtocol;
4014
4203
  const adaptPeerResponse = (response) => adaptPeerFaultErrorResponse(response, peerResponseProtocol, { pinned });
4015
4204
  const startTime = Date.now();
4205
+ const attemptAbort = new AbortController();
4206
+ const attemptSignal = attemptTimeoutMs ? AbortSignal.any([requestSignal, attemptAbort.signal]) : requestSignal;
4207
+ const attemptTimer = attemptTimeoutMs
4208
+ ? setTimeout(() => attemptAbort.abort(new Error(`no answer within ${Math.round(attemptTimeoutMs / 1000)}s, about 4x this seller's usual time`)), attemptTimeoutMs)
4209
+ : null;
4210
+ attemptTimer?.unref?.();
4211
+ const sellerKey = selectedPeer.peerId.toLowerCase();
4212
+ this._inFlightBySeller.set(sellerKey, (this._inFlightBySeller.get(sellerKey) ?? 0) + 1);
4016
4213
  try {
4017
4214
  if (wantsStreaming) {
4018
4215
  let streamed = false;
4019
4216
  let firstFrameAt = 0;
4217
+ let streamStartBody = new Uint8Array();
4020
4218
  const response = await this._node.sendRequestStream(selectedPeer, requestForPeer, {
4021
4219
  onResponseStart: (startResponse, metadata) => {
4022
4220
  if (!metadata.streaming)
4023
4221
  return;
4222
+ // The seller is answering: from here a slow answer is still an answer.
4223
+ if (attemptTimer)
4224
+ clearTimeout(attemptTimer);
4024
4225
  streamed = true;
4025
4226
  firstFrameAt = Date.now();
4227
+ streamStartBody = startResponse.body;
4026
4228
  const adaptedStartResponse = streamResponseAdapter
4027
4229
  ? streamResponseAdapter.adaptStart(startResponse)
4028
4230
  : startResponse;
@@ -4051,7 +4253,7 @@ export class BuyerProxy {
4051
4253
  }
4052
4254
  }
4053
4255
  },
4054
- }, { signal: requestSignal, pinned, ...(buyerIdentity ? { buyerIdentity } : {}) });
4256
+ }, { signal: attemptSignal, pinned, ...(buyerIdentity ? { buyerIdentity } : {}) });
4055
4257
  recordRouterUsage(this._modelRoutingClient, routingConversationKey, requestForPeer, response, selectedPeer, requestSignal);
4056
4258
  let responseForClient = adaptBuyerFaultErrorResponse(response, requestProtocol);
4057
4259
  responseForClient = adaptPeerResponse(responseForClient);
@@ -4073,8 +4275,9 @@ export class BuyerProxy {
4073
4275
  log(`${prefix}: ${summarizeErrorResponse(responseForClient)}`);
4074
4276
  }
4075
4277
  const telemetry = computeResponseTelemetry(requestForPeer, responseForClient.headers, responseForClient.body, selectedPeer);
4278
+ let streamTiming = null;
4076
4279
  if (streamed && responseForClient.statusCode < 400) {
4077
- this._recordPeerSpeed(selectedPeer.peerId, latencyServiceId, firstFrameAt - startTime, finishedAt - firstFrameAt, telemetry.usage.outputTokens);
4280
+ streamTiming = { firstTokenMs: firstFrameAt - startTime, streamMs: finishedAt - firstFrameAt, outputTokens: telemetry.usage.outputTokens, streamed: true };
4078
4281
  }
4079
4282
  const responseFault = responseFaultAttribution(responseForClient);
4080
4283
  const modelNotFound = !streamed
@@ -4098,30 +4301,58 @@ export class BuyerProxy {
4098
4301
  this._recordPeerResponseHealth(selectedPeer.peerId, responseForClient.statusCode, requestForPeer.path);
4099
4302
  }
4100
4303
  if (streamed) {
4304
+ const streamedBody = Buffer.concat([streamStartBody, response.body]);
4305
+ // The seller's provider failed after the stream started: the wire
4306
+ // ends normally, but the answer never completed.
4307
+ const streamFailed = responseForClient.statusCode < 300
4308
+ && hasStreamError(response.headers['content-type'], streamedBody);
4309
+ const streamedEmpty = !pinned && !streamFailed && responseForClient.statusCode < 300
4310
+ && isEmptyAnswer(response.headers['content-type'], streamedBody);
4311
+ // Already sent, so there is nothing to compare against: an empty
4312
+ // answer counts against the seller by itself.
4313
+ if (streamedEmpty)
4314
+ this._recordSellerFailure(selectedPeer.peerId, latencyServiceId, 'streamed an answer with no text or tool call');
4315
+ else if (streamFailed) {
4316
+ // Auto-routing attributes it with the other attempts; a pinned
4317
+ // request has no other attempts, so count it here.
4318
+ if (pinned && this._attribution.isBuyerHealthy(this._now())) {
4319
+ this._recordSellerFailure(selectedPeer.peerId, latencyServiceId, 'stream interrupted');
4320
+ }
4321
+ }
4322
+ else if (streamTiming)
4323
+ this._recordSellerSuccess(selectedPeer.peerId, latencyServiceId, streamTiming);
4101
4324
  // Headers already sent to client, can't retry
4102
4325
  if (!res.writableEnded) {
4103
4326
  res.end();
4104
4327
  }
4105
- return { done: true };
4328
+ return streamFailed ? { done: true, interrupted: 'seller' } : { done: true, emptyAnswer: streamedEmpty };
4106
4329
  }
4107
4330
  // Non-streamed response — check if retryable
4108
4331
  const responseHeaders = attachAntseedTelemetryHeaders(responseForClient.headers, selectedPeer, telemetry, requestForPeer.requestId, latencyMs);
4109
- if (shouldTryNextPeer(responseForClient.statusCode, modelNotFound, pinned, gatewayAutoRoute)) {
4332
+ const answeredEmpty = !pinned && responseForClient.statusCode < 300
4333
+ && isEmptyAnswer(responseForClient.headers['content-type'], responseForClient.body);
4334
+ const emptyAnswer = extraAttempt && answeredEmpty;
4335
+ if (emptyAnswer || shouldTryNextPeer(responseForClient.statusCode, modelNotFound, pinned, gatewayAutoRoute, extraAttempt && responseFault !== 'buyer')) {
4110
4336
  return {
4111
4337
  done: false,
4112
4338
  statusCode: responseForClient.statusCode,
4113
4339
  responseBody: Buffer.from(responseForClient.body),
4114
4340
  responseHeaders,
4115
4341
  errorMessage: null,
4342
+ modelNotFound,
4343
+ emptyAnswer,
4116
4344
  };
4117
4345
  }
4346
+ if (responseForClient.statusCode < 300 && !answeredEmpty && !isControlPlaneServicesPath(requestForPeer.path)) {
4347
+ this._recordSellerSuccess(selectedPeer.peerId, latencyServiceId, { firstTokenMs: latencyMs, outputTokens: telemetry.usage.outputTokens, streamed: false });
4348
+ }
4118
4349
  res.writeHead(responseForClient.statusCode, responseHeaders);
4119
4350
  res.end(Buffer.from(responseForClient.body));
4120
- return { done: true };
4351
+ return { done: true, emptyAnswer: answeredEmpty };
4121
4352
  }
4122
4353
  else {
4123
4354
  const upstreamResponse = await this._node.sendRequest(selectedPeer, requestForPeer, {
4124
- signal: requestSignal,
4355
+ signal: attemptSignal,
4125
4356
  pinned,
4126
4357
  ...(buyerIdentity ? { buyerIdentity } : {}),
4127
4358
  });
@@ -4177,18 +4408,27 @@ export class BuyerProxy {
4177
4408
  this._recordPeerResponseHealth(selectedPeer.peerId, response.statusCode, requestForPeer.path);
4178
4409
  }
4179
4410
  // Check if retryable
4180
- if (shouldTryNextPeer(response.statusCode, modelNotFound, pinned, gatewayAutoRoute)) {
4181
- return { done: false, statusCode: response.statusCode, responseBody: Buffer.from(response.body), responseHeaders, errorMessage: null };
4411
+ const answeredEmpty = !pinned && response.statusCode < 300 && !videoRoute
4412
+ && isEmptyAnswer(response.headers['content-type'], response.body);
4413
+ const emptyAnswer = extraAttempt && answeredEmpty;
4414
+ if (emptyAnswer || shouldTryNextPeer(response.statusCode, modelNotFound, pinned, gatewayAutoRoute, extraAttempt && responseFault !== 'buyer')) {
4415
+ return { done: false, statusCode: response.statusCode, responseBody: Buffer.from(response.body), responseHeaders, errorMessage: null, modelNotFound, emptyAnswer };
4416
+ }
4417
+ if (response.statusCode < 300 && !answeredEmpty && !videoRoute && !isControlPlaneServicesPath(requestForPeer.path)) {
4418
+ this._recordSellerSuccess(selectedPeer.peerId, latencyServiceId, { firstTokenMs: latencyMs, outputTokens: telemetry.usage.outputTokens, streamed: false });
4182
4419
  }
4183
4420
  // Forward response headers and body to the HTTP client
4184
4421
  res.writeHead(response.statusCode, responseHeaders);
4185
4422
  res.end(Buffer.from(response.body));
4186
- return { done: true };
4423
+ return { done: true, emptyAnswer: answeredEmpty };
4187
4424
  }
4188
4425
  }
4189
4426
  catch (err) {
4190
4427
  const latencyMs = Date.now() - startTime;
4191
- const message = err instanceof Error ? err.message : String(err);
4428
+ const timedOut = attemptAbort.signal.aborted && !requestSignal.aborted;
4429
+ const message = timedOut
4430
+ ? attemptAbort.signal.reason.message
4431
+ : err instanceof Error ? err.message : String(err);
4192
4432
  const abortedLocally = requestSignal.aborted;
4193
4433
  log(`Request failed after ${latencyMs}ms: ${message}`);
4194
4434
  if (abortedLocally) {
@@ -4222,7 +4462,7 @@ export class BuyerProxy {
4222
4462
  // ignore
4223
4463
  }
4224
4464
  }
4225
- return { done: true };
4465
+ return { done: true, interrupted: 'cancelled' };
4226
4466
  }
4227
4467
  // Whose fault was this? Errors raised by our own wallet, deposits,
4228
4468
  // transport or state machine say nothing about the peer, and telling the
@@ -4237,7 +4477,7 @@ export class BuyerProxy {
4237
4477
  if (!res.writableEnded) {
4238
4478
  res.end();
4239
4479
  }
4240
- return { done: true };
4480
+ return { done: true, interrupted: fault === 'buyer' ? 'buyer' : 'seller' };
4241
4481
  }
4242
4482
  if (fault === 'buyer') {
4243
4483
  const buyerResponse = adaptBuyerFaultErrorResponse({
@@ -4275,8 +4515,18 @@ export class BuyerProxy {
4275
4515
  responseBody: Buffer.from(peerResponse.body),
4276
4516
  responseHeaders: peerResponse.headers,
4277
4517
  errorMessage: message,
4518
+ timedOut,
4278
4519
  };
4279
4520
  }
4521
+ finally {
4522
+ if (attemptTimer)
4523
+ clearTimeout(attemptTimer);
4524
+ const remaining = (this._inFlightBySeller.get(sellerKey) ?? 1) - 1;
4525
+ if (remaining > 0)
4526
+ this._inFlightBySeller.set(sellerKey, remaining);
4527
+ else
4528
+ this._inFlightBySeller.delete(sellerKey);
4529
+ }
4280
4530
  }
4281
4531
  }
4282
4532
  //# sourceMappingURL=buyer-proxy.js.map