@antseed/cli 0.1.194 → 0.1.196
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.
- package/dist/cli/commands/seller/start.d.ts.map +1 -1
- package/dist/cli/commands/seller/start.js +5 -3
- package/dist/cli/commands/seller/start.js.map +1 -1
- package/dist/gateway/routing-through-gateway.test.js +1 -1
- package/dist/gateway/routing-through-gateway.test.js.map +1 -1
- package/dist/proxy/buyer-proxy.d.ts +30 -12
- package/dist/proxy/buyer-proxy.d.ts.map +1 -1
- package/dist/proxy/buyer-proxy.js +330 -61
- package/dist/proxy/buyer-proxy.js.map +1 -1
- package/dist/proxy/buyer-proxy.test.js +446 -23
- package/dist/proxy/buyer-proxy.test.js.map +1 -1
- package/dist/proxy/gateway-policy.test.js +7 -6
- package/dist/proxy/gateway-policy.test.js.map +1 -1
- package/dist/proxy/response-quality.d.ts +25 -0
- package/dist/proxy/response-quality.d.ts.map +1 -0
- package/dist/proxy/response-quality.js +203 -0
- package/dist/proxy/response-quality.js.map +1 -0
- package/dist/proxy/response-quality.test.d.ts +2 -0
- package/dist/proxy/response-quality.test.d.ts.map +1 -0
- package/dist/proxy/response-quality.test.js +72 -0
- package/dist/proxy/response-quality.test.js.map +1 -0
- package/dist/proxy/route-policy.d.ts +2 -0
- package/dist/proxy/route-policy.d.ts.map +1 -1
- package/dist/proxy/route-policy.js +9 -8
- package/dist/proxy/route-policy.js.map +1 -1
- package/dist/proxy/route-policy.test.js +6 -0
- package/dist/proxy/route-policy.test.js.map +1 -1
- package/dist/proxy/routing-parameters.test.js +10 -0
- package/dist/proxy/routing-parameters.test.js.map +1 -1
- package/dist/proxy/routing.d.ts.map +1 -1
- package/dist/proxy/routing.js +2 -0
- package/dist/proxy/routing.js.map +1 -1
- package/dist/proxy/video-download.d.ts +9 -0
- package/dist/proxy/video-download.d.ts.map +1 -1
- package/dist/proxy/video-download.js +62 -9
- package/dist/proxy/video-download.js.map +1 -1
- package/dist/proxy/video-download.test.js +73 -10
- package/dist/proxy/video-download.test.js.map +1 -1
- package/package.json +7 -7
|
@@ -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
|
|
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
|
-
*
|
|
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
|
|
120
|
-
|
|
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];
|
|
@@ -231,6 +255,18 @@ export function isModelNotFoundResponse(response) {
|
|
|
231
255
|
* liveness (`lastReachedAt`) even if the DHT record is older.
|
|
232
256
|
*/
|
|
233
257
|
const CARRY_FORWARD_TTL_MS = 2 * 60 * 60_000;
|
|
258
|
+
/**
|
|
259
|
+
* Seller-announced catalog fields. Explicit tracking ensures stale catalog
|
|
260
|
+
* (for example after health checks hide all models) is cleared on the next
|
|
261
|
+
* peer refresh.
|
|
262
|
+
*/
|
|
263
|
+
const PEER_CATALOG_FIELDS = [
|
|
264
|
+
'providerPricing',
|
|
265
|
+
'providerServiceCategories',
|
|
266
|
+
'providerServiceApiProtocols',
|
|
267
|
+
'providerServiceUnitBillingModels',
|
|
268
|
+
'providerServiceCapabilities',
|
|
269
|
+
];
|
|
234
270
|
/**
|
|
235
271
|
* Requests kept in the spend-attribution map. Entries outlive their request on
|
|
236
272
|
* purpose (a seller-initiated auth can land after the response), so this is
|
|
@@ -793,6 +829,15 @@ export class BuyerProxy {
|
|
|
793
829
|
_peerHealth = new Map();
|
|
794
830
|
/** Decides whether a failure is the peer's fault at all. */
|
|
795
831
|
_attribution = new PeerAttributionTracker();
|
|
832
|
+
/**
|
|
833
|
+
* What this buyer has seen of each seller and model: how often requests
|
|
834
|
+
* succeed and how fast answers come. Automatic routing ranks by it.
|
|
835
|
+
*/
|
|
836
|
+
_sellerHealth = new SellerHealthBook();
|
|
837
|
+
/** Requests in flight per seller, to stop sending a seller more than it says it can take at once. */
|
|
838
|
+
_inFlightBySeller = new Map();
|
|
839
|
+
_peerHealthPersistTimer = null;
|
|
840
|
+
_adaptiveRouting;
|
|
796
841
|
_heartbeatHandle = null;
|
|
797
842
|
_now;
|
|
798
843
|
/** Latest relayer receipt per sweep authNonce, for CLI progress polling. */
|
|
@@ -824,14 +869,6 @@ export class BuyerProxy {
|
|
|
824
869
|
_controlSecretCheckedAt = 0;
|
|
825
870
|
_controlSecretForcedAt = 0;
|
|
826
871
|
_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
872
|
_balanceReader;
|
|
836
873
|
_balanceCache = new ChainReadCache();
|
|
837
874
|
_restartHandler;
|
|
@@ -873,6 +910,7 @@ export class BuyerProxy {
|
|
|
873
910
|
}
|
|
874
911
|
: null;
|
|
875
912
|
this._now = config.now ?? (() => Date.now());
|
|
913
|
+
this._adaptiveRouting = { ...config.adaptiveRouting };
|
|
876
914
|
this._server = createServer((req, res) => {
|
|
877
915
|
this._handleRequest(req, res).catch((err) => {
|
|
878
916
|
log('Unhandled error:', err);
|
|
@@ -900,6 +938,17 @@ export class BuyerProxy {
|
|
|
900
938
|
this._spendAttribution.record(event);
|
|
901
939
|
});
|
|
902
940
|
}
|
|
941
|
+
const responseAuthNode = this._node;
|
|
942
|
+
if (typeof responseAuthNode.on === 'function') {
|
|
943
|
+
responseAuthNode.on('response-auth', (event) => {
|
|
944
|
+
if (event.verified || !isSellerVerificationFailure(event.verificationError))
|
|
945
|
+
return;
|
|
946
|
+
// A forged or mismatched receipt can only be the seller's doing: it
|
|
947
|
+
// counts as several failed requests.
|
|
948
|
+
this._recordSellerFailure(event.sellerPeerId, event.advertisedService, `response failed verification (${event.verificationError})`, 3);
|
|
949
|
+
log(`Peer ${event.sellerPeerId.slice(0, 12)}... response failed verification (${event.verificationError}); counted against its ${event.advertisedService} health.`);
|
|
950
|
+
});
|
|
951
|
+
}
|
|
903
952
|
const eventNode = this._node;
|
|
904
953
|
if (typeof eventNode.on === 'function') {
|
|
905
954
|
eventNode.on('peers:discovered', (peers) => {
|
|
@@ -1091,23 +1140,54 @@ export class BuyerProxy {
|
|
|
1091
1140
|
teeCapable: parseVerifierCapabilities(peer.capabilities).supported.includes(TEE_VERIFIER_ID),
|
|
1092
1141
|
};
|
|
1093
1142
|
}
|
|
1094
|
-
/**
|
|
1095
|
-
|
|
1096
|
-
return
|
|
1143
|
+
/** Health is tracked per seller and model: one seller can serve a small model fast and a large one slowly. */
|
|
1144
|
+
_healthServiceKey(serviceId) {
|
|
1145
|
+
return serviceId ? policyModelKey(serviceId) : null;
|
|
1097
1146
|
}
|
|
1098
1147
|
_peerLatency(peerId, serviceId) {
|
|
1099
|
-
return this.
|
|
1148
|
+
return this._sellerHealth.stats(peerId, this._healthServiceKey(serviceId), this._now())?.firstTokenMs ?? null;
|
|
1100
1149
|
}
|
|
1101
1150
|
_peerThroughput(peerId, serviceId) {
|
|
1102
|
-
return this.
|
|
1151
|
+
return this._sellerHealth.stats(peerId, this._healthServiceKey(serviceId), this._now())?.tokensPerSecond ?? null;
|
|
1103
1152
|
}
|
|
1104
|
-
/**
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1153
|
+
/**
|
|
1154
|
+
* Sellers the paying identity already has an open payment channel with, so
|
|
1155
|
+
* routing can spread traffic without reserving funds on new sellers. Null
|
|
1156
|
+
* when the node cannot say.
|
|
1157
|
+
*/
|
|
1158
|
+
_openChannelPeers(buyerIdentity) {
|
|
1159
|
+
const node = this._node;
|
|
1160
|
+
if (typeof node.getActiveBuyerChannels !== 'function')
|
|
1161
|
+
return null;
|
|
1162
|
+
try {
|
|
1163
|
+
return new Set(node.getActiveBuyerChannels(buyerIdentity).map((channel) => channel.peerId.toLowerCase()));
|
|
1110
1164
|
}
|
|
1165
|
+
catch {
|
|
1166
|
+
return null;
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
_attemptTimeoutMs(timedLike, request) {
|
|
1170
|
+
return adaptiveFirstTokenTimeoutMs(this._sellerHealth.stats(timedLike.peerId, this._healthServiceKey(timedLike.serviceId), this._now()), requestShape(request).outputTokens, requestWantsStreaming(request.headers, request.body));
|
|
1171
|
+
}
|
|
1172
|
+
/** A seller answered: count the success and how fast the answer came. */
|
|
1173
|
+
_recordSellerSuccess(peerId, serviceId, timing) {
|
|
1174
|
+
this._sellerHealth.recordSuccess(peerId, this._healthServiceKey(serviceId), this._now(), timing);
|
|
1175
|
+
this._schedulePeerHealthPersist();
|
|
1176
|
+
}
|
|
1177
|
+
/** A seller failed a request: count it against that seller and model. */
|
|
1178
|
+
_recordSellerFailure(peerId, serviceId, reason, weight = 1) {
|
|
1179
|
+
this._sellerHealth.recordFailure(peerId, this._healthServiceKey(serviceId), this._now(), reason, weight);
|
|
1180
|
+
this._schedulePeerHealthPersist();
|
|
1181
|
+
}
|
|
1182
|
+
/** Seller health changes on every request: write it at most every few seconds, not per request. */
|
|
1183
|
+
_schedulePeerHealthPersist() {
|
|
1184
|
+
if (this._peerHealthPersistTimer)
|
|
1185
|
+
return;
|
|
1186
|
+
this._peerHealthPersistTimer = setTimeout(() => {
|
|
1187
|
+
this._peerHealthPersistTimer = null;
|
|
1188
|
+
void this._persistPeerHealthToState();
|
|
1189
|
+
}, SELLER_HEALTH_PERSIST_DELAY_MS);
|
|
1190
|
+
this._peerHealthPersistTimer.unref?.();
|
|
1111
1191
|
}
|
|
1112
1192
|
/**
|
|
1113
1193
|
* Candidate selection and ranking for a model-only request. The real
|
|
@@ -1212,6 +1292,33 @@ export class BuyerProxy {
|
|
|
1212
1292
|
candidates = this._readyFirst(ranked, verdicts, now);
|
|
1213
1293
|
}
|
|
1214
1294
|
const isCooling = (candidate) => isModelRouteCoolingDown(candidate, now);
|
|
1295
|
+
const openChannels = this._openChannelPeers(request.headers[BUYER_IDENTITY_HEADER]);
|
|
1296
|
+
// Among the sellers the buyer's limits allow, rank by what this buyer has
|
|
1297
|
+
// seen them do: expected cost of a successful answer, spread across the
|
|
1298
|
+
// sellers close to the best. An explicit gateway sort or route chain
|
|
1299
|
+
// reorders afterwards, but still tries unhealthy sellers late.
|
|
1300
|
+
const adaptive = orderAdaptiveRoutes(candidates, (candidate) => ({
|
|
1301
|
+
peerId: candidate.peerId,
|
|
1302
|
+
serviceId: this._healthServiceKey(candidate.serviceId),
|
|
1303
|
+
inputUsdPerMillion: candidate.inputUsdPerMillion,
|
|
1304
|
+
outputUsdPerMillion: candidate.outputUsdPerMillion,
|
|
1305
|
+
requestUsd: candidate.minImageUsdPerImage,
|
|
1306
|
+
coolingDown: isCooling(candidate),
|
|
1307
|
+
...(openChannels ? { channelOpen: modelRouteTotalPrice(candidate) === 0 || openChannels.has(candidate.peerId.toLowerCase()) } : {}),
|
|
1308
|
+
saturated: typeof candidate.peer.maxConcurrency === 'number' && candidate.peer.maxConcurrency > 0
|
|
1309
|
+
&& (this._inFlightBySeller.get(candidate.peerId.toLowerCase()) ?? 0) >= candidate.peer.maxConcurrency,
|
|
1310
|
+
}), this._sellerHealth, requestShape(request), {
|
|
1311
|
+
now,
|
|
1312
|
+
affinityKey: input.affinityKey ?? null,
|
|
1313
|
+
...this._adaptiveRouting,
|
|
1314
|
+
...(input.deterministic ? { spreadTolerance: 0, explorationRate: 0 } : {}),
|
|
1315
|
+
});
|
|
1316
|
+
// Without routing preferences the buyer ranks by reputation, as before;
|
|
1317
|
+
// health only moves sellers that fail often behind the others.
|
|
1318
|
+
candidates = routingPreferences
|
|
1319
|
+
? adaptive.ordered
|
|
1320
|
+
: [...candidates].sort((a, b) => Number(adaptive.scores.get(a).unhealthy) - Number(adaptive.scores.get(b).unhealthy));
|
|
1321
|
+
const healthOf = (candidate) => adaptive.scores.get(candidate);
|
|
1215
1322
|
let chainPosition = null;
|
|
1216
1323
|
if (effectivePolicy) {
|
|
1217
1324
|
const ordering = orderByPolicy(candidates, effectivePolicy, requestedService, {
|
|
@@ -1220,6 +1327,7 @@ export class BuyerProxy {
|
|
|
1220
1327
|
trustScore: (candidate) => (candidate.reputation >= 0 ? candidate.reputation : null),
|
|
1221
1328
|
latencyMs: (candidate) => this._peerLatency(candidate.peerId, candidate.serviceId),
|
|
1222
1329
|
coolingDown: isCooling,
|
|
1330
|
+
unhealthy: (candidate) => healthOf(candidate).unhealthy,
|
|
1223
1331
|
});
|
|
1224
1332
|
for (const candidate of candidates) {
|
|
1225
1333
|
const reason = ordering.excluded.get(normalizePolicyPeerId(candidate.peerId));
|
|
@@ -1234,7 +1342,8 @@ export class BuyerProxy {
|
|
|
1234
1342
|
const preferredPeerId = input.preferredPeerId;
|
|
1235
1343
|
if (preferredPeerId && !findModelRoute(effectivePolicy, requestedService)) {
|
|
1236
1344
|
const preferredIndex = candidates.findIndex((candidate) => (candidate.peer.peerId.toLowerCase() === preferredPeerId
|
|
1237
|
-
&& !isCoolingDown(this._peerHealth.get(candidate.peer.peerId), now)
|
|
1345
|
+
&& !isCoolingDown(this._peerHealth.get(candidate.peer.peerId), now)
|
|
1346
|
+
&& !healthOf(candidate).unhealthy));
|
|
1238
1347
|
if (preferredIndex > 0) {
|
|
1239
1348
|
const [preferred] = candidates.splice(preferredIndex, 1);
|
|
1240
1349
|
if (preferred)
|
|
@@ -1250,18 +1359,24 @@ export class BuyerProxy {
|
|
|
1250
1359
|
const position = chainPosition?.get(normalizePolicyPeerId(candidate.peerId));
|
|
1251
1360
|
if (position !== undefined)
|
|
1252
1361
|
verdict.reasons.push(`route chain #${position + 1}`);
|
|
1362
|
+
const health = healthOf(candidate);
|
|
1253
1363
|
if (isCooling(candidate))
|
|
1254
1364
|
verdict.reasons.push('cooling down (tried last)');
|
|
1365
|
+
else if (health.unhealthy)
|
|
1366
|
+
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)`);
|
|
1255
1369
|
if (modelRouteTotalPrice(candidate) === 0)
|
|
1256
1370
|
verdict.reasons.push('free');
|
|
1371
|
+
verdict.reasons.push(`success ~${Math.round(health.successProbability * 100)}%${health.stats ? '' : ' (no data yet)'}`);
|
|
1257
1372
|
const latency = this._peerLatency(candidate.peerId, candidate.serviceId);
|
|
1258
1373
|
if (latency !== null)
|
|
1259
|
-
verdict.reasons.push(`
|
|
1374
|
+
verdict.reasons.push(`first token ~${Math.round(latency)}ms`);
|
|
1260
1375
|
const throughput = this._peerThroughput(candidate.peerId, candidate.serviceId);
|
|
1261
1376
|
if (throughput !== null)
|
|
1262
1377
|
verdict.reasons.push(`~${Math.round(throughput)} tok/s`);
|
|
1263
1378
|
});
|
|
1264
|
-
return { candidates, verdicts: [...verdicts.values()], effectivePolicy };
|
|
1379
|
+
return { candidates, verdicts: [...verdicts.values()], effectivePolicy, explored: adaptive.explored };
|
|
1265
1380
|
}
|
|
1266
1381
|
/**
|
|
1267
1382
|
* Without routing preferences: best effective reputation first, and only
|
|
@@ -1305,6 +1420,7 @@ export class BuyerProxy {
|
|
|
1305
1420
|
requiredParameters: [],
|
|
1306
1421
|
requestPolicy,
|
|
1307
1422
|
preferredPeerId: null,
|
|
1423
|
+
deterministic: true,
|
|
1308
1424
|
});
|
|
1309
1425
|
const effective = plan.effectivePolicy;
|
|
1310
1426
|
const modelAllowed = policyAllowsModel(effective, model);
|
|
@@ -1573,6 +1689,7 @@ export class BuyerProxy {
|
|
|
1573
1689
|
// impossibly distant, so a restart can never extend one. Nothing new can
|
|
1574
1690
|
// escalate until a success re-establishes that the buyer is healthy.
|
|
1575
1691
|
this._peerHealth = parsePersistedPeerHealth(parsed, this._now());
|
|
1692
|
+
this._sellerHealth = SellerHealthBook.parse(parsed.sellerHealth, this._now());
|
|
1576
1693
|
const peers = parsePersistedPeers(parsed);
|
|
1577
1694
|
if (peers.length === 0) {
|
|
1578
1695
|
return;
|
|
@@ -1596,6 +1713,11 @@ export class BuyerProxy {
|
|
|
1596
1713
|
async stop() {
|
|
1597
1714
|
this._teeVerification.close();
|
|
1598
1715
|
await this._teeControl.close();
|
|
1716
|
+
if (this._peerHealthPersistTimer) {
|
|
1717
|
+
clearTimeout(this._peerHealthPersistTimer);
|
|
1718
|
+
this._peerHealthPersistTimer = null;
|
|
1719
|
+
await this._persistPeerHealthToState().catch(() => { });
|
|
1720
|
+
}
|
|
1599
1721
|
if (this._stateWatchDebounce) {
|
|
1600
1722
|
clearTimeout(this._stateWatchDebounce);
|
|
1601
1723
|
this._stateWatchDebounce = null;
|
|
@@ -1836,6 +1958,13 @@ export class BuyerProxy {
|
|
|
1836
1958
|
...peer,
|
|
1837
1959
|
...(metadata ? { metadata } : {}),
|
|
1838
1960
|
};
|
|
1961
|
+
// The seller's announced catalog comes only from this scan: a seller
|
|
1962
|
+
// that stopped advertising a service (for example after failed health
|
|
1963
|
+
// checks) leaves these fields out, and the cached copy must not survive.
|
|
1964
|
+
for (const field of PEER_CATALOG_FIELDS) {
|
|
1965
|
+
if (peer[field] === undefined)
|
|
1966
|
+
delete mergedPeer[field];
|
|
1967
|
+
}
|
|
1839
1968
|
if (prev.lastReachedAt && (!peer.lastReachedAt || prev.lastReachedAt > peer.lastReachedAt)) {
|
|
1840
1969
|
mergedPeer.lastReachedAt = prev.lastReachedAt;
|
|
1841
1970
|
}
|
|
@@ -2107,6 +2236,7 @@ export class BuyerProxy {
|
|
|
2107
2236
|
this._peerHealth = prunePeerHealth(this._peerHealth, now);
|
|
2108
2237
|
await this._mergeStateFile({
|
|
2109
2238
|
peerHealth: serializePeerHealth(this._peerHealth),
|
|
2239
|
+
sellerHealth: this._sellerHealth.serialize(now),
|
|
2110
2240
|
peerHealthUpdatedAt: now,
|
|
2111
2241
|
});
|
|
2112
2242
|
}
|
|
@@ -2305,6 +2435,18 @@ export class BuyerProxy {
|
|
|
2305
2435
|
suppressedUntil: attribution.suppressedUntil,
|
|
2306
2436
|
lastSuppressedBy: attribution.lastSuppressedBy,
|
|
2307
2437
|
peers,
|
|
2438
|
+
// What automatic routing has learned of each seller and model.
|
|
2439
|
+
sellerHealth: this._sellerHealth.entries(now).map(({ peerId, serviceId, stats }) => ({
|
|
2440
|
+
peerId,
|
|
2441
|
+
serviceId,
|
|
2442
|
+
successes: Math.round(stats.successes * 100) / 100,
|
|
2443
|
+
failures: Math.round(stats.failures * 100) / 100,
|
|
2444
|
+
firstTokenMs: stats.firstTokenMs === null ? null : Math.round(stats.firstTokenMs),
|
|
2445
|
+
tokensPerSecond: stats.tokensPerSecond === null ? null : Math.round(stats.tokensPerSecond),
|
|
2446
|
+
lastSuccessAt: stats.lastSuccessAt,
|
|
2447
|
+
lastFailureAt: stats.lastFailureAt,
|
|
2448
|
+
lastFailureReason: stats.lastFailureReason,
|
|
2449
|
+
})),
|
|
2308
2450
|
}));
|
|
2309
2451
|
return;
|
|
2310
2452
|
}
|
|
@@ -2339,6 +2481,8 @@ export class BuyerProxy {
|
|
|
2339
2481
|
// Deliberately does not stamp a success: the user is asking us to give
|
|
2340
2482
|
// the peer another chance, not asserting that it answered.
|
|
2341
2483
|
this._rollbackPeerHealth([normalized], 'cleared by request');
|
|
2484
|
+
this._sellerHealth.forget(normalized);
|
|
2485
|
+
void this._persistPeerHealthToState();
|
|
2342
2486
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
2343
2487
|
res.end(JSON.stringify({ ok: true, peerId: normalized }));
|
|
2344
2488
|
return;
|
|
@@ -3559,7 +3703,7 @@ export class BuyerProxy {
|
|
|
3559
3703
|
({ candidatePeers: modelPeers, routePlanByPeerId: modelPlans } = selectModelPeers(discoveredPeers));
|
|
3560
3704
|
}
|
|
3561
3705
|
const router = this._node.router;
|
|
3562
|
-
const { candidates, effectivePolicy } = this._planModelRoutes({
|
|
3706
|
+
const { candidates, effectivePolicy, explored } = this._planModelRoutes({
|
|
3563
3707
|
modelPeers,
|
|
3564
3708
|
modelPlans,
|
|
3565
3709
|
request: serializedReq,
|
|
@@ -3569,6 +3713,9 @@ export class BuyerProxy {
|
|
|
3569
3713
|
requiredParameters,
|
|
3570
3714
|
requestPolicy,
|
|
3571
3715
|
preferredPeerId: preferredConversationPeerId,
|
|
3716
|
+
// Conversations the client identifies (session id, prompt-cache key)
|
|
3717
|
+
// stay on one seller so its prompt cache keeps paying off.
|
|
3718
|
+
affinityKey: routingConversationKey ?? promptCacheKey(serializedReq),
|
|
3572
3719
|
});
|
|
3573
3720
|
const requireVerified = effectivePolicy?.requireVerified === true;
|
|
3574
3721
|
if (candidates.length === 0) {
|
|
@@ -3613,6 +3760,31 @@ export class BuyerProxy {
|
|
|
3613
3760
|
// Set once a seller refused for lack of deposits: every other paid
|
|
3614
3761
|
// seller would too, so only free ones are still worth trying.
|
|
3615
3762
|
let unfunded = false;
|
|
3763
|
+
// Sellers that failed this request, whatever the failure: an error
|
|
3764
|
+
// status, a dropped request, an empty answer. If a later seller serves
|
|
3765
|
+
// the same request, they could have too, and each failure counts
|
|
3766
|
+
// against its seller's health. If none does, the request itself may be
|
|
3767
|
+
// at fault: only failures that can only be the seller's count, and only
|
|
3768
|
+
// while the buyer itself is known to be healthy.
|
|
3769
|
+
const failedSellers = new Map();
|
|
3770
|
+
const countFailures = (servedBy) => {
|
|
3771
|
+
const buyerHealthy = this._attribution.isBuyerHealthy(this._now());
|
|
3772
|
+
for (const [peerId, { serviceId, failure }] of failedSellers) {
|
|
3773
|
+
// A seller that recovered on its own retry (a 429, then an answer)
|
|
3774
|
+
// proved nothing against itself.
|
|
3775
|
+
if (peerId === servedBy)
|
|
3776
|
+
continue;
|
|
3777
|
+
if (servedBy === null && (!failure.sellerOnly || !buyerHealthy))
|
|
3778
|
+
continue;
|
|
3779
|
+
const detail = servedBy ? `${failure.description} (peer ${servedBy.slice(0, 12)}... served it)` : failure.description;
|
|
3780
|
+
this._recordSellerFailure(peerId, serviceId, detail, failure.weight);
|
|
3781
|
+
log(`Peer ${peerId.slice(0, 12)}... ${detail}; counted against its ${serviceId} health.`);
|
|
3782
|
+
}
|
|
3783
|
+
};
|
|
3784
|
+
// Failures that only maybe are the seller's (a client error, an empty
|
|
3785
|
+
// answer) get one extra seller to compare against, not the whole list:
|
|
3786
|
+
// when every seller fails, the client gets that answer quickly.
|
|
3787
|
+
let comparisonUsed = false;
|
|
3616
3788
|
const isFree = (candidate) => modelRouteTotalPrice(candidate) === 0;
|
|
3617
3789
|
for (const [index, selected] of candidates.entries()) {
|
|
3618
3790
|
if (unfunded && !isFree(selected))
|
|
@@ -3631,11 +3803,29 @@ export class BuyerProxy {
|
|
|
3631
3803
|
+ `service="${selected.serviceId}" reputation=${selected.reputation} `
|
|
3632
3804
|
+ `effective=${selected.effectiveReputationScore ?? 'unknown'} peer=${index + 1}/${candidates.length} `
|
|
3633
3805
|
+ `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
|
|
3806
|
+
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,
|
|
3807
|
+
// With another seller to fall back on, don't wait far longer than
|
|
3808
|
+
// this one usually takes; the last seller gets the full timeout. A
|
|
3809
|
+
// probe of a seller with no recent data waits no longer than the
|
|
3810
|
+
// seller it stands in for would.
|
|
3811
|
+
index + 1 < candidates.length && !nativeVideo
|
|
3812
|
+
? this._attemptTimeoutMs(selected === explored ? candidates[index + 1] : selected, selected.request)
|
|
3813
|
+
: null);
|
|
3635
3814
|
if (result.done) {
|
|
3815
|
+
// A stream that broke after its headers proves nothing for the
|
|
3816
|
+
// sellers before it, and counts against this one like any other
|
|
3817
|
+
// transport failure.
|
|
3818
|
+
if (result.interrupted === 'seller') {
|
|
3819
|
+
failedSellers.set(selected.peer.peerId, {
|
|
3820
|
+
serviceId: selected.serviceId,
|
|
3821
|
+
failure: { description: 'stream interrupted', sellerOnly: true, weight: 1 },
|
|
3822
|
+
});
|
|
3823
|
+
}
|
|
3824
|
+
const answered = res.statusCode < 400 && !result.emptyAnswer && !result.interrupted;
|
|
3825
|
+
countFailures(answered ? selected.peer.peerId : null);
|
|
3636
3826
|
// Only a seller that answered becomes the chat's route: an error
|
|
3637
3827
|
// must not pin later turns to it.
|
|
3638
|
-
if (trackedConversationId &&
|
|
3828
|
+
if (trackedConversationId && answered) {
|
|
3639
3829
|
this._conversations.recordRoutedModel(trackedConversationId, `${selected.peer.peerId}@${selected.serviceId}`, conversationPinAtRouting);
|
|
3640
3830
|
}
|
|
3641
3831
|
return result;
|
|
@@ -3645,6 +3835,15 @@ export class BuyerProxy {
|
|
|
3645
3835
|
if (nativeVideo)
|
|
3646
3836
|
return result;
|
|
3647
3837
|
lastRetry = result;
|
|
3838
|
+
if (!isBuyerFault(result) && !result.modelNotFound && result.statusCode !== 402) {
|
|
3839
|
+
const failure = classifyFailedAttempt(result);
|
|
3840
|
+
failedSellers.set(selected.peer.peerId, { serviceId: selected.serviceId, failure });
|
|
3841
|
+
if (!failure.sellerOnly && result.statusCode !== 429) {
|
|
3842
|
+
comparisonUsed = true;
|
|
3843
|
+
log(`Peer ${selected.peer.peerId.slice(0, 12)}... ${failure.description}; asking one more seller.`);
|
|
3844
|
+
break;
|
|
3845
|
+
}
|
|
3846
|
+
}
|
|
3648
3847
|
if (isBuyerFault(result)) {
|
|
3649
3848
|
const freeLeft = candidates.slice(index + 1).some(isFree);
|
|
3650
3849
|
if (result.statusCode === 402 && freeLeft) {
|
|
@@ -3667,6 +3866,7 @@ export class BuyerProxy {
|
|
|
3667
3866
|
log(`Peer ${selected.peer.peerId.slice(0, 12)}... failed retryably; trying next model peer.`);
|
|
3668
3867
|
}
|
|
3669
3868
|
}
|
|
3869
|
+
countFailures(null);
|
|
3670
3870
|
return lastRetry ?? dispatchFailure(502, { 'content-type': 'application/json' }, JSON.stringify({
|
|
3671
3871
|
error: {
|
|
3672
3872
|
type: 'peer_verification_failed',
|
|
@@ -3829,7 +4029,13 @@ export class BuyerProxy {
|
|
|
3829
4029
|
if (result.done && trackedConversationId && pinnedServiceId) {
|
|
3830
4030
|
this._conversations.recordRoutedModel(trackedConversationId, `${selectedPeer.peerId}@${pinnedServiceId}`, conversationPinAtRouting);
|
|
3831
4031
|
}
|
|
3832
|
-
// A pinned peer is never retried against another peer; the caller reports
|
|
4032
|
+
// A pinned peer is never retried against another peer; the caller reports
|
|
4033
|
+
// any failure. Failures only the seller can cause still count against it.
|
|
4034
|
+
if (!result.done && !isBuyerFault(result) && this._attribution.isBuyerHealthy(this._now())) {
|
|
4035
|
+
const failure = classifyFailedAttempt(result);
|
|
4036
|
+
if (failure.sellerOnly)
|
|
4037
|
+
this._recordSellerFailure(selectedPeer.peerId, pinnedServiceId ?? requestedService, failure.description, failure.weight);
|
|
4038
|
+
}
|
|
3833
4039
|
return result;
|
|
3834
4040
|
}
|
|
3835
4041
|
async _verifyPeer(peer, makeReach, signal, requireVerified = false) {
|
|
@@ -3915,7 +4121,9 @@ export class BuyerProxy {
|
|
|
3915
4121
|
* was sent to the client (success or non-retryable error), or retry info if the
|
|
3916
4122
|
* caller should try another peer.
|
|
3917
4123
|
*/
|
|
3918
|
-
async _dispatchToPeer(res, serializedReq, selectedPeer, routePlanByPeerId, requestProtocol, requestedService, explicitProvider, router, pinned, requestSignal, gatewayRouted = false, routingConversationKey = null
|
|
4124
|
+
async _dispatchToPeer(res, serializedReq, selectedPeer, routePlanByPeerId, requestProtocol, requestedService, explicitProvider, router, pinned, requestSignal, gatewayRouted = false, routingConversationKey = null, extraAttempt = false,
|
|
4125
|
+
/** Give up on this seller if it has not started answering by then, so the next one can be tried. */
|
|
4126
|
+
attemptTimeoutMs = null) {
|
|
3919
4127
|
const gatewayAutoRoute = gatewayRouted && !pinned;
|
|
3920
4128
|
const selectedRoutePlan = routePlanByPeerId.get(selectedPeer.peerId)
|
|
3921
4129
|
?? resolvePeerRoutePlan(selectedPeer, requestProtocol, requestedService, explicitProvider, 'lenient');
|
|
@@ -4013,16 +4221,29 @@ export class BuyerProxy {
|
|
|
4013
4221
|
const peerResponseProtocol = selectedRoutePlan.selection?.targetProtocol ?? requestProtocol;
|
|
4014
4222
|
const adaptPeerResponse = (response) => adaptPeerFaultErrorResponse(response, peerResponseProtocol, { pinned });
|
|
4015
4223
|
const startTime = Date.now();
|
|
4224
|
+
const attemptAbort = new AbortController();
|
|
4225
|
+
const attemptSignal = attemptTimeoutMs ? AbortSignal.any([requestSignal, attemptAbort.signal]) : requestSignal;
|
|
4226
|
+
const attemptTimer = attemptTimeoutMs
|
|
4227
|
+
? setTimeout(() => attemptAbort.abort(new Error(`no answer within ${Math.round(attemptTimeoutMs / 1000)}s, about 4x this seller's usual time`)), attemptTimeoutMs)
|
|
4228
|
+
: null;
|
|
4229
|
+
attemptTimer?.unref?.();
|
|
4230
|
+
const sellerKey = selectedPeer.peerId.toLowerCase();
|
|
4231
|
+
this._inFlightBySeller.set(sellerKey, (this._inFlightBySeller.get(sellerKey) ?? 0) + 1);
|
|
4016
4232
|
try {
|
|
4017
4233
|
if (wantsStreaming) {
|
|
4018
4234
|
let streamed = false;
|
|
4019
4235
|
let firstFrameAt = 0;
|
|
4236
|
+
let streamStartBody = new Uint8Array();
|
|
4020
4237
|
const response = await this._node.sendRequestStream(selectedPeer, requestForPeer, {
|
|
4021
4238
|
onResponseStart: (startResponse, metadata) => {
|
|
4022
4239
|
if (!metadata.streaming)
|
|
4023
4240
|
return;
|
|
4241
|
+
// The seller is answering: from here a slow answer is still an answer.
|
|
4242
|
+
if (attemptTimer)
|
|
4243
|
+
clearTimeout(attemptTimer);
|
|
4024
4244
|
streamed = true;
|
|
4025
4245
|
firstFrameAt = Date.now();
|
|
4246
|
+
streamStartBody = startResponse.body;
|
|
4026
4247
|
const adaptedStartResponse = streamResponseAdapter
|
|
4027
4248
|
? streamResponseAdapter.adaptStart(startResponse)
|
|
4028
4249
|
: startResponse;
|
|
@@ -4051,7 +4272,7 @@ export class BuyerProxy {
|
|
|
4051
4272
|
}
|
|
4052
4273
|
}
|
|
4053
4274
|
},
|
|
4054
|
-
}, { signal:
|
|
4275
|
+
}, { signal: attemptSignal, pinned, ...(buyerIdentity ? { buyerIdentity } : {}) });
|
|
4055
4276
|
recordRouterUsage(this._modelRoutingClient, routingConversationKey, requestForPeer, response, selectedPeer, requestSignal);
|
|
4056
4277
|
let responseForClient = adaptBuyerFaultErrorResponse(response, requestProtocol);
|
|
4057
4278
|
responseForClient = adaptPeerResponse(responseForClient);
|
|
@@ -4073,8 +4294,9 @@ export class BuyerProxy {
|
|
|
4073
4294
|
log(`${prefix}: ${summarizeErrorResponse(responseForClient)}`);
|
|
4074
4295
|
}
|
|
4075
4296
|
const telemetry = computeResponseTelemetry(requestForPeer, responseForClient.headers, responseForClient.body, selectedPeer);
|
|
4297
|
+
let streamTiming = null;
|
|
4076
4298
|
if (streamed && responseForClient.statusCode < 400) {
|
|
4077
|
-
|
|
4299
|
+
streamTiming = { firstTokenMs: firstFrameAt - startTime, streamMs: finishedAt - firstFrameAt, outputTokens: telemetry.usage.outputTokens, streamed: true };
|
|
4078
4300
|
}
|
|
4079
4301
|
const responseFault = responseFaultAttribution(responseForClient);
|
|
4080
4302
|
const modelNotFound = !streamed
|
|
@@ -4098,30 +4320,58 @@ export class BuyerProxy {
|
|
|
4098
4320
|
this._recordPeerResponseHealth(selectedPeer.peerId, responseForClient.statusCode, requestForPeer.path);
|
|
4099
4321
|
}
|
|
4100
4322
|
if (streamed) {
|
|
4323
|
+
const streamedBody = Buffer.concat([streamStartBody, response.body]);
|
|
4324
|
+
// The seller's provider failed after the stream started: the wire
|
|
4325
|
+
// ends normally, but the answer never completed.
|
|
4326
|
+
const streamFailed = responseForClient.statusCode < 300
|
|
4327
|
+
&& hasStreamError(response.headers['content-type'], streamedBody);
|
|
4328
|
+
const streamedEmpty = !pinned && !streamFailed && responseForClient.statusCode < 300
|
|
4329
|
+
&& isEmptyAnswer(response.headers['content-type'], streamedBody);
|
|
4330
|
+
// Already sent, so there is nothing to compare against: an empty
|
|
4331
|
+
// answer counts against the seller by itself.
|
|
4332
|
+
if (streamedEmpty)
|
|
4333
|
+
this._recordSellerFailure(selectedPeer.peerId, latencyServiceId, 'streamed an answer with no text or tool call');
|
|
4334
|
+
else if (streamFailed) {
|
|
4335
|
+
// Auto-routing attributes it with the other attempts; a pinned
|
|
4336
|
+
// request has no other attempts, so count it here.
|
|
4337
|
+
if (pinned && this._attribution.isBuyerHealthy(this._now())) {
|
|
4338
|
+
this._recordSellerFailure(selectedPeer.peerId, latencyServiceId, 'stream interrupted');
|
|
4339
|
+
}
|
|
4340
|
+
}
|
|
4341
|
+
else if (streamTiming)
|
|
4342
|
+
this._recordSellerSuccess(selectedPeer.peerId, latencyServiceId, streamTiming);
|
|
4101
4343
|
// Headers already sent to client, can't retry
|
|
4102
4344
|
if (!res.writableEnded) {
|
|
4103
4345
|
res.end();
|
|
4104
4346
|
}
|
|
4105
|
-
return { done: true };
|
|
4347
|
+
return streamFailed ? { done: true, interrupted: 'seller' } : { done: true, emptyAnswer: streamedEmpty };
|
|
4106
4348
|
}
|
|
4107
4349
|
// Non-streamed response — check if retryable
|
|
4108
4350
|
const responseHeaders = attachAntseedTelemetryHeaders(responseForClient.headers, selectedPeer, telemetry, requestForPeer.requestId, latencyMs);
|
|
4109
|
-
|
|
4351
|
+
const answeredEmpty = !pinned && responseForClient.statusCode < 300
|
|
4352
|
+
&& isEmptyAnswer(responseForClient.headers['content-type'], responseForClient.body);
|
|
4353
|
+
const emptyAnswer = extraAttempt && answeredEmpty;
|
|
4354
|
+
if (emptyAnswer || shouldTryNextPeer(responseForClient.statusCode, modelNotFound, pinned, gatewayAutoRoute, extraAttempt && responseFault !== 'buyer')) {
|
|
4110
4355
|
return {
|
|
4111
4356
|
done: false,
|
|
4112
4357
|
statusCode: responseForClient.statusCode,
|
|
4113
4358
|
responseBody: Buffer.from(responseForClient.body),
|
|
4114
4359
|
responseHeaders,
|
|
4115
4360
|
errorMessage: null,
|
|
4361
|
+
modelNotFound,
|
|
4362
|
+
emptyAnswer,
|
|
4116
4363
|
};
|
|
4117
4364
|
}
|
|
4365
|
+
if (responseForClient.statusCode < 300 && !answeredEmpty && !isControlPlaneServicesPath(requestForPeer.path)) {
|
|
4366
|
+
this._recordSellerSuccess(selectedPeer.peerId, latencyServiceId, { firstTokenMs: latencyMs, outputTokens: telemetry.usage.outputTokens, streamed: false });
|
|
4367
|
+
}
|
|
4118
4368
|
res.writeHead(responseForClient.statusCode, responseHeaders);
|
|
4119
4369
|
res.end(Buffer.from(responseForClient.body));
|
|
4120
|
-
return { done: true };
|
|
4370
|
+
return { done: true, emptyAnswer: answeredEmpty };
|
|
4121
4371
|
}
|
|
4122
4372
|
else {
|
|
4123
4373
|
const upstreamResponse = await this._node.sendRequest(selectedPeer, requestForPeer, {
|
|
4124
|
-
signal:
|
|
4374
|
+
signal: attemptSignal,
|
|
4125
4375
|
pinned,
|
|
4126
4376
|
...(buyerIdentity ? { buyerIdentity } : {}),
|
|
4127
4377
|
});
|
|
@@ -4177,18 +4427,27 @@ export class BuyerProxy {
|
|
|
4177
4427
|
this._recordPeerResponseHealth(selectedPeer.peerId, response.statusCode, requestForPeer.path);
|
|
4178
4428
|
}
|
|
4179
4429
|
// Check if retryable
|
|
4180
|
-
|
|
4181
|
-
|
|
4430
|
+
const answeredEmpty = !pinned && response.statusCode < 300 && !videoRoute
|
|
4431
|
+
&& isEmptyAnswer(response.headers['content-type'], response.body);
|
|
4432
|
+
const emptyAnswer = extraAttempt && answeredEmpty;
|
|
4433
|
+
if (emptyAnswer || shouldTryNextPeer(response.statusCode, modelNotFound, pinned, gatewayAutoRoute, extraAttempt && responseFault !== 'buyer')) {
|
|
4434
|
+
return { done: false, statusCode: response.statusCode, responseBody: Buffer.from(response.body), responseHeaders, errorMessage: null, modelNotFound, emptyAnswer };
|
|
4435
|
+
}
|
|
4436
|
+
if (response.statusCode < 300 && !answeredEmpty && !videoRoute && !isControlPlaneServicesPath(requestForPeer.path)) {
|
|
4437
|
+
this._recordSellerSuccess(selectedPeer.peerId, latencyServiceId, { firstTokenMs: latencyMs, outputTokens: telemetry.usage.outputTokens, streamed: false });
|
|
4182
4438
|
}
|
|
4183
4439
|
// Forward response headers and body to the HTTP client
|
|
4184
4440
|
res.writeHead(response.statusCode, responseHeaders);
|
|
4185
4441
|
res.end(Buffer.from(response.body));
|
|
4186
|
-
return { done: true };
|
|
4442
|
+
return { done: true, emptyAnswer: answeredEmpty };
|
|
4187
4443
|
}
|
|
4188
4444
|
}
|
|
4189
4445
|
catch (err) {
|
|
4190
4446
|
const latencyMs = Date.now() - startTime;
|
|
4191
|
-
const
|
|
4447
|
+
const timedOut = attemptAbort.signal.aborted && !requestSignal.aborted;
|
|
4448
|
+
const message = timedOut
|
|
4449
|
+
? attemptAbort.signal.reason.message
|
|
4450
|
+
: err instanceof Error ? err.message : String(err);
|
|
4192
4451
|
const abortedLocally = requestSignal.aborted;
|
|
4193
4452
|
log(`Request failed after ${latencyMs}ms: ${message}`);
|
|
4194
4453
|
if (abortedLocally) {
|
|
@@ -4222,7 +4481,7 @@ export class BuyerProxy {
|
|
|
4222
4481
|
// ignore
|
|
4223
4482
|
}
|
|
4224
4483
|
}
|
|
4225
|
-
return { done: true };
|
|
4484
|
+
return { done: true, interrupted: 'cancelled' };
|
|
4226
4485
|
}
|
|
4227
4486
|
// Whose fault was this? Errors raised by our own wallet, deposits,
|
|
4228
4487
|
// transport or state machine say nothing about the peer, and telling the
|
|
@@ -4237,7 +4496,7 @@ export class BuyerProxy {
|
|
|
4237
4496
|
if (!res.writableEnded) {
|
|
4238
4497
|
res.end();
|
|
4239
4498
|
}
|
|
4240
|
-
return { done: true };
|
|
4499
|
+
return { done: true, interrupted: fault === 'buyer' ? 'buyer' : 'seller' };
|
|
4241
4500
|
}
|
|
4242
4501
|
if (fault === 'buyer') {
|
|
4243
4502
|
const buyerResponse = adaptBuyerFaultErrorResponse({
|
|
@@ -4275,8 +4534,18 @@ export class BuyerProxy {
|
|
|
4275
4534
|
responseBody: Buffer.from(peerResponse.body),
|
|
4276
4535
|
responseHeaders: peerResponse.headers,
|
|
4277
4536
|
errorMessage: message,
|
|
4537
|
+
timedOut,
|
|
4278
4538
|
};
|
|
4279
4539
|
}
|
|
4540
|
+
finally {
|
|
4541
|
+
if (attemptTimer)
|
|
4542
|
+
clearTimeout(attemptTimer);
|
|
4543
|
+
const remaining = (this._inFlightBySeller.get(sellerKey) ?? 1) - 1;
|
|
4544
|
+
if (remaining > 0)
|
|
4545
|
+
this._inFlightBySeller.set(sellerKey, remaining);
|
|
4546
|
+
else
|
|
4547
|
+
this._inFlightBySeller.delete(sellerKey);
|
|
4548
|
+
}
|
|
4280
4549
|
}
|
|
4281
4550
|
}
|
|
4282
4551
|
//# sourceMappingURL=buyer-proxy.js.map
|