@antseed/cli 0.1.193 → 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.
- package/dist/cli/commands/buyer/start.d.ts.map +1 -1
- package/dist/cli/commands/buyer/start.js +9 -4
- package/dist/cli/commands/buyer/start.js.map +1 -1
- package/dist/cli/commands/gateway/index.d.ts.map +1 -1
- package/dist/cli/commands/gateway/index.js +6 -0
- package/dist/cli/commands/gateway/index.js.map +1 -1
- package/dist/cli/commands/seller/start.d.ts.map +1 -1
- package/dist/cli/commands/seller/start.js +9 -0
- package/dist/cli/commands/seller/start.js.map +1 -1
- package/dist/cli/commands/tunnel/start.d.ts.map +1 -1
- package/dist/cli/commands/tunnel/start.js +8 -2
- package/dist/cli/commands/tunnel/start.js.map +1 -1
- package/dist/cli/commands/update.d.ts +19 -0
- package/dist/cli/commands/update.d.ts.map +1 -0
- package/dist/cli/commands/update.js +153 -0
- package/dist/cli/commands/update.js.map +1 -0
- package/dist/cli/index.js +11 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/program.d.ts.map +1 -1
- package/dist/cli/program.js +2 -0
- package/dist/cli/program.js.map +1 -1
- package/dist/gateway/buyer-process.d.ts +5 -0
- package/dist/gateway/buyer-process.d.ts.map +1 -1
- package/dist/gateway/buyer-process.js +7 -4
- package/dist/gateway/buyer-process.js.map +1 -1
- package/dist/gateway/buyer-process.test.js +28 -2
- package/dist/gateway/buyer-process.test.js.map +1 -1
- package/dist/gateway/console-api/deps.d.ts +3 -0
- package/dist/gateway/console-api/deps.d.ts.map +1 -1
- package/dist/gateway/console-api/handlers/index.d.ts.map +1 -1
- package/dist/gateway/console-api/handlers/index.js +2 -0
- package/dist/gateway/console-api/handlers/index.js.map +1 -1
- package/dist/gateway/console-api/handlers/staking.d.ts +28 -0
- package/dist/gateway/console-api/handlers/staking.d.ts.map +1 -0
- package/dist/gateway/console-api/handlers/staking.js +153 -0
- package/dist/gateway/console-api/handlers/staking.js.map +1 -0
- package/dist/gateway/console-api/handlers/staking.test.d.ts +2 -0
- package/dist/gateway/console-api/handlers/staking.test.d.ts.map +1 -0
- package/dist/gateway/console-api/handlers/staking.test.js +126 -0
- package/dist/gateway/console-api/handlers/staking.test.js.map +1 -0
- package/dist/gateway/console-api/handlers/update.d.ts +13 -0
- package/dist/gateway/console-api/handlers/update.d.ts.map +1 -0
- package/dist/gateway/console-api/handlers/update.js +65 -0
- package/dist/gateway/console-api/handlers/update.js.map +1 -0
- package/dist/gateway/console-api/router.d.ts +1 -1
- package/dist/gateway/console-api/router.d.ts.map +1 -1
- package/dist/gateway/console-api/router.js +8 -3
- package/dist/gateway/console-api/router.js.map +1 -1
- package/dist/gateway/console-api/types.d.ts +47 -0
- package/dist/gateway/console-api/types.d.ts.map +1 -1
- package/dist/gateway/console-api/types.js.map +1 -1
- package/dist/gateway/console-api/update.test.d.ts +2 -0
- package/dist/gateway/console-api/update.test.d.ts.map +1 -0
- package/dist/gateway/console-api/update.test.js +198 -0
- package/dist/gateway/console-api/update.test.js.map +1 -0
- package/dist/gateway/console.d.ts +3 -0
- package/dist/gateway/console.d.ts.map +1 -1
- package/dist/gateway/console.js +3 -0
- package/dist/gateway/console.js.map +1 -1
- package/dist/gateway/gateway-policy.test.js +66 -0
- package/dist/gateway/gateway-policy.test.js.map +1 -1
- package/dist/gateway/gateway.test.js +88 -0
- package/dist/gateway/gateway.test.js.map +1 -1
- package/dist/gateway/policy-resolver.test.js +11 -3
- package/dist/gateway/policy-resolver.test.js.map +1 -1
- package/dist/gateway/request-errors.test.js +119 -3
- package/dist/gateway/request-errors.test.js.map +1 -1
- package/dist/gateway/request-shaping.d.ts +4 -2
- package/dist/gateway/request-shaping.d.ts.map +1 -1
- package/dist/gateway/request-shaping.js +16 -10
- package/dist/gateway/request-shaping.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/gateway/server.d.ts +6 -0
- package/dist/gateway/server.d.ts.map +1 -1
- package/dist/gateway/server.js +162 -73
- package/dist/gateway/server.js.map +1 -1
- package/dist/gateway/services/usage.d.ts.map +1 -1
- package/dist/gateway/services/usage.js +7 -0
- package/dist/gateway/services/usage.js.map +1 -1
- package/dist/gateway/store-data.test.js +1 -1
- package/dist/gateway/store-data.test.js.map +1 -1
- package/dist/gateway/store-migrations.d.ts.map +1 -1
- package/dist/gateway/store-migrations.js +7 -0
- package/dist/gateway/store-migrations.js.map +1 -1
- package/dist/gateway/store.d.ts +24 -1
- package/dist/gateway/store.d.ts.map +1 -1
- package/dist/gateway/store.js +29 -10
- package/dist/gateway/store.js.map +1 -1
- package/dist/gateway/updates.d.ts +34 -0
- package/dist/gateway/updates.d.ts.map +1 -0
- package/dist/gateway/updates.js +155 -0
- package/dist/gateway/updates.js.map +1 -0
- package/dist/proxy/buyer-proxy.d.ts +34 -12
- package/dist/proxy/buyer-proxy.d.ts.map +1 -1
- package/dist/proxy/buyer-proxy.js +320 -61
- package/dist/proxy/buyer-proxy.js.map +1 -1
- package/dist/proxy/buyer-proxy.test.js +430 -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/router-execution.d.ts.map +1 -1
- package/dist/proxy/router-execution.js +14 -4
- package/dist/proxy/router-execution.js.map +1 -1
- package/dist/proxy/router-execution.test.js +35 -0
- package/dist/proxy/router-execution.test.js.map +1 -1
- package/dist/update/install-method.d.ts +25 -0
- package/dist/update/install-method.d.ts.map +1 -0
- package/dist/update/install-method.js +76 -0
- package/dist/update/install-method.js.map +1 -0
- package/dist/update/notifier.d.ts +30 -0
- package/dist/update/notifier.d.ts.map +1 -0
- package/dist/update/notifier.js +84 -0
- package/dist/update/notifier.js.map +1 -0
- package/dist/update/root-updater.d.ts +80 -0
- package/dist/update/root-updater.d.ts.map +1 -0
- package/dist/update/root-updater.js +104 -0
- package/dist/update/root-updater.js.map +1 -0
- package/dist/update/update.test.d.ts +2 -0
- package/dist/update/update.test.d.ts.map +1 -0
- package/dist/update/update.test.js +224 -0
- package/dist/update/update.test.js.map +1 -0
- package/dist/update/version.d.ts +29 -0
- package/dist/update/version.d.ts.map +1 -0
- package/dist/update/version.js +110 -0
- package/dist/update/version.js.map +1 -0
- package/dist/update/watch.d.ts +32 -0
- package/dist/update/watch.d.ts.map +1 -0
- package/dist/update/watch.js +59 -0
- package/dist/update/watch.js.map +1 -0
- package/package.json +10 -10
|
@@ -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';
|
|
@@ -41,6 +42,10 @@ import { buyerConfigReason, buyerHardPolicy, buyerPreferencePolicy, orderByPolic
|
|
|
41
42
|
// Re-export for backward compatibility (used by tests and other consumers)
|
|
42
43
|
export { selectCandidatePeersForRouting } from './routing.js';
|
|
43
44
|
export { parsePeerPinnedService, rewritePeerPinnedServiceInBody, substituteRoutedModelAlias, ROUTED_MODEL_ALIAS } from './request-utils.js';
|
|
45
|
+
/** On shutdown, requests in flight get this long to finish before their connections are closed. */
|
|
46
|
+
export const BUYER_SHUTDOWN_DRAIN_MS = 60_000;
|
|
47
|
+
/** How long a graceful buyer shutdown may take before the process exits anyway: the proxy's drain, then the node. */
|
|
48
|
+
export const BUYER_SHUTDOWN_DEADLINE_MS = BUYER_SHUTDOWN_DRAIN_MS + 15_000;
|
|
44
49
|
const WATCHER_ABSENCE_ERRORS = {
|
|
45
50
|
'payments-disabled': 'Deposit watcher unavailable — payments are disabled on this buyer.',
|
|
46
51
|
'no-deposit-relay': 'Deposit watcher unavailable — this chain has no deposit relay.',
|
|
@@ -58,24 +63,8 @@ const BALANCE_FRESH_FLOOR_MS = 5_000;
|
|
|
58
63
|
const CONTROL_SECRET_RECHECK_MS = 1_000;
|
|
59
64
|
/** Floor between forced re-reads on a secret mismatch, so a stream of bad secrets can't hammer the disk. */
|
|
60
65
|
const CONTROL_SECRET_FORCED_RECHECK_MS = 100;
|
|
61
|
-
const
|
|
62
|
-
const MAX_LATENCY_ENTRIES = 4096;
|
|
63
|
-
/** Responses with fewer output tokens than this say little about a seller's generation speed. */
|
|
64
|
-
const MIN_THROUGHPUT_TOKENS = 20;
|
|
66
|
+
const SELLER_HEALTH_PERSIST_DELAY_MS = 5_000;
|
|
65
67
|
const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000';
|
|
66
|
-
/** Moves `key`'s EMA toward `sample`; the map stays least recently measured first and bounded. */
|
|
67
|
-
function updateEma(map, key, sample) {
|
|
68
|
-
if (!Number.isFinite(sample) || sample < 0)
|
|
69
|
-
return;
|
|
70
|
-
const previous = map.get(key);
|
|
71
|
-
map.delete(key);
|
|
72
|
-
map.set(key, previous === undefined ? sample : previous + LATENCY_EMA_ALPHA * (sample - previous));
|
|
73
|
-
if (map.size > MAX_LATENCY_ENTRIES) {
|
|
74
|
-
const oldest = map.keys().next().value;
|
|
75
|
-
if (oldest !== undefined)
|
|
76
|
-
map.delete(oldest);
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
68
|
/** Exact USDC decimal string from base units, e.g. 1500000n -> "1.500000". */
|
|
80
69
|
function formatUsdcBaseUnits(amount) {
|
|
81
70
|
const sign = amount < 0n ? '-' : '';
|
|
@@ -110,10 +99,49 @@ function retryableStatusCodesFor(gatewayAutoRoute) {
|
|
|
110
99
|
* Whether the routing loop should try the next seller. On automatic routing a
|
|
111
100
|
* seller's model_not_found also moves on: its cached catalog was stale (the
|
|
112
101
|
* seller stopped serving the model), and another seller may still serve it.
|
|
113
|
-
*
|
|
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.
|
|
114
128
|
*/
|
|
115
|
-
function
|
|
116
|
-
|
|
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
|
+
};
|
|
117
145
|
}
|
|
118
146
|
const MODEL_RATE_LIMIT_MAX_ATTEMPTS_PER_PEER = 3;
|
|
119
147
|
const MODEL_RATE_LIMIT_RETRY_DELAYS_MS = [250, 750];
|
|
@@ -789,6 +817,15 @@ export class BuyerProxy {
|
|
|
789
817
|
_peerHealth = new Map();
|
|
790
818
|
/** Decides whether a failure is the peer's fault at all. */
|
|
791
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;
|
|
792
829
|
_heartbeatHandle = null;
|
|
793
830
|
_now;
|
|
794
831
|
/** Latest relayer receipt per sweep authNonce, for CLI progress polling. */
|
|
@@ -820,14 +857,6 @@ export class BuyerProxy {
|
|
|
820
857
|
_controlSecretCheckedAt = 0;
|
|
821
858
|
_controlSecretForcedAt = 0;
|
|
822
859
|
_loggedUnauthenticatedPolicy = false;
|
|
823
|
-
/**
|
|
824
|
-
* Per-seller speed measured by this proxy on streamed requests, as EMAs:
|
|
825
|
-
* time to the first response frame (ms) and output tokens per second after
|
|
826
|
-
* it. Kept apart so a long answer doesn't read as a slow seller. Latency
|
|
827
|
-
* feeds `sort: 'latency'`; sellers without a measurement rank after measured ones.
|
|
828
|
-
*/
|
|
829
|
-
_peerLatencyMs = new Map();
|
|
830
|
-
_peerTokensPerSecond = new Map();
|
|
831
860
|
_balanceReader;
|
|
832
861
|
_balanceCache = new ChainReadCache();
|
|
833
862
|
_restartHandler;
|
|
@@ -869,6 +898,7 @@ export class BuyerProxy {
|
|
|
869
898
|
}
|
|
870
899
|
: null;
|
|
871
900
|
this._now = config.now ?? (() => Date.now());
|
|
901
|
+
this._adaptiveRouting = { ...config.adaptiveRouting };
|
|
872
902
|
this._server = createServer((req, res) => {
|
|
873
903
|
this._handleRequest(req, res).catch((err) => {
|
|
874
904
|
log('Unhandled error:', err);
|
|
@@ -896,6 +926,17 @@ export class BuyerProxy {
|
|
|
896
926
|
this._spendAttribution.record(event);
|
|
897
927
|
});
|
|
898
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
|
+
}
|
|
899
940
|
const eventNode = this._node;
|
|
900
941
|
if (typeof eventNode.on === 'function') {
|
|
901
942
|
eventNode.on('peers:discovered', (peers) => {
|
|
@@ -1087,24 +1128,55 @@ export class BuyerProxy {
|
|
|
1087
1128
|
teeCapable: parseVerifierCapabilities(peer.capabilities).supported.includes(TEE_VERIFIER_ID),
|
|
1088
1129
|
};
|
|
1089
1130
|
}
|
|
1090
|
-
/**
|
|
1091
|
-
|
|
1092
|
-
return
|
|
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;
|
|
1093
1134
|
}
|
|
1094
1135
|
_peerLatency(peerId, serviceId) {
|
|
1095
|
-
return this.
|
|
1136
|
+
return this._sellerHealth.stats(peerId, this._healthServiceKey(serviceId), this._now())?.firstTokenMs ?? null;
|
|
1096
1137
|
}
|
|
1097
1138
|
_peerThroughput(peerId, serviceId) {
|
|
1098
|
-
return this.
|
|
1139
|
+
return this._sellerHealth.stats(peerId, this._healthServiceKey(serviceId), this._now())?.tokensPerSecond ?? null;
|
|
1099
1140
|
}
|
|
1100
|
-
/**
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
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;
|
|
1106
1155
|
}
|
|
1107
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
|
+
}
|
|
1108
1180
|
/**
|
|
1109
1181
|
* Candidate selection and ranking for a model-only request. The real
|
|
1110
1182
|
* routing path and `GET /_antseed/route-preview` both call this, so the
|
|
@@ -1208,6 +1280,33 @@ export class BuyerProxy {
|
|
|
1208
1280
|
candidates = this._readyFirst(ranked, verdicts, now);
|
|
1209
1281
|
}
|
|
1210
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);
|
|
1211
1310
|
let chainPosition = null;
|
|
1212
1311
|
if (effectivePolicy) {
|
|
1213
1312
|
const ordering = orderByPolicy(candidates, effectivePolicy, requestedService, {
|
|
@@ -1216,6 +1315,7 @@ export class BuyerProxy {
|
|
|
1216
1315
|
trustScore: (candidate) => (candidate.reputation >= 0 ? candidate.reputation : null),
|
|
1217
1316
|
latencyMs: (candidate) => this._peerLatency(candidate.peerId, candidate.serviceId),
|
|
1218
1317
|
coolingDown: isCooling,
|
|
1318
|
+
unhealthy: (candidate) => healthOf(candidate).unhealthy,
|
|
1219
1319
|
});
|
|
1220
1320
|
for (const candidate of candidates) {
|
|
1221
1321
|
const reason = ordering.excluded.get(normalizePolicyPeerId(candidate.peerId));
|
|
@@ -1230,7 +1330,8 @@ export class BuyerProxy {
|
|
|
1230
1330
|
const preferredPeerId = input.preferredPeerId;
|
|
1231
1331
|
if (preferredPeerId && !findModelRoute(effectivePolicy, requestedService)) {
|
|
1232
1332
|
const preferredIndex = candidates.findIndex((candidate) => (candidate.peer.peerId.toLowerCase() === preferredPeerId
|
|
1233
|
-
&& !isCoolingDown(this._peerHealth.get(candidate.peer.peerId), now)
|
|
1333
|
+
&& !isCoolingDown(this._peerHealth.get(candidate.peer.peerId), now)
|
|
1334
|
+
&& !healthOf(candidate).unhealthy));
|
|
1234
1335
|
if (preferredIndex > 0) {
|
|
1235
1336
|
const [preferred] = candidates.splice(preferredIndex, 1);
|
|
1236
1337
|
if (preferred)
|
|
@@ -1246,18 +1347,24 @@ export class BuyerProxy {
|
|
|
1246
1347
|
const position = chainPosition?.get(normalizePolicyPeerId(candidate.peerId));
|
|
1247
1348
|
if (position !== undefined)
|
|
1248
1349
|
verdict.reasons.push(`route chain #${position + 1}`);
|
|
1350
|
+
const health = healthOf(candidate);
|
|
1249
1351
|
if (isCooling(candidate))
|
|
1250
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)`);
|
|
1251
1357
|
if (modelRouteTotalPrice(candidate) === 0)
|
|
1252
1358
|
verdict.reasons.push('free');
|
|
1359
|
+
verdict.reasons.push(`success ~${Math.round(health.successProbability * 100)}%${health.stats ? '' : ' (no data yet)'}`);
|
|
1253
1360
|
const latency = this._peerLatency(candidate.peerId, candidate.serviceId);
|
|
1254
1361
|
if (latency !== null)
|
|
1255
|
-
verdict.reasons.push(`
|
|
1362
|
+
verdict.reasons.push(`first token ~${Math.round(latency)}ms`);
|
|
1256
1363
|
const throughput = this._peerThroughput(candidate.peerId, candidate.serviceId);
|
|
1257
1364
|
if (throughput !== null)
|
|
1258
1365
|
verdict.reasons.push(`~${Math.round(throughput)} tok/s`);
|
|
1259
1366
|
});
|
|
1260
|
-
return { candidates, verdicts: [...verdicts.values()], effectivePolicy };
|
|
1367
|
+
return { candidates, verdicts: [...verdicts.values()], effectivePolicy, explored: adaptive.explored };
|
|
1261
1368
|
}
|
|
1262
1369
|
/**
|
|
1263
1370
|
* Without routing preferences: best effective reputation first, and only
|
|
@@ -1301,6 +1408,7 @@ export class BuyerProxy {
|
|
|
1301
1408
|
requiredParameters: [],
|
|
1302
1409
|
requestPolicy,
|
|
1303
1410
|
preferredPeerId: null,
|
|
1411
|
+
deterministic: true,
|
|
1304
1412
|
});
|
|
1305
1413
|
const effective = plan.effectivePolicy;
|
|
1306
1414
|
const modelAllowed = policyAllowsModel(effective, model);
|
|
@@ -1569,6 +1677,7 @@ export class BuyerProxy {
|
|
|
1569
1677
|
// impossibly distant, so a restart can never extend one. Nothing new can
|
|
1570
1678
|
// escalate until a success re-establishes that the buyer is healthy.
|
|
1571
1679
|
this._peerHealth = parsePersistedPeerHealth(parsed, this._now());
|
|
1680
|
+
this._sellerHealth = SellerHealthBook.parse(parsed.sellerHealth, this._now());
|
|
1572
1681
|
const peers = parsePersistedPeers(parsed);
|
|
1573
1682
|
if (peers.length === 0) {
|
|
1574
1683
|
return;
|
|
@@ -1592,6 +1701,11 @@ export class BuyerProxy {
|
|
|
1592
1701
|
async stop() {
|
|
1593
1702
|
this._teeVerification.close();
|
|
1594
1703
|
await this._teeControl.close();
|
|
1704
|
+
if (this._peerHealthPersistTimer) {
|
|
1705
|
+
clearTimeout(this._peerHealthPersistTimer);
|
|
1706
|
+
this._peerHealthPersistTimer = null;
|
|
1707
|
+
await this._persistPeerHealthToState().catch(() => { });
|
|
1708
|
+
}
|
|
1595
1709
|
if (this._stateWatchDebounce) {
|
|
1596
1710
|
clearTimeout(this._stateWatchDebounce);
|
|
1597
1711
|
this._stateWatchDebounce = null;
|
|
@@ -1618,8 +1732,13 @@ export class BuyerProxy {
|
|
|
1618
1732
|
}
|
|
1619
1733
|
await this._writeStateFile('stopped');
|
|
1620
1734
|
await this._conversations.flush();
|
|
1735
|
+
// Requests in flight finish (a supervisor restarting the buyer holds new
|
|
1736
|
+
// ones meanwhile), up to the drain limit.
|
|
1621
1737
|
return new Promise((resolve) => {
|
|
1738
|
+
const drain = setTimeout(() => this._server.closeAllConnections(), BUYER_SHUTDOWN_DRAIN_MS);
|
|
1739
|
+
drain.unref?.();
|
|
1622
1740
|
this._server.close(() => {
|
|
1741
|
+
clearTimeout(drain);
|
|
1623
1742
|
this._spendAttribution.close();
|
|
1624
1743
|
resolve();
|
|
1625
1744
|
});
|
|
@@ -2098,6 +2217,7 @@ export class BuyerProxy {
|
|
|
2098
2217
|
this._peerHealth = prunePeerHealth(this._peerHealth, now);
|
|
2099
2218
|
await this._mergeStateFile({
|
|
2100
2219
|
peerHealth: serializePeerHealth(this._peerHealth),
|
|
2220
|
+
sellerHealth: this._sellerHealth.serialize(now),
|
|
2101
2221
|
peerHealthUpdatedAt: now,
|
|
2102
2222
|
});
|
|
2103
2223
|
}
|
|
@@ -2296,6 +2416,18 @@ export class BuyerProxy {
|
|
|
2296
2416
|
suppressedUntil: attribution.suppressedUntil,
|
|
2297
2417
|
lastSuppressedBy: attribution.lastSuppressedBy,
|
|
2298
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
|
+
})),
|
|
2299
2431
|
}));
|
|
2300
2432
|
return;
|
|
2301
2433
|
}
|
|
@@ -2330,6 +2462,8 @@ export class BuyerProxy {
|
|
|
2330
2462
|
// Deliberately does not stamp a success: the user is asking us to give
|
|
2331
2463
|
// the peer another chance, not asserting that it answered.
|
|
2332
2464
|
this._rollbackPeerHealth([normalized], 'cleared by request');
|
|
2465
|
+
this._sellerHealth.forget(normalized);
|
|
2466
|
+
void this._persistPeerHealthToState();
|
|
2333
2467
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
2334
2468
|
res.end(JSON.stringify({ ok: true, peerId: normalized }));
|
|
2335
2469
|
return;
|
|
@@ -3550,7 +3684,7 @@ export class BuyerProxy {
|
|
|
3550
3684
|
({ candidatePeers: modelPeers, routePlanByPeerId: modelPlans } = selectModelPeers(discoveredPeers));
|
|
3551
3685
|
}
|
|
3552
3686
|
const router = this._node.router;
|
|
3553
|
-
const { candidates, effectivePolicy } = this._planModelRoutes({
|
|
3687
|
+
const { candidates, effectivePolicy, explored } = this._planModelRoutes({
|
|
3554
3688
|
modelPeers,
|
|
3555
3689
|
modelPlans,
|
|
3556
3690
|
request: serializedReq,
|
|
@@ -3560,6 +3694,9 @@ export class BuyerProxy {
|
|
|
3560
3694
|
requiredParameters,
|
|
3561
3695
|
requestPolicy,
|
|
3562
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),
|
|
3563
3700
|
});
|
|
3564
3701
|
const requireVerified = effectivePolicy?.requireVerified === true;
|
|
3565
3702
|
if (candidates.length === 0) {
|
|
@@ -3604,6 +3741,31 @@ export class BuyerProxy {
|
|
|
3604
3741
|
// Set once a seller refused for lack of deposits: every other paid
|
|
3605
3742
|
// seller would too, so only free ones are still worth trying.
|
|
3606
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;
|
|
3607
3769
|
const isFree = (candidate) => modelRouteTotalPrice(candidate) === 0;
|
|
3608
3770
|
for (const [index, selected] of candidates.entries()) {
|
|
3609
3771
|
if (unfunded && !isFree(selected))
|
|
@@ -3622,11 +3784,29 @@ export class BuyerProxy {
|
|
|
3622
3784
|
+ `service="${selected.serviceId}" reputation=${selected.reputation} `
|
|
3623
3785
|
+ `effective=${selected.effectiveReputationScore ?? 'unknown'} peer=${index + 1}/${candidates.length} `
|
|
3624
3786
|
+ `attempt=${peerAttempt + 1}/${MODEL_RATE_LIMIT_MAX_ATTEMPTS_PER_PEER}`);
|
|
3625
|
-
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);
|
|
3626
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);
|
|
3627
3807
|
// Only a seller that answered becomes the chat's route: an error
|
|
3628
3808
|
// must not pin later turns to it.
|
|
3629
|
-
if (trackedConversationId &&
|
|
3809
|
+
if (trackedConversationId && answered) {
|
|
3630
3810
|
this._conversations.recordRoutedModel(trackedConversationId, `${selected.peer.peerId}@${selected.serviceId}`, conversationPinAtRouting);
|
|
3631
3811
|
}
|
|
3632
3812
|
return result;
|
|
@@ -3636,6 +3816,15 @@ export class BuyerProxy {
|
|
|
3636
3816
|
if (nativeVideo)
|
|
3637
3817
|
return result;
|
|
3638
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
|
+
}
|
|
3639
3828
|
if (isBuyerFault(result)) {
|
|
3640
3829
|
const freeLeft = candidates.slice(index + 1).some(isFree);
|
|
3641
3830
|
if (result.statusCode === 402 && freeLeft) {
|
|
@@ -3658,6 +3847,7 @@ export class BuyerProxy {
|
|
|
3658
3847
|
log(`Peer ${selected.peer.peerId.slice(0, 12)}... failed retryably; trying next model peer.`);
|
|
3659
3848
|
}
|
|
3660
3849
|
}
|
|
3850
|
+
countFailures(null);
|
|
3661
3851
|
return lastRetry ?? dispatchFailure(502, { 'content-type': 'application/json' }, JSON.stringify({
|
|
3662
3852
|
error: {
|
|
3663
3853
|
type: 'peer_verification_failed',
|
|
@@ -3820,7 +4010,13 @@ export class BuyerProxy {
|
|
|
3820
4010
|
if (result.done && trackedConversationId && pinnedServiceId) {
|
|
3821
4011
|
this._conversations.recordRoutedModel(trackedConversationId, `${selectedPeer.peerId}@${pinnedServiceId}`, conversationPinAtRouting);
|
|
3822
4012
|
}
|
|
3823
|
-
// A pinned peer is never retried against another peer; the caller reports
|
|
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
|
+
}
|
|
3824
4020
|
return result;
|
|
3825
4021
|
}
|
|
3826
4022
|
async _verifyPeer(peer, makeReach, signal, requireVerified = false) {
|
|
@@ -3906,7 +4102,9 @@ export class BuyerProxy {
|
|
|
3906
4102
|
* was sent to the client (success or non-retryable error), or retry info if the
|
|
3907
4103
|
* caller should try another peer.
|
|
3908
4104
|
*/
|
|
3909
|
-
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) {
|
|
3910
4108
|
const gatewayAutoRoute = gatewayRouted && !pinned;
|
|
3911
4109
|
const selectedRoutePlan = routePlanByPeerId.get(selectedPeer.peerId)
|
|
3912
4110
|
?? resolvePeerRoutePlan(selectedPeer, requestProtocol, requestedService, explicitProvider, 'lenient');
|
|
@@ -4004,16 +4202,29 @@ export class BuyerProxy {
|
|
|
4004
4202
|
const peerResponseProtocol = selectedRoutePlan.selection?.targetProtocol ?? requestProtocol;
|
|
4005
4203
|
const adaptPeerResponse = (response) => adaptPeerFaultErrorResponse(response, peerResponseProtocol, { pinned });
|
|
4006
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);
|
|
4007
4213
|
try {
|
|
4008
4214
|
if (wantsStreaming) {
|
|
4009
4215
|
let streamed = false;
|
|
4010
4216
|
let firstFrameAt = 0;
|
|
4217
|
+
let streamStartBody = new Uint8Array();
|
|
4011
4218
|
const response = await this._node.sendRequestStream(selectedPeer, requestForPeer, {
|
|
4012
4219
|
onResponseStart: (startResponse, metadata) => {
|
|
4013
4220
|
if (!metadata.streaming)
|
|
4014
4221
|
return;
|
|
4222
|
+
// The seller is answering: from here a slow answer is still an answer.
|
|
4223
|
+
if (attemptTimer)
|
|
4224
|
+
clearTimeout(attemptTimer);
|
|
4015
4225
|
streamed = true;
|
|
4016
4226
|
firstFrameAt = Date.now();
|
|
4227
|
+
streamStartBody = startResponse.body;
|
|
4017
4228
|
const adaptedStartResponse = streamResponseAdapter
|
|
4018
4229
|
? streamResponseAdapter.adaptStart(startResponse)
|
|
4019
4230
|
: startResponse;
|
|
@@ -4042,7 +4253,7 @@ export class BuyerProxy {
|
|
|
4042
4253
|
}
|
|
4043
4254
|
}
|
|
4044
4255
|
},
|
|
4045
|
-
}, { signal:
|
|
4256
|
+
}, { signal: attemptSignal, pinned, ...(buyerIdentity ? { buyerIdentity } : {}) });
|
|
4046
4257
|
recordRouterUsage(this._modelRoutingClient, routingConversationKey, requestForPeer, response, selectedPeer, requestSignal);
|
|
4047
4258
|
let responseForClient = adaptBuyerFaultErrorResponse(response, requestProtocol);
|
|
4048
4259
|
responseForClient = adaptPeerResponse(responseForClient);
|
|
@@ -4064,8 +4275,9 @@ export class BuyerProxy {
|
|
|
4064
4275
|
log(`${prefix}: ${summarizeErrorResponse(responseForClient)}`);
|
|
4065
4276
|
}
|
|
4066
4277
|
const telemetry = computeResponseTelemetry(requestForPeer, responseForClient.headers, responseForClient.body, selectedPeer);
|
|
4278
|
+
let streamTiming = null;
|
|
4067
4279
|
if (streamed && responseForClient.statusCode < 400) {
|
|
4068
|
-
|
|
4280
|
+
streamTiming = { firstTokenMs: firstFrameAt - startTime, streamMs: finishedAt - firstFrameAt, outputTokens: telemetry.usage.outputTokens, streamed: true };
|
|
4069
4281
|
}
|
|
4070
4282
|
const responseFault = responseFaultAttribution(responseForClient);
|
|
4071
4283
|
const modelNotFound = !streamed
|
|
@@ -4089,30 +4301,58 @@ export class BuyerProxy {
|
|
|
4089
4301
|
this._recordPeerResponseHealth(selectedPeer.peerId, responseForClient.statusCode, requestForPeer.path);
|
|
4090
4302
|
}
|
|
4091
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);
|
|
4092
4324
|
// Headers already sent to client, can't retry
|
|
4093
4325
|
if (!res.writableEnded) {
|
|
4094
4326
|
res.end();
|
|
4095
4327
|
}
|
|
4096
|
-
return { done: true };
|
|
4328
|
+
return streamFailed ? { done: true, interrupted: 'seller' } : { done: true, emptyAnswer: streamedEmpty };
|
|
4097
4329
|
}
|
|
4098
4330
|
// Non-streamed response — check if retryable
|
|
4099
4331
|
const responseHeaders = attachAntseedTelemetryHeaders(responseForClient.headers, selectedPeer, telemetry, requestForPeer.requestId, latencyMs);
|
|
4100
|
-
|
|
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')) {
|
|
4101
4336
|
return {
|
|
4102
4337
|
done: false,
|
|
4103
4338
|
statusCode: responseForClient.statusCode,
|
|
4104
4339
|
responseBody: Buffer.from(responseForClient.body),
|
|
4105
4340
|
responseHeaders,
|
|
4106
4341
|
errorMessage: null,
|
|
4342
|
+
modelNotFound,
|
|
4343
|
+
emptyAnswer,
|
|
4107
4344
|
};
|
|
4108
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
|
+
}
|
|
4109
4349
|
res.writeHead(responseForClient.statusCode, responseHeaders);
|
|
4110
4350
|
res.end(Buffer.from(responseForClient.body));
|
|
4111
|
-
return { done: true };
|
|
4351
|
+
return { done: true, emptyAnswer: answeredEmpty };
|
|
4112
4352
|
}
|
|
4113
4353
|
else {
|
|
4114
4354
|
const upstreamResponse = await this._node.sendRequest(selectedPeer, requestForPeer, {
|
|
4115
|
-
signal:
|
|
4355
|
+
signal: attemptSignal,
|
|
4116
4356
|
pinned,
|
|
4117
4357
|
...(buyerIdentity ? { buyerIdentity } : {}),
|
|
4118
4358
|
});
|
|
@@ -4168,18 +4408,27 @@ export class BuyerProxy {
|
|
|
4168
4408
|
this._recordPeerResponseHealth(selectedPeer.peerId, response.statusCode, requestForPeer.path);
|
|
4169
4409
|
}
|
|
4170
4410
|
// Check if retryable
|
|
4171
|
-
|
|
4172
|
-
|
|
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 });
|
|
4173
4419
|
}
|
|
4174
4420
|
// Forward response headers and body to the HTTP client
|
|
4175
4421
|
res.writeHead(response.statusCode, responseHeaders);
|
|
4176
4422
|
res.end(Buffer.from(response.body));
|
|
4177
|
-
return { done: true };
|
|
4423
|
+
return { done: true, emptyAnswer: answeredEmpty };
|
|
4178
4424
|
}
|
|
4179
4425
|
}
|
|
4180
4426
|
catch (err) {
|
|
4181
4427
|
const latencyMs = Date.now() - startTime;
|
|
4182
|
-
const
|
|
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);
|
|
4183
4432
|
const abortedLocally = requestSignal.aborted;
|
|
4184
4433
|
log(`Request failed after ${latencyMs}ms: ${message}`);
|
|
4185
4434
|
if (abortedLocally) {
|
|
@@ -4213,7 +4462,7 @@ export class BuyerProxy {
|
|
|
4213
4462
|
// ignore
|
|
4214
4463
|
}
|
|
4215
4464
|
}
|
|
4216
|
-
return { done: true };
|
|
4465
|
+
return { done: true, interrupted: 'cancelled' };
|
|
4217
4466
|
}
|
|
4218
4467
|
// Whose fault was this? Errors raised by our own wallet, deposits,
|
|
4219
4468
|
// transport or state machine say nothing about the peer, and telling the
|
|
@@ -4228,7 +4477,7 @@ export class BuyerProxy {
|
|
|
4228
4477
|
if (!res.writableEnded) {
|
|
4229
4478
|
res.end();
|
|
4230
4479
|
}
|
|
4231
|
-
return { done: true };
|
|
4480
|
+
return { done: true, interrupted: fault === 'buyer' ? 'buyer' : 'seller' };
|
|
4232
4481
|
}
|
|
4233
4482
|
if (fault === 'buyer') {
|
|
4234
4483
|
const buyerResponse = adaptBuyerFaultErrorResponse({
|
|
@@ -4266,8 +4515,18 @@ export class BuyerProxy {
|
|
|
4266
4515
|
responseBody: Buffer.from(peerResponse.body),
|
|
4267
4516
|
responseHeaders: peerResponse.headers,
|
|
4268
4517
|
errorMessage: message,
|
|
4518
|
+
timedOut,
|
|
4269
4519
|
};
|
|
4270
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
|
+
}
|
|
4271
4530
|
}
|
|
4272
4531
|
}
|
|
4273
4532
|
//# sourceMappingURL=buyer-proxy.js.map
|