@antseed/cli 0.1.188 → 0.1.189
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/README.md +9 -0
- package/dist/config/service-metadata.test.js +4 -0
- package/dist/config/service-metadata.test.js.map +1 -1
- package/dist/gateway/console-api/handlers/network-mapping.d.ts +2 -0
- package/dist/gateway/console-api/handlers/network-mapping.d.ts.map +1 -1
- package/dist/gateway/console-api/handlers/network-mapping.js +4 -0
- package/dist/gateway/console-api/handlers/network-mapping.js.map +1 -1
- package/dist/gateway/console-api/handlers/network.test.js +5 -1
- package/dist/gateway/console-api/handlers/network.test.js.map +1 -1
- package/dist/gateway/console-api/types.d.ts +10 -2
- package/dist/gateway/console-api/types.d.ts.map +1 -1
- package/dist/gateway/console-api/types.js.map +1 -1
- package/dist/gateway/gateway-policy.test.js +44 -0
- package/dist/gateway/gateway-policy.test.js.map +1 -1
- package/dist/gateway/server.d.ts.map +1 -1
- package/dist/gateway/server.js +12 -1
- package/dist/gateway/server.js.map +1 -1
- package/dist/gateway/services/usage.d.ts.map +1 -1
- package/dist/gateway/services/usage.js +3 -1
- 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 +5 -0
- package/dist/gateway/store-migrations.js.map +1 -1
- package/dist/gateway/store.d.ts +9 -2
- package/dist/gateway/store.d.ts.map +1 -1
- package/dist/gateway/store.js +26 -10
- package/dist/gateway/store.js.map +1 -1
- package/dist/gateway/store.test.js +1 -1
- package/dist/gateway/store.test.js.map +1 -1
- package/dist/plugins/registry.d.ts.map +1 -1
- package/dist/plugins/registry.js +6 -0
- package/dist/plugins/registry.js.map +1 -1
- package/dist/proxy/buyer-proxy.d.ts +64 -8
- package/dist/proxy/buyer-proxy.d.ts.map +1 -1
- package/dist/proxy/buyer-proxy.js +519 -144
- package/dist/proxy/buyer-proxy.js.map +1 -1
- package/dist/proxy/buyer-proxy.test.js +1331 -5
- package/dist/proxy/buyer-proxy.test.js.map +1 -1
- package/dist/proxy/conversation-store.d.ts +8 -0
- package/dist/proxy/conversation-store.d.ts.map +1 -1
- package/dist/proxy/conversation-store.js +20 -0
- package/dist/proxy/conversation-store.js.map +1 -1
- package/dist/proxy/conversation-store.test.js +54 -1
- package/dist/proxy/conversation-store.test.js.map +1 -1
- package/dist/proxy/gateway-policy.test.js +145 -7
- package/dist/proxy/gateway-policy.test.js.map +1 -1
- package/dist/proxy/network-models.d.ts +21 -1
- package/dist/proxy/network-models.d.ts.map +1 -1
- package/dist/proxy/network-models.js +2 -0
- package/dist/proxy/network-models.js.map +1 -1
- package/dist/proxy/request-utils.d.ts +20 -0
- package/dist/proxy/request-utils.d.ts.map +1 -1
- package/dist/proxy/request-utils.js +46 -1
- package/dist/proxy/request-utils.js.map +1 -1
- package/dist/proxy/route-policy.d.ts +7 -0
- package/dist/proxy/route-policy.d.ts.map +1 -1
- package/dist/proxy/route-policy.js +10 -0
- package/dist/proxy/route-policy.js.map +1 -1
- package/dist/proxy/router-execution.d.ts +63 -0
- package/dist/proxy/router-execution.d.ts.map +1 -0
- package/dist/proxy/router-execution.js +220 -0
- package/dist/proxy/router-execution.js.map +1 -0
- package/dist/proxy/router-execution.test.d.ts +2 -0
- package/dist/proxy/router-execution.test.d.ts.map +1 -0
- package/dist/proxy/router-execution.test.js +269 -0
- package/dist/proxy/router-execution.test.js.map +1 -0
- package/dist/proxy/routing-services.d.ts +37 -0
- package/dist/proxy/routing-services.d.ts.map +1 -0
- package/dist/proxy/routing-services.js +58 -0
- package/dist/proxy/routing-services.js.map +1 -0
- package/dist/proxy/routing-services.test.d.ts +2 -0
- package/dist/proxy/routing-services.test.d.ts.map +1 -0
- package/dist/proxy/routing-services.test.js +95 -0
- package/dist/proxy/routing-services.test.js.map +1 -0
- package/dist/proxy/routing-usage.d.ts +4 -0
- package/dist/proxy/routing-usage.d.ts.map +1 -0
- package/dist/proxy/routing-usage.js +19 -0
- package/dist/proxy/routing-usage.js.map +1 -0
- package/dist/proxy/routing-usage.test.d.ts +2 -0
- package/dist/proxy/routing-usage.test.d.ts.map +1 -0
- package/dist/proxy/routing-usage.test.js +32 -0
- package/dist/proxy/routing-usage.test.js.map +1 -0
- package/dist/proxy/telemetry.d.ts +5 -2
- package/dist/proxy/telemetry.d.ts.map +1 -1
- package/dist/proxy/telemetry.js +7 -2
- package/dist/proxy/telemetry.js.map +1 -1
- package/package.json +11 -9
|
@@ -4,21 +4,24 @@ import { prepareVideoRequest, recordVideoAcceptance } from './native-video-proxy
|
|
|
4
4
|
import { downloadVideo } from './video-download.js';
|
|
5
5
|
import { createServer } from 'node:http';
|
|
6
6
|
import { randomUUID } from 'node:crypto';
|
|
7
|
+
import { buildRoutingServices, findPinnedRoutingService } from './routing-services.js';
|
|
7
8
|
import { readFileSync, watchFile, unwatchFile } from 'node:fs';
|
|
8
9
|
import { readFile, writeFile, rename, mkdir, readdir, stat, unlink } from 'node:fs/promises';
|
|
9
10
|
import { join } from 'node:path';
|
|
10
|
-
import { ANTSEED_BUYER_FAULT_ERROR_CODE, ANTSEED_FAULT_ATTRIBUTION_HEADER, ANTSEED_ATTEST_PATH, adaptPeerFaultErrorResponse, computeTrustScore, decodeSweepRequest, faultAttributionOf, faultCodeOf, isModelRouteCoolingDown, isModelRouteEligible, modelRouteReputationScore, modelRouteTotalPrice, normalizedModelReputationScore, peerSupportsCooperativeClose, 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, sanitizePeerDisplayName, } from '@antseed/node';
|
|
11
12
|
import { canonicalModelKey } from '@antseed/node/model-identity';
|
|
12
13
|
import { createStreamingAdapter, detectRequestServiceApiProtocol, transformRequest, transformResponse, } from './service-api-adapter.js';
|
|
13
|
-
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, } from './request-utils.js';
|
|
14
|
+
import { DEBUG, log, extractRequestedService, summarizeRequestShape, summarizeErrorResponse, requestWantsStreaming, parsePeerPinnedService, rewritePeerPinnedServiceInBody, substituteRoutedModelAlias, overrideRoutedModelInBody, ROUTED_MODEL_ALIAS, SYSTEM_PROXY_SOURCE_HEADER, SYSTEM_ROUTED_MODEL_HEADER, BUYER_IDENTITY_HEADER, normalizePeerId, takeRoutingOptions, } from './request-utils.js';
|
|
14
15
|
import { BuyerIdentityLoader } from '../buyer-identities/loader.js';
|
|
15
16
|
import { buildNetworkModels, effectiveModelReputationScore, parseModelTypeFilter, } from './network-models.js';
|
|
16
17
|
import { findMissingRequiredParameters, findUnannouncedRequestParameters, findAdvertisedServiceOffer, findAdvertisedServiceProtocols, getExplicitProviderOverride, getExplicitPeerIdOverride, parseRequiredParametersHeader, REQUIRED_PARAMETERS_HEADER, resolvePeerRoutePlan, selectCandidatePeersForRouting, } from './routing.js';
|
|
17
18
|
import { computeResponseTelemetry, attachAntseedTelemetryHeaders, attachStreamingAntseedHeaders, } from './telemetry.js';
|
|
18
19
|
import { DEFAULT_BUYER_PEER_REFRESH_INTERVAL_MS } from '../config/defaults.js';
|
|
19
20
|
import { extractConversationIdentity, extractFirstUserSnippet, isCompletionRequestPath, isTitleGenerationRequest, parseRequestBodyObject, } from './conversation-identity.js';
|
|
20
|
-
import { SPEND_ATTRIBUTION_DB_FILE, SPEND_ATTRIBUTION_HEADER, SpendAttributionFeed, parseSpendAttributionTag } from './spend-attribution.js';
|
|
21
21
|
import { ConversationStore } from './conversation-store.js';
|
|
22
|
+
import { eligibleRouterCandidates, executeRouterSelection, NoEligibleCandidatesError, requestForRecommendation, RoutingModelsCache } from './router-execution.js';
|
|
23
|
+
import { recordRouterUsage } from './routing-usage.js';
|
|
24
|
+
import { SPEND_ATTRIBUTION_DB_FILE, SPEND_ATTRIBUTION_HEADER, SpendAttributionFeed, parseSpendAttributionTag } from './spend-attribution.js';
|
|
22
25
|
import { DEPOSIT_WATCH_ACTIVE_TTL_MS } from './deposit-watcher.js';
|
|
23
26
|
import { DepositLog } from './deposit-log.js';
|
|
24
27
|
import { recordPeerFailureEntry, clearPeerHealthEntry, isCoolingDown, parsePersistedPeerHealth, prunePeerHealth, serializePeerHealth, reasonEscalates, } from './peer-health.js';
|
|
@@ -32,8 +35,9 @@ import { TeeControl } from './tee-control.js';
|
|
|
32
35
|
import { ChainReadCache } from './chain-read-cache.js';
|
|
33
36
|
import { isClientCredentialHeader } from './credential-headers.js';
|
|
34
37
|
import { loadConfig } from '../config/loader.js';
|
|
38
|
+
import { ModelRoutingClient } from '@antseed/router-core';
|
|
35
39
|
import { GATEWAY_CONTROL_HEADER, GATEWAY_CONTROL_SECRET_ENV, GATEWAY_CONTROL_SECRET_FILE, ROUTING_POLICY_HEADER, decodePolicyHeader, findModelRoute, meaningfulPolicy, narrowPolicy, normalizePeerId as normalizePolicyPeerId, policyAllowsModel, policyModelKey, } from '../routing-policy/policy.js';
|
|
36
|
-
import { buyerConfigReason, buyerHardPolicy, buyerPreferencePolicy, orderByPolicy, pinnedPeerExclusionReasons, policyExclusionReasons, preferenceExclusionReasons, rankingPreferences, secretsMatch, } from './route-policy.js';
|
|
40
|
+
import { buyerConfigReason, buyerHardPolicy, buyerPreferencePolicy, orderByPolicy, pinnedPeerExclusionReasons, policyExclusionReasons, preferenceExclusionReasons, rankingPreferences, secretsMatch, routingServicePolicy, } from './route-policy.js';
|
|
37
41
|
// Re-export for backward compatibility (used by tests and other consumers)
|
|
38
42
|
export { selectCandidatePeersForRouting } from './routing.js';
|
|
39
43
|
export { parsePeerPinnedService, rewritePeerPinnedServiceInBody, substituteRoutedModelAlias, ROUTED_MODEL_ALIAS } from './request-utils.js';
|
|
@@ -56,7 +60,22 @@ const CONTROL_SECRET_RECHECK_MS = 1_000;
|
|
|
56
60
|
const CONTROL_SECRET_FORCED_RECHECK_MS = 100;
|
|
57
61
|
const LATENCY_EMA_ALPHA = 0.3;
|
|
58
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;
|
|
59
65
|
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
|
+
}
|
|
60
79
|
/** Exact USDC decimal string from base units, e.g. 1500000n -> "1.500000". */
|
|
61
80
|
function formatUsdcBaseUnits(amount) {
|
|
62
81
|
const sign = amount < 0n ? '-' : '';
|
|
@@ -76,6 +95,8 @@ function isLoopbackAddress(address) {
|
|
|
76
95
|
// because model_not_found already has dedicated unadvertise handling while a
|
|
77
96
|
// generic 404 would repeat identically on every peer.
|
|
78
97
|
const RETRYABLE_STATUS_CODES = new Set([401, 403, 408, 429, 500, 502, 503, 504]);
|
|
98
|
+
// Moving to another recommendation is a new purchase, so skip timeouts (408/504), where the seller may already have done the work.
|
|
99
|
+
const RECOMMENDATION_FALLBACK_STATUS_CODES = new Set([401, 403, 429, 500, 502, 503]);
|
|
79
100
|
/**
|
|
80
101
|
* Gateway-policy requests on automatic routing also move on after a 402: the
|
|
81
102
|
* buyer could not fund that seller (no deposits for its reserve), but a free
|
|
@@ -132,6 +153,10 @@ function isValidRoutedModelTarget(value) {
|
|
|
132
153
|
return false;
|
|
133
154
|
return !value.includes('@') || parsePeerPinnedService(value) !== null;
|
|
134
155
|
}
|
|
156
|
+
function sameRoutingServiceTarget(a, b) {
|
|
157
|
+
return !!a && !!b && a.peerId.toLowerCase() === b.peerId.toLowerCase()
|
|
158
|
+
&& a.provider === b.provider && a.serviceId === b.serviceId;
|
|
159
|
+
}
|
|
135
160
|
/** Returns `request` with its body's model field rewritten to `serviceId`, or unchanged if nothing rewrote. */
|
|
136
161
|
function withRoutedModel(request, serviceId) {
|
|
137
162
|
const rewritten = overrideRoutedModelInBody(request.body, request.headers, serviceId);
|
|
@@ -239,6 +264,18 @@ function responseFaultAttribution(response) {
|
|
|
239
264
|
? attribution
|
|
240
265
|
: 'peer';
|
|
241
266
|
}
|
|
267
|
+
function dispatchFailure(statusCode, responseHeaders, body) {
|
|
268
|
+
return { done: false, statusCode, responseHeaders, responseBody: Buffer.from(body), errorMessage: null };
|
|
269
|
+
}
|
|
270
|
+
function writeDispatchFailure(res, result) {
|
|
271
|
+
if (res.headersSent || res.writableEnded)
|
|
272
|
+
return;
|
|
273
|
+
res.writeHead(result.statusCode, result.responseHeaders);
|
|
274
|
+
res.end(result.responseBody);
|
|
275
|
+
}
|
|
276
|
+
function isBuyerFault(result) {
|
|
277
|
+
return result.responseHeaders[ANTSEED_FAULT_ATTRIBUTION_HEADER]?.toLowerCase() === 'buyer';
|
|
278
|
+
}
|
|
242
279
|
function adaptOpenAICompatibleErrorResponse(response, requestProtocol) {
|
|
243
280
|
if (response.statusCode !== 402) {
|
|
244
281
|
return response;
|
|
@@ -708,12 +745,14 @@ export class BuyerProxy {
|
|
|
708
745
|
_configFileWatching = false;
|
|
709
746
|
_pinnedPeer;
|
|
710
747
|
/**
|
|
711
|
-
*
|
|
712
|
-
*
|
|
713
|
-
*
|
|
714
|
-
* selection) and persisted in buyer.state.json like the session peer pin.
|
|
748
|
+
* Default for the `antseed` model alias: an explicit model route (`model`) or an exact
|
|
749
|
+
* routing service (`router`). At most one is set. Set via `POST /_antseed/route` and
|
|
750
|
+
* persisted in buyer.state.json.
|
|
715
751
|
*/
|
|
716
752
|
_defaultRoutedModel = null;
|
|
753
|
+
_defaultRouter = null;
|
|
754
|
+
_routingModels = new RoutingModelsCache();
|
|
755
|
+
_modelRoutingClient;
|
|
717
756
|
_conversations;
|
|
718
757
|
/**
|
|
719
758
|
* Wall-clock of the last model-request activity (dispatch or streamed
|
|
@@ -782,11 +821,13 @@ export class BuyerProxy {
|
|
|
782
821
|
_controlSecretForcedAt = 0;
|
|
783
822
|
_loggedUnauthenticatedPolicy = false;
|
|
784
823
|
/**
|
|
785
|
-
* Per-seller
|
|
786
|
-
* response frame
|
|
787
|
-
*
|
|
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.
|
|
788
828
|
*/
|
|
789
829
|
_peerLatencyMs = new Map();
|
|
830
|
+
_peerTokensPerSecond = new Map();
|
|
790
831
|
_balanceReader;
|
|
791
832
|
_balanceCache = new ChainReadCache();
|
|
792
833
|
_restartHandler;
|
|
@@ -801,6 +842,7 @@ export class BuyerProxy {
|
|
|
801
842
|
this._restartAllowedAt = Date.now() + (config.minRestartIntervalMs ?? MIN_RESTART_INTERVAL_MS);
|
|
802
843
|
this._settingOverrides = [...(config.settingOverrides ?? [])];
|
|
803
844
|
this._node = config.node;
|
|
845
|
+
this._modelRoutingClient = config.modelRoutingClient ?? new ModelRoutingClient();
|
|
804
846
|
this._verifier = config.verifier;
|
|
805
847
|
this._buyerIdentities = config.buyerIdentities ?? new BuyerIdentityLoader(config.node, config.dataDir);
|
|
806
848
|
this._teeVerification = new TeeVerification(config.verifier);
|
|
@@ -871,13 +913,15 @@ export class BuyerProxy {
|
|
|
871
913
|
const entry = this._requestConversations.get(event.requestId);
|
|
872
914
|
if (!entry)
|
|
873
915
|
return;
|
|
916
|
+
const routing = entry.purpose === 'routing';
|
|
874
917
|
this._conversations.addSpend(entry.convId, {
|
|
875
918
|
amountUsdc: event.amountUsdc,
|
|
876
919
|
inputTokens: event.inputTokens,
|
|
877
920
|
cachedInputTokens: event.cachedInputTokens,
|
|
878
921
|
outputTokens: event.outputTokens,
|
|
879
|
-
}, !entry.counted);
|
|
880
|
-
|
|
922
|
+
}, !routing && !entry.counted);
|
|
923
|
+
if (!routing)
|
|
924
|
+
entry.counted = true;
|
|
881
925
|
}
|
|
882
926
|
/**
|
|
883
927
|
* Identity named by a request: null for the default, the name when it is
|
|
@@ -1050,20 +1094,15 @@ export class BuyerProxy {
|
|
|
1050
1094
|
_peerLatency(peerId, serviceId) {
|
|
1051
1095
|
return this._peerLatencyMs.get(this._latencyKey(peerId, serviceId)) ?? null;
|
|
1052
1096
|
}
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1097
|
+
_peerThroughput(peerId, serviceId) {
|
|
1098
|
+
return this._peerTokensPerSecond.get(this._latencyKey(peerId, serviceId)) ?? null;
|
|
1099
|
+
}
|
|
1100
|
+
/** One streamed request: time to its first frame, then output tokens over the rest of the stream. */
|
|
1101
|
+
_recordPeerSpeed(peerId, serviceId, firstFrameMs, streamMs, outputTokens) {
|
|
1056
1102
|
const key = this._latencyKey(peerId, serviceId);
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
this._peerLatencyMs.set(key, previous === undefined
|
|
1061
|
-
? latencyMs
|
|
1062
|
-
: previous + LATENCY_EMA_ALPHA * (latencyMs - previous));
|
|
1063
|
-
if (this._peerLatencyMs.size > MAX_LATENCY_ENTRIES) {
|
|
1064
|
-
const oldest = this._peerLatencyMs.keys().next().value;
|
|
1065
|
-
if (oldest !== undefined)
|
|
1066
|
-
this._peerLatencyMs.delete(oldest);
|
|
1103
|
+
updateEma(this._peerLatencyMs, key, firstFrameMs);
|
|
1104
|
+
if (outputTokens >= MIN_THROUGHPUT_TOKENS && streamMs > 0) {
|
|
1105
|
+
updateEma(this._peerTokensPerSecond, key, outputTokens / (streamMs / 1000));
|
|
1067
1106
|
}
|
|
1068
1107
|
}
|
|
1069
1108
|
/**
|
|
@@ -1214,6 +1253,9 @@ export class BuyerProxy {
|
|
|
1214
1253
|
const latency = this._peerLatency(candidate.peerId, candidate.serviceId);
|
|
1215
1254
|
if (latency !== null)
|
|
1216
1255
|
verdict.reasons.push(`latency ~${Math.round(latency)}ms`);
|
|
1256
|
+
const throughput = this._peerThroughput(candidate.peerId, candidate.serviceId);
|
|
1257
|
+
if (throughput !== null)
|
|
1258
|
+
verdict.reasons.push(`~${Math.round(throughput)} tok/s`);
|
|
1217
1259
|
});
|
|
1218
1260
|
return { candidates, verdicts: [...verdicts.values()], effectivePolicy };
|
|
1219
1261
|
}
|
|
@@ -1424,8 +1466,12 @@ export class BuyerProxy {
|
|
|
1424
1466
|
* a grace window — the seller-initiated auth path can land just after the
|
|
1425
1467
|
* response is returned. Bounded so a leaked id can't grow the map forever.
|
|
1426
1468
|
*/
|
|
1427
|
-
_trackRequestConversation(requestId, convId) {
|
|
1428
|
-
this._requestConversations.
|
|
1469
|
+
_trackRequestConversation(requestId, convId, parentRequestId, purpose) {
|
|
1470
|
+
if (this._requestConversations.has(requestId))
|
|
1471
|
+
throw new Error('Request ID already tracked');
|
|
1472
|
+
// Inference retries share one turn counter; recommendation purchases have their own cost entry.
|
|
1473
|
+
const parent = !purpose && parentRequestId ? this._requestConversations.get(parentRequestId) : undefined;
|
|
1474
|
+
this._requestConversations.set(requestId, parent?.convId === convId ? parent : { convId, counted: false, purpose });
|
|
1429
1475
|
while (this._requestConversations.size > MAX_TRACKED_REQUEST_CONVERSATIONS) {
|
|
1430
1476
|
const oldest = this._requestConversations.keys().next().value;
|
|
1431
1477
|
if (oldest === undefined)
|
|
@@ -1602,6 +1648,27 @@ export class BuyerProxy {
|
|
|
1602
1648
|
// watcher setup failed; non-fatal
|
|
1603
1649
|
}
|
|
1604
1650
|
}
|
|
1651
|
+
_validateRouterSelection(value) {
|
|
1652
|
+
if (!isRoutingSelection(value) || value.kind !== 'router')
|
|
1653
|
+
throw new Error('Invalid router selection');
|
|
1654
|
+
if (!value.service)
|
|
1655
|
+
throw new Error('Select an exact routing-service target');
|
|
1656
|
+
}
|
|
1657
|
+
/** Parses the `router` field of `POST /_antseed/route` (`{ service, costQualityTradeoff?, allowedModels? }`). */
|
|
1658
|
+
_parseDefaultRouter(value) {
|
|
1659
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
1660
|
+
throw new Error('router must be { service, costQualityTradeoff? }');
|
|
1661
|
+
const selection = { ...value, kind: 'router' };
|
|
1662
|
+
this._validateRouterSelection(selection);
|
|
1663
|
+
return structuredClone(selection);
|
|
1664
|
+
}
|
|
1665
|
+
/** The default router in its wire form, without the internal `kind` tag. */
|
|
1666
|
+
get _defaultRouterJson() {
|
|
1667
|
+
if (!this._defaultRouter)
|
|
1668
|
+
return null;
|
|
1669
|
+
const { kind: _kind, ...router } = this._defaultRouter;
|
|
1670
|
+
return router;
|
|
1671
|
+
}
|
|
1605
1672
|
async _reloadSessionOverrides(opts = {}) {
|
|
1606
1673
|
try {
|
|
1607
1674
|
const raw = await readFile(this._stateFile, 'utf-8');
|
|
@@ -1614,7 +1681,16 @@ export class BuyerProxy {
|
|
|
1614
1681
|
}
|
|
1615
1682
|
const routedModel = typeof parsed.defaultRoutedModel === 'string' ? parsed.defaultRoutedModel.trim() : '';
|
|
1616
1683
|
this._defaultRoutedModel = routedModel.length > 0 && isValidRoutedModelTarget(routedModel) ? routedModel : null;
|
|
1617
|
-
|
|
1684
|
+
try {
|
|
1685
|
+
this._defaultRouter = null;
|
|
1686
|
+
if (parsed.defaultRouter != null && this._defaultRoutedModel === null) {
|
|
1687
|
+
this._defaultRouter = this._parseDefaultRouter(parsed.defaultRouter);
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
catch {
|
|
1691
|
+
this._defaultRouter = null;
|
|
1692
|
+
}
|
|
1693
|
+
log(`Session overrides reloaded: peer=${this._pinnedPeer ?? 'none'} route=${this._defaultRoutedModel ?? 'none'} router=${JSON.stringify(this._defaultRouterJson)}`);
|
|
1618
1694
|
}
|
|
1619
1695
|
catch {
|
|
1620
1696
|
// state file unreadable; keep current values
|
|
@@ -1693,7 +1769,7 @@ export class BuyerProxy {
|
|
|
1693
1769
|
// in the file — the debounce may have been cancelled before
|
|
1694
1770
|
// _reloadSessionOverrides could commit the latest CLI-written values.
|
|
1695
1771
|
const sessionOverrides = state === 'connected'
|
|
1696
|
-
? { pinnedPeerId: this._pinnedPeer, defaultRoutedModel: this._defaultRoutedModel }
|
|
1772
|
+
? { pinnedPeerId: this._pinnedPeer, defaultRoutedModel: this._defaultRoutedModel, defaultRouter: this._defaultRouterJson }
|
|
1697
1773
|
: {};
|
|
1698
1774
|
await this._mergeStateFile({
|
|
1699
1775
|
state,
|
|
@@ -2239,9 +2315,14 @@ export class BuyerProxy {
|
|
|
2239
2315
|
res.end(JSON.stringify({ ok: true, peerId: normalized }));
|
|
2240
2316
|
return;
|
|
2241
2317
|
}
|
|
2318
|
+
if (path === '/_antseed/routing-services' && method === 'GET') {
|
|
2319
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
2320
|
+
res.end(JSON.stringify({ ok: true, services: await buildRoutingServices(await this._getPeers(), this._modelRoutingClient, this._node, this._routingModels) }));
|
|
2321
|
+
return;
|
|
2322
|
+
}
|
|
2242
2323
|
if (path === '/_antseed/route' && method === 'GET') {
|
|
2243
2324
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
2244
|
-
res.end(JSON.stringify({ ok: true, model: this._defaultRoutedModel }));
|
|
2325
|
+
res.end(JSON.stringify({ ok: true, model: this._defaultRoutedModel, router: this._defaultRouterJson }));
|
|
2245
2326
|
return;
|
|
2246
2327
|
}
|
|
2247
2328
|
if (path === '/_antseed/route' && method === 'POST') {
|
|
@@ -2256,9 +2337,10 @@ export class BuyerProxy {
|
|
|
2256
2337
|
}
|
|
2257
2338
|
chunks.push(chunk);
|
|
2258
2339
|
}
|
|
2340
|
+
let body;
|
|
2259
2341
|
let model;
|
|
2260
2342
|
try {
|
|
2261
|
-
|
|
2343
|
+
body = JSON.parse(Buffer.concat(chunks).toString());
|
|
2262
2344
|
model = typeof body.model === 'string' ? body.model.trim() : '';
|
|
2263
2345
|
}
|
|
2264
2346
|
catch {
|
|
@@ -2266,16 +2348,28 @@ export class BuyerProxy {
|
|
|
2266
2348
|
res.end(JSON.stringify({ ok: false, error: 'Invalid JSON body' }));
|
|
2267
2349
|
return;
|
|
2268
2350
|
}
|
|
2269
|
-
|
|
2351
|
+
let router = null;
|
|
2352
|
+
try {
|
|
2353
|
+
if (body.router != null) {
|
|
2354
|
+
if (model.length > 0)
|
|
2355
|
+
throw new Error('Set model or router, not both');
|
|
2356
|
+
router = this._parseDefaultRouter(body.router);
|
|
2357
|
+
}
|
|
2358
|
+
if (model.length > 0 && !isValidRoutedModelTarget(model)) {
|
|
2359
|
+
throw new Error('model must be "<service>", "<peerId>@<service>", or empty to clear');
|
|
2360
|
+
}
|
|
2361
|
+
}
|
|
2362
|
+
catch (error) {
|
|
2270
2363
|
res.writeHead(400, { 'content-type': 'application/json' });
|
|
2271
|
-
res.end(JSON.stringify({ ok: false, error:
|
|
2364
|
+
res.end(JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) }));
|
|
2272
2365
|
return;
|
|
2273
2366
|
}
|
|
2274
2367
|
this._defaultRoutedModel = model.length > 0 ? model : null;
|
|
2275
|
-
|
|
2276
|
-
|
|
2368
|
+
this._defaultRouter = router;
|
|
2369
|
+
await this._mergeStateFile({ defaultRoutedModel: this._defaultRoutedModel, defaultRouter: this._defaultRouterJson });
|
|
2370
|
+
log(`Default route set: model=${this._defaultRoutedModel ?? 'none'} router=${JSON.stringify(this._defaultRouterJson)}`);
|
|
2277
2371
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
2278
|
-
res.end(JSON.stringify({ ok: true, model: this._defaultRoutedModel }));
|
|
2372
|
+
res.end(JSON.stringify({ ok: true, model: this._defaultRoutedModel, router: this._defaultRouterJson }));
|
|
2279
2373
|
return;
|
|
2280
2374
|
}
|
|
2281
2375
|
if (path === '/_antseed/conversations' && method === 'GET') {
|
|
@@ -2316,6 +2410,8 @@ export class BuyerProxy {
|
|
|
2316
2410
|
let parsed;
|
|
2317
2411
|
try {
|
|
2318
2412
|
parsed = JSON.parse(Buffer.concat(chunks).toString());
|
|
2413
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
2414
|
+
throw new Error('Invalid conversation update');
|
|
2319
2415
|
}
|
|
2320
2416
|
catch {
|
|
2321
2417
|
res.writeHead(400, { 'content-type': 'application/json' });
|
|
@@ -2340,6 +2436,21 @@ export class BuyerProxy {
|
|
|
2340
2436
|
res.end(JSON.stringify({ ok: false, error: 'Unknown conversation' }));
|
|
2341
2437
|
return;
|
|
2342
2438
|
}
|
|
2439
|
+
if ('routingSelection' in parsed) {
|
|
2440
|
+
try {
|
|
2441
|
+
if ('pinnedModel' in parsed)
|
|
2442
|
+
throw new Error('Update routingSelection or pinnedModel, not both');
|
|
2443
|
+
if (parsed.routingSelection !== null)
|
|
2444
|
+
this._validateRouterSelection(parsed.routingSelection);
|
|
2445
|
+
}
|
|
2446
|
+
catch (error) {
|
|
2447
|
+
res.writeHead(400, { 'content-type': 'application/json' });
|
|
2448
|
+
res.end(JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) }));
|
|
2449
|
+
return;
|
|
2450
|
+
}
|
|
2451
|
+
const selection = parsed.routingSelection;
|
|
2452
|
+
conversation = this._conversations.setRoutingSelection(id, selection);
|
|
2453
|
+
}
|
|
2343
2454
|
if ('pinnedModel' in parsed) {
|
|
2344
2455
|
const pin = typeof parsed.pinnedModel === 'string' ? parsed.pinnedModel.trim() : '';
|
|
2345
2456
|
if (pin.length > 0 && !isValidRoutedModelTarget(pin)) {
|
|
@@ -2715,18 +2826,64 @@ export class BuyerProxy {
|
|
|
2715
2826
|
res.writeHead(400, responseHeaders);
|
|
2716
2827
|
res.end(JSON.stringify({
|
|
2717
2828
|
error: {
|
|
2718
|
-
message: `Unknown model type "${url.searchParams.get('type') ?? ''}" — expected "text", "images", "decisions", or "
|
|
2829
|
+
message: `Unknown model type "${url.searchParams.get('type') ?? ''}" — expected "text", "images", "decisions", "videos", or "routers".`,
|
|
2719
2830
|
type: 'invalid_request_error',
|
|
2720
2831
|
param: 'type',
|
|
2721
2832
|
},
|
|
2722
2833
|
}));
|
|
2723
2834
|
return;
|
|
2724
2835
|
}
|
|
2836
|
+
if (typeFilter === 'router') {
|
|
2837
|
+
const routers = await this._networkRouters(peers, requestPolicy);
|
|
2838
|
+
log(`GET /v1/models?type=router answered locally: ${routers.length} routers`);
|
|
2839
|
+
res.writeHead(200, responseHeaders);
|
|
2840
|
+
res.end(JSON.stringify({ object: 'list', data: routers }));
|
|
2841
|
+
return;
|
|
2842
|
+
}
|
|
2725
2843
|
const data = typeFilter === 'all' ? models : models.filter((entry) => entry.type === typeFilter);
|
|
2726
2844
|
log(`GET /v1/models answered locally: ${data.length} models across ${peers.length} peers${typeFilter ? ` (type=${typeFilter})` : ''}`);
|
|
2727
2845
|
res.writeHead(200, responseHeaders);
|
|
2728
2846
|
res.end(JSON.stringify({ object: 'list', data }));
|
|
2729
2847
|
}
|
|
2848
|
+
/**
|
|
2849
|
+
* Routing services on the network, one entry per service id with each
|
|
2850
|
+
* router peer offering it. A router is listed only when a request could pay
|
|
2851
|
+
* it (see `_routerExclusionReasons`), and under a gateway policy its models
|
|
2852
|
+
* are cut to the ones the policy allows. Model lists are free calls, cached
|
|
2853
|
+
* per router.
|
|
2854
|
+
*/
|
|
2855
|
+
async _networkRouters(peers, requestPolicy) {
|
|
2856
|
+
const policy = this._effectivePolicy(requestPolicy);
|
|
2857
|
+
const services = await buildRoutingServices(peers, this._modelRoutingClient, this._node, this._routingModels);
|
|
2858
|
+
const created = Math.floor(this._now() / 1000);
|
|
2859
|
+
const byId = new Map();
|
|
2860
|
+
for (const service of services) {
|
|
2861
|
+
const peer = peers.find((candidate) => candidate.peerId === service.peerId);
|
|
2862
|
+
if (!peer || this._routerExclusionReasons(peer, service.serviceId, requestPolicy).length > 0)
|
|
2863
|
+
continue;
|
|
2864
|
+
const models = service.catalog
|
|
2865
|
+
? [...new Set(service.catalog.models.map((model) => model.serviceId))]
|
|
2866
|
+
.filter((model) => !policy || policyAllowsModel(policy, model))
|
|
2867
|
+
.sort((a, b) => a.localeCompare(b))
|
|
2868
|
+
: null;
|
|
2869
|
+
const price = Number(service.priceMicroUsdc);
|
|
2870
|
+
let entry = byId.get(service.serviceId);
|
|
2871
|
+
if (!entry) {
|
|
2872
|
+
entry = { id: service.serviceId, name: service.label, object: 'model', created, owned_by: 'antseed', type: 'router', peers: [] };
|
|
2873
|
+
byId.set(service.serviceId, entry);
|
|
2874
|
+
}
|
|
2875
|
+
entry.peers.push({
|
|
2876
|
+
peerId: service.peerId,
|
|
2877
|
+
...(service.sellerName ? { displayName: service.sellerName } : {}),
|
|
2878
|
+
provider: service.provider,
|
|
2879
|
+
serviceId: service.serviceId,
|
|
2880
|
+
model: `${service.peerId}@${service.serviceId}`,
|
|
2881
|
+
priceUsdPerRequest: Number.isFinite(price) ? price / 1_000_000 : null,
|
|
2882
|
+
models,
|
|
2883
|
+
});
|
|
2884
|
+
}
|
|
2885
|
+
return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id));
|
|
2886
|
+
}
|
|
2730
2887
|
/**
|
|
2731
2888
|
* Whether a request under the gateway's policy could use this offer, by
|
|
2732
2889
|
* the checks that judge a pinned `peer@model`: the model allow list, the
|
|
@@ -2896,6 +3053,19 @@ export class BuyerProxy {
|
|
|
2896
3053
|
}));
|
|
2897
3054
|
return;
|
|
2898
3055
|
}
|
|
3056
|
+
// Per-request routing options (`routing` in the body) are read here and
|
|
3057
|
+
// stripped, so they never reach a seller, whatever the model. A malformed
|
|
3058
|
+
// `routing` is reported only when a router serves the request.
|
|
3059
|
+
let routingOptions = {};
|
|
3060
|
+
let invalidRouting = null;
|
|
3061
|
+
if (method === 'POST' && isCompletionRequestPath(path)) {
|
|
3062
|
+
const taken = takeRoutingOptions(serializedReq);
|
|
3063
|
+
if (taken.kind === 'options') {
|
|
3064
|
+
serializedReq = taken.request;
|
|
3065
|
+
routingOptions = taken.options;
|
|
3066
|
+
invalidRouting = taken.invalid ?? null;
|
|
3067
|
+
}
|
|
3068
|
+
}
|
|
2899
3069
|
if (nativeVideo) {
|
|
2900
3070
|
// Re-classify with the body: some APIs (Venice) carry the job ID in it.
|
|
2901
3071
|
const prepared = prepareVideoRequest(nativeVideoRoute(serializedReq) ?? nativeVideo, serializedReq.headers, this._resourceRoutes);
|
|
@@ -2921,6 +3091,7 @@ export class BuyerProxy {
|
|
|
2921
3091
|
const conversationIdentity = isConversationRequest
|
|
2922
3092
|
? extractConversationIdentity(serializedReq.headers, conversationBody)
|
|
2923
3093
|
: null;
|
|
3094
|
+
const routingConversationKey = conversationIdentity ? `${conversationIdentity.tool}:${conversationIdentity.sessionKey}` : null;
|
|
2924
3095
|
// Internal marker from the system proxy: source profile used only for
|
|
2925
3096
|
// local conversation attribution. Stripped here so it never reaches a seller.
|
|
2926
3097
|
delete serializedReq.headers[SYSTEM_PROXY_SOURCE_HEADER];
|
|
@@ -2930,6 +3101,10 @@ export class BuyerProxy {
|
|
|
2930
3101
|
const storedConversation = conversationIdentity && trackedConversationKey
|
|
2931
3102
|
? this._conversations.get(`${conversationIdentity.tool}:${trackedConversationKey}`)
|
|
2932
3103
|
: null;
|
|
3104
|
+
// Use this chat's router override when present; otherwise follow the buyer's default choice.
|
|
3105
|
+
const conversationSelection = storedConversation?.routingSelection ?? null;
|
|
3106
|
+
// Keep this request's choice stable; later selection changes affect subsequent requests.
|
|
3107
|
+
const routerSelection = structuredClone(conversationSelection ?? this._defaultRouter);
|
|
2933
3108
|
const storedAutoRoute = storedConversation?.peerSource === 'auto' && storedConversation.pinnedModel
|
|
2934
3109
|
? parsePeerPinnedService(storedConversation.pinnedModel)
|
|
2935
3110
|
: null;
|
|
@@ -2939,6 +3114,242 @@ export class BuyerProxy {
|
|
|
2939
3114
|
const preferredPeerHeader = normalizePeerId(serializedReq.headers['x-antseed-prefer-peer'] ?? '');
|
|
2940
3115
|
const preferredConversationPeerId = preferredPeerHeader ?? storedAutoRoute?.peerId ?? null;
|
|
2941
3116
|
const effectiveRoutedModel = chatPinnedModel ?? this._defaultRoutedModel;
|
|
3117
|
+
// Where a router pin can come from besides the request's own model: the
|
|
3118
|
+
// `antseed` alias resolves to the chat's explicit pin or, when the chat
|
|
3119
|
+
// chose no route of its own (neither a pin nor a router), the default
|
|
3120
|
+
// route; a system-proxied request follows the chat's explicit pin. An
|
|
3121
|
+
// automatic chat route (the seller a router chose last turn) never
|
|
3122
|
+
// counts, so every turn of a router-pinned chat is ranked again.
|
|
3123
|
+
const userChatPin = storedConversation?.peerSource === 'user' ? storedConversation.pinnedModel : null;
|
|
3124
|
+
const aliasTarget = userChatPin ?? (conversationSelection ? null : this._defaultRoutedModel);
|
|
3125
|
+
// Reuse disconnect cancellation for both the recommendation and the eventual inference call.
|
|
3126
|
+
const clientAbortController = new AbortController();
|
|
3127
|
+
const onClientAbort = () => {
|
|
3128
|
+
if (clientAbortController.signal.aborted)
|
|
3129
|
+
return;
|
|
3130
|
+
clientAbortController.abort();
|
|
3131
|
+
log(`Client disconnected; aborting upstream request reqId=${serializedReq.requestId.slice(0, 8)}`);
|
|
3132
|
+
};
|
|
3133
|
+
req.once('close', () => {
|
|
3134
|
+
if (!req.complete && !res.writableEnded)
|
|
3135
|
+
onClientAbort();
|
|
3136
|
+
});
|
|
3137
|
+
res.once('close', () => {
|
|
3138
|
+
if (!res.writableEnded)
|
|
3139
|
+
onClientAbort();
|
|
3140
|
+
});
|
|
3141
|
+
// Explicit model/seller choices bypass recommendations, even when the default is a router.
|
|
3142
|
+
// A system-proxy connection's model was assigned at connect time, not chosen by the tool:
|
|
3143
|
+
// under a router it stands for the automatic alias, unless this chat is pinned. The request
|
|
3144
|
+
// is only rewritten once the router path is certain (below), so a request that goes direct
|
|
3145
|
+
// keeps its connect-time model.
|
|
3146
|
+
const systemRoutedAsAlias = routerSelection !== null && systemRoutedModel && storedConversation?.peerSource !== 'user';
|
|
3147
|
+
const rawService = systemRoutedAsAlias ? ROUTED_MODEL_ALIAS : extractRequestedService(serializedReq);
|
|
3148
|
+
// `<routerPeer>@<routingServiceId>`: the request names a routing service as
|
|
3149
|
+
// its model, so that router picks the model and seller for this request.
|
|
3150
|
+
const routerPinSource = rawService === ROUTED_MODEL_ALIAS
|
|
3151
|
+
? aliasTarget
|
|
3152
|
+
: systemRoutedModel && userChatPin ? userChatPin : rawService;
|
|
3153
|
+
const pinnedRouter = await this._resolvePinnedRouter(routerPinSource);
|
|
3154
|
+
const autoRequested = routerSelection !== null
|
|
3155
|
+
&& rawService === ROUTED_MODEL_ALIAS;
|
|
3156
|
+
const chatModelPin = storedConversation?.peerSource === 'user' ? chatPinnedModel : null;
|
|
3157
|
+
if (autoRequested && chatModelPin) {
|
|
3158
|
+
serializedReq = withRoutedModel(serializedReq, chatModelPin);
|
|
3159
|
+
}
|
|
3160
|
+
const useModelRouter = pinnedRouter !== null || (autoRequested && !chatModelPin
|
|
3161
|
+
&& !getExplicitPeerIdOverride(serializedReq, effectivePinnedPeer ?? undefined));
|
|
3162
|
+
if (useModelRouter && systemRoutedAsAlias) {
|
|
3163
|
+
serializedReq = withRoutedModel(serializedReq, ROUTED_MODEL_ALIAS);
|
|
3164
|
+
}
|
|
3165
|
+
// A request without `routing` uses the saved router settings (tradeoff,
|
|
3166
|
+
// model allow list) when it pins the router those settings belong to.
|
|
3167
|
+
const savedRouter = this._defaultRouter;
|
|
3168
|
+
const savedForPinned = pinnedRouter && savedRouter && sameRoutingServiceTarget(savedRouter.service, pinnedRouter.service)
|
|
3169
|
+
? structuredClone(savedRouter)
|
|
3170
|
+
: null;
|
|
3171
|
+
const baseSelection = pinnedRouter ? savedForPinned ?? pinnedRouter : routerSelection;
|
|
3172
|
+
const activeSelection = baseSelection && routingOptions.costQualityTradeoff !== undefined
|
|
3173
|
+
? { ...baseSelection, costQualityTradeoff: routingOptions.costQualityTradeoff }
|
|
3174
|
+
: baseSelection;
|
|
3175
|
+
const context = {
|
|
3176
|
+
request: serializedReq, requiredParameters, requestPolicy, signal: clientAbortController.signal,
|
|
3177
|
+
systemRoutedModel, conversationIdentity, conversationBody, storedConversation,
|
|
3178
|
+
effectivePinnedPeer, effectiveRoutedModel, chatPinnedModel,
|
|
3179
|
+
preferredConversationPeerId,
|
|
3180
|
+
// Cache-reuse observations only feed a router, so skip them when none is configured.
|
|
3181
|
+
routingConversationKey: activeSelection ? routingConversationKey : null,
|
|
3182
|
+
spendAttributionTag,
|
|
3183
|
+
};
|
|
3184
|
+
// Both paths return an inference answer; only the router path buys a recommendation first.
|
|
3185
|
+
if (activeSelection && useModelRouter) {
|
|
3186
|
+
if (invalidRouting) {
|
|
3187
|
+
sendJson(res, 400, { error: { type: 'invalid_request_error', code: 'invalid_routing', message: invalidRouting, param: 'routing' } });
|
|
3188
|
+
return;
|
|
3189
|
+
}
|
|
3190
|
+
const refusal = await this._routerRefusal(activeSelection, requestPolicy);
|
|
3191
|
+
if (refusal) {
|
|
3192
|
+
log(`Routing service refused before ranking: ${refusal.error.message}`);
|
|
3193
|
+
sendJson(res, 403, refusal);
|
|
3194
|
+
return;
|
|
3195
|
+
}
|
|
3196
|
+
await this._dispatchViaModelRouter(res, context, activeSelection);
|
|
3197
|
+
}
|
|
3198
|
+
else {
|
|
3199
|
+
await this._dispatchDirectModelRequest(res, context);
|
|
3200
|
+
}
|
|
3201
|
+
}
|
|
3202
|
+
/**
|
|
3203
|
+
* Buy ranked recommendations, then send each one through the normal inference path
|
|
3204
|
+
* until one succeeds. A failed exact peer falls back to the next recommendation.
|
|
3205
|
+
*/
|
|
3206
|
+
async _dispatchViaModelRouter(res, context, selection) {
|
|
3207
|
+
const { request, requiredParameters, requestPolicy, signal, routingConversationKey, spendAttributionTag } = context;
|
|
3208
|
+
const conversationId = this._trackRouterConversation(context);
|
|
3209
|
+
let recommendations;
|
|
3210
|
+
try {
|
|
3211
|
+
const client = this._modelRoutingClient;
|
|
3212
|
+
if (!selection.service)
|
|
3213
|
+
throw new Error('Select an exact routing-service target');
|
|
3214
|
+
const peers = await this._getPeers();
|
|
3215
|
+
// The router may only recommend destinations the buyer's own policies allow.
|
|
3216
|
+
const candidates = eligibleRouterCandidates(request, peers, requiredParameters, this._routingPreferences, (policyRequest, peer) => peerAllowedByPolicy(this._node.router, policyRequest, peer)
|
|
3217
|
+
&& this._routerCandidateAllowedByRequestPolicy(requestPolicy, policyRequest, peer)
|
|
3218
|
+
&& !isCoolingDown(this._peerHealth.get(peer.peerId), this._now()));
|
|
3219
|
+
recommendations = await executeRouterSelection({
|
|
3220
|
+
node: this._node, client, request, peers, candidates,
|
|
3221
|
+
conversationKey: routingConversationKey, selection, modelsCache: this._routingModels,
|
|
3222
|
+
...(request.headers[BUYER_IDENTITY_HEADER] ? { buyerIdentity: request.headers[BUYER_IDENTITY_HEADER] } : {}),
|
|
3223
|
+
signal: AbortSignal.any([signal, AbortSignal.timeout(120_000)]),
|
|
3224
|
+
onRoutingRequest: requestId => {
|
|
3225
|
+
if (spendAttributionTag)
|
|
3226
|
+
this._spendAttribution.track(requestId, spendAttributionTag);
|
|
3227
|
+
if (conversationId)
|
|
3228
|
+
this._trackRequestConversation(requestId, conversationId, undefined, 'routing');
|
|
3229
|
+
},
|
|
3230
|
+
});
|
|
3231
|
+
}
|
|
3232
|
+
catch (error) {
|
|
3233
|
+
// Report routing failures rather than silently choosing a model for the user.
|
|
3234
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3235
|
+
writeDispatchFailure(res, error instanceof NoEligibleCandidatesError
|
|
3236
|
+
? dispatchFailure(422, { 'content-type': 'application/json' }, JSON.stringify({
|
|
3237
|
+
error: { type: 'invalid_request_error', code: 'no_eligible_candidates', message },
|
|
3238
|
+
}))
|
|
3239
|
+
: dispatchFailure(502, { 'content-type': 'application/json' }, JSON.stringify({
|
|
3240
|
+
error: { type: 'routing_error', message },
|
|
3241
|
+
})));
|
|
3242
|
+
return;
|
|
3243
|
+
}
|
|
3244
|
+
let lastFailure = null;
|
|
3245
|
+
for (const recommendation of recommendations) {
|
|
3246
|
+
// Never switch sellers once the user has disconnected or a response has started.
|
|
3247
|
+
if (signal.aborted || res.headersSent || res.writableEnded)
|
|
3248
|
+
return;
|
|
3249
|
+
// Each attempt has its own billing ID, but all attempts count as one chat turn.
|
|
3250
|
+
const attempt = requestForRecommendation(request, recommendation, randomUUID());
|
|
3251
|
+
if (spendAttributionTag)
|
|
3252
|
+
this._spendAttribution.track(attempt.requestId, spendAttributionTag);
|
|
3253
|
+
if (conversationId)
|
|
3254
|
+
this._trackRequestConversation(attempt.requestId, conversationId, request.requestId);
|
|
3255
|
+
const result = await this._dispatchInference(res, context, attempt, conversationId);
|
|
3256
|
+
if (result.done)
|
|
3257
|
+
return;
|
|
3258
|
+
lastFailure = result;
|
|
3259
|
+
// Only try the next recommendation after a clear seller refusal, never after an ambiguous
|
|
3260
|
+
// transport failure or timeout (the seller may have done paid work) or a buyer-side fault.
|
|
3261
|
+
if (result.errorMessage !== null || isBuyerFault(result) || !RECOMMENDATION_FALLBACK_STATUS_CODES.has(result.statusCode))
|
|
3262
|
+
break;
|
|
3263
|
+
log(`Recommendation ${recommendation.peerId ? `${recommendation.peerId.slice(0, 12)}...@` : ''}${recommendation.serviceId} failed (${result.statusCode}); trying the next recommendation.`);
|
|
3264
|
+
}
|
|
3265
|
+
if (signal.aborted)
|
|
3266
|
+
return;
|
|
3267
|
+
writeDispatchFailure(res, lastFailure ?? dispatchFailure(502, { 'content-type': 'application/json' }, JSON.stringify({
|
|
3268
|
+
error: { type: 'routing_error', message: 'No recommended destination could serve this request' },
|
|
3269
|
+
})));
|
|
3270
|
+
}
|
|
3271
|
+
/**
|
|
3272
|
+
* The router selection a `<peerId>@<serviceId>` model names, when that
|
|
3273
|
+
* peer's routing service is `<serviceId>`. A peer missing from the cache
|
|
3274
|
+
* gets one refresh first, so a router that just came online is not taken
|
|
3275
|
+
* for a seller. Still missing, the pin goes down the seller-pin path, which
|
|
3276
|
+
* reports the peer as unreachable.
|
|
3277
|
+
*/
|
|
3278
|
+
async _resolvePinnedRouter(model) {
|
|
3279
|
+
const pin = model ? parsePeerPinnedService(model) : null;
|
|
3280
|
+
if (!pin)
|
|
3281
|
+
return null;
|
|
3282
|
+
let peers = await this._getPeers();
|
|
3283
|
+
if (!peers.some((peer) => peer.peerId.toLowerCase() === pin.peerId)) {
|
|
3284
|
+
peers = await this._getPeers({ forceRefresh: true });
|
|
3285
|
+
}
|
|
3286
|
+
const service = findPinnedRoutingService(peers, pin.peerId, pin.service);
|
|
3287
|
+
return service ? { kind: 'router', service } : null;
|
|
3288
|
+
}
|
|
3289
|
+
/**
|
|
3290
|
+
* Why a routing service may not be paid, the way a pinned seller is judged:
|
|
3291
|
+
* the gateway policy's model allow list (the router's service id counts as
|
|
3292
|
+
* its model), its seller rules and strict model routes, and the buyer's
|
|
3293
|
+
* hard limits, all through `routingServicePolicy`. Empty when allowed.
|
|
3294
|
+
*/
|
|
3295
|
+
_routerExclusionReasons(peer, serviceId, requestPolicy) {
|
|
3296
|
+
const facts = this._policyFacts(peer, null);
|
|
3297
|
+
const reasons = requestPolicy ? pinnedPeerExclusionReasons(routingServicePolicy(requestPolicy), facts, serviceId) : [];
|
|
3298
|
+
if (requestPolicy && !policyAllowsModel(this._effectivePolicy(requestPolicy), serviceId))
|
|
3299
|
+
reasons.unshift('model not allowed');
|
|
3300
|
+
addBuyerConfigReasons(reasons, policyExclusionReasons(routingServicePolicy(this._buyerHardPolicy()), facts));
|
|
3301
|
+
return reasons;
|
|
3302
|
+
}
|
|
3303
|
+
/** A 403 body when the policy forbids paying this router for a ranking; null when allowed or its peer is unknown. */
|
|
3304
|
+
async _routerRefusal(selection, requestPolicy) {
|
|
3305
|
+
const service = selection.service;
|
|
3306
|
+
if (!service)
|
|
3307
|
+
return null;
|
|
3308
|
+
const peer = (await this._getPeers()).find((candidate) => candidate.peerId.toLowerCase() === service.peerId.toLowerCase());
|
|
3309
|
+
if (!peer)
|
|
3310
|
+
return null;
|
|
3311
|
+
const reasons = this._routerExclusionReasons(peer, service.serviceId, requestPolicy);
|
|
3312
|
+
if (reasons.length === 0)
|
|
3313
|
+
return null;
|
|
3314
|
+
const label = `${service.peerId.slice(0, 12)}...@${service.serviceId}`;
|
|
3315
|
+
if (reasons[0] === 'model not allowed') {
|
|
3316
|
+
return { error: { type: 'permission_error', code: 'model_not_allowed', param: 'model',
|
|
3317
|
+
message: `Routing service "${service.serviceId}" is not allowed by the routing policy.` } };
|
|
3318
|
+
}
|
|
3319
|
+
return { error: { type: 'permission_error', code: 'peer_not_allowed',
|
|
3320
|
+
message: `Routing service ${label} is not allowed by the routing policy (${reasons.join('; ')}).` } };
|
|
3321
|
+
}
|
|
3322
|
+
_routerCandidateAllowedByRequestPolicy(requestPolicy, request, peer) {
|
|
3323
|
+
if (!requestPolicy)
|
|
3324
|
+
return true;
|
|
3325
|
+
const provider = getExplicitProviderOverride(request);
|
|
3326
|
+
const serviceId = extractRequestedService(request);
|
|
3327
|
+
const offer = provider && serviceId ? findAdvertisedServiceOffer(peer, provider, serviceId) : null;
|
|
3328
|
+
return offer !== null
|
|
3329
|
+
&& this._policyAllowsOffer(requestPolicy, peer, offer)
|
|
3330
|
+
&& policyExclusionReasons(this._buyerHardPolicy(), this._policyFacts(peer, offer)).length === 0;
|
|
3331
|
+
}
|
|
3332
|
+
/** Track a router-served chat before buying its recommendation, so routing costs are attributed. */
|
|
3333
|
+
_trackRouterConversation(context) {
|
|
3334
|
+
const { request, conversationIdentity, conversationBody, storedConversation } = context;
|
|
3335
|
+
if (!conversationIdentity?.isUserThread)
|
|
3336
|
+
return storedConversation?.id ?? null;
|
|
3337
|
+
if (!storedConversation && isTitleGenerationRequest(conversationBody))
|
|
3338
|
+
return null;
|
|
3339
|
+
const { id } = this._conversations.touch({
|
|
3340
|
+
tool: conversationIdentity.tool,
|
|
3341
|
+
sessionKey: conversationIdentity.parentSessionKey ?? conversationIdentity.sessionKey,
|
|
3342
|
+
snippet: storedConversation?.snippet ? null : extractFirstUserSnippet(conversationBody),
|
|
3343
|
+
lastModel: null,
|
|
3344
|
+
});
|
|
3345
|
+
// Inference attempts link to this ID so fallbacks count as one chat turn.
|
|
3346
|
+
this._trackRequestConversation(request.requestId, id);
|
|
3347
|
+
return id;
|
|
3348
|
+
}
|
|
3349
|
+
/** Apply model aliases and chat pins, then attach inference to its conversation for accounting. */
|
|
3350
|
+
_prepareInferenceRequest(res, context) {
|
|
3351
|
+
const { effectiveRoutedModel, chatPinnedModel, systemRoutedModel, storedConversation, conversationIdentity, conversationBody } = context;
|
|
3352
|
+
let serializedReq = context.request;
|
|
2942
3353
|
let trackedConversationId = storedConversation?.id ?? null;
|
|
2943
3354
|
// Resolve the `antseed` model alias to the session's default route first,
|
|
2944
3355
|
// so the regular `<peerId>@<service>` pin rewrite below picks up the
|
|
@@ -2957,7 +3368,7 @@ export class BuyerProxy {
|
|
|
2957
3368
|
param: 'model',
|
|
2958
3369
|
},
|
|
2959
3370
|
}));
|
|
2960
|
-
return;
|
|
3371
|
+
return null;
|
|
2961
3372
|
}
|
|
2962
3373
|
if (aliasResult.substituted) {
|
|
2963
3374
|
serializedReq = { ...serializedReq, body: aliasResult.body, headers: aliasResult.headers };
|
|
@@ -3020,10 +3431,29 @@ export class BuyerProxy {
|
|
|
3020
3431
|
this._trackRequestConversation(serializedReq.requestId, tracked.id);
|
|
3021
3432
|
}
|
|
3022
3433
|
}
|
|
3434
|
+
return { request: serializedReq, conversationId: trackedConversationId };
|
|
3435
|
+
}
|
|
3436
|
+
/** Send a fixed-model or pinned-seller request without buying a model recommendation. */
|
|
3437
|
+
async _dispatchDirectModelRequest(res, context) {
|
|
3438
|
+
const prepared = this._prepareInferenceRequest(res, context);
|
|
3439
|
+
if (!prepared)
|
|
3440
|
+
return;
|
|
3441
|
+
const result = await this._dispatchInference(res, context, prepared.request, prepared.conversationId);
|
|
3442
|
+
if (!result.done)
|
|
3443
|
+
writeDispatchFailure(res, result);
|
|
3444
|
+
}
|
|
3445
|
+
/**
|
|
3446
|
+
* Select a seller for the requested model (or the pinned seller) and send the request.
|
|
3447
|
+
* Success is written to `res`; failures are returned so callers can fall back or report them.
|
|
3448
|
+
*/
|
|
3449
|
+
async _dispatchInference(res, context, request, trackedConversationId) {
|
|
3023
3450
|
// A chat switched while this request is in flight must keep the new route.
|
|
3024
3451
|
const conversationPinAtRouting = trackedConversationId
|
|
3025
3452
|
? this._conversations.get(trackedConversationId)?.pinnedModel ?? null
|
|
3026
3453
|
: null;
|
|
3454
|
+
const nativeVideo = nativeVideoRoute(request);
|
|
3455
|
+
let serializedReq = request;
|
|
3456
|
+
const { requiredParameters, requestPolicy, signal, effectivePinnedPeer, preferredConversationPeerId, routingConversationKey } = context;
|
|
3027
3457
|
const { body: servicePinBody, headers: servicePinHeaders, pinnedPeerId: bodyPinnedPeer, } = rewritePeerPinnedServiceInBody(serializedReq.body, serializedReq.headers);
|
|
3028
3458
|
if (servicePinBody !== serializedReq.body) {
|
|
3029
3459
|
serializedReq = { ...serializedReq, body: servicePinBody, headers: servicePinHeaders };
|
|
@@ -3031,24 +3461,6 @@ export class BuyerProxy {
|
|
|
3031
3461
|
log(`Model peer pin applied: peer=${bodyPinnedPeer.slice(0, 12)}...`);
|
|
3032
3462
|
}
|
|
3033
3463
|
}
|
|
3034
|
-
const clientAbortController = new AbortController();
|
|
3035
|
-
const onClientAbort = () => {
|
|
3036
|
-
if (clientAbortController.signal.aborted) {
|
|
3037
|
-
return;
|
|
3038
|
-
}
|
|
3039
|
-
clientAbortController.abort();
|
|
3040
|
-
log(`Client disconnected; aborting upstream request reqId=${serializedReq.requestId.slice(0, 8)}`);
|
|
3041
|
-
};
|
|
3042
|
-
req.once('close', () => {
|
|
3043
|
-
if (!req.complete && !res.writableEnded) {
|
|
3044
|
-
onClientAbort();
|
|
3045
|
-
}
|
|
3046
|
-
});
|
|
3047
|
-
res.once('close', () => {
|
|
3048
|
-
if (!res.writableEnded) {
|
|
3049
|
-
onClientAbort();
|
|
3050
|
-
}
|
|
3051
|
-
});
|
|
3052
3464
|
const requestProtocol = detectRequestServiceApiProtocol(serializedReq);
|
|
3053
3465
|
const requestedService = extractRequestedService(serializedReq);
|
|
3054
3466
|
log(`Routing: protocol=${requestProtocol ?? 'null'} service=${requestedService ?? 'null'}`);
|
|
@@ -3063,8 +3475,7 @@ export class BuyerProxy {
|
|
|
3063
3475
|
+ ' • Model name prefix: <peerId>@<model>\n'
|
|
3064
3476
|
+ ' • Session pin: antseed buyer connection set --peer <peerId>\n'
|
|
3065
3477
|
+ 'Discover peers with: antseed network browse';
|
|
3066
|
-
|
|
3067
|
-
res.end(JSON.stringify({
|
|
3478
|
+
return dispatchFailure(400, { 'content-type': 'application/json' }, JSON.stringify({
|
|
3068
3479
|
error: {
|
|
3069
3480
|
type: 'missing_routing_target',
|
|
3070
3481
|
code: 'missing_routing_target',
|
|
@@ -3078,26 +3489,22 @@ export class BuyerProxy {
|
|
|
3078
3489
|
},
|
|
3079
3490
|
},
|
|
3080
3491
|
}));
|
|
3081
|
-
return;
|
|
3082
3492
|
}
|
|
3083
3493
|
if (requestPolicy && !policyAllowsModel(this._effectivePolicy(requestPolicy), requestedService)) {
|
|
3084
|
-
|
|
3494
|
+
return dispatchFailure(403, { 'content-type': 'application/json' }, JSON.stringify({
|
|
3085
3495
|
error: {
|
|
3086
3496
|
type: 'permission_error',
|
|
3087
3497
|
code: 'model_not_allowed',
|
|
3088
3498
|
message: `Model "${requestedService ?? 'unknown'}" is not allowed by the routing policy.`,
|
|
3089
3499
|
param: 'model',
|
|
3090
3500
|
},
|
|
3091
|
-
});
|
|
3092
|
-
return;
|
|
3501
|
+
}));
|
|
3093
3502
|
}
|
|
3094
3503
|
// Discover peers
|
|
3095
3504
|
const peers = await this._getPeers();
|
|
3096
3505
|
if (peers.length === 0) {
|
|
3097
3506
|
log('No sellers available');
|
|
3098
|
-
|
|
3099
|
-
res.end('No sellers available on the network. Is a seeder running?');
|
|
3100
|
-
return;
|
|
3507
|
+
return dispatchFailure(502, { 'content-type': 'text/plain' }, 'No sellers available on the network. Is a seeder running?');
|
|
3101
3508
|
}
|
|
3102
3509
|
if (!explicitPeerId && requestedService) {
|
|
3103
3510
|
const selectModelPeers = (candidateSources) => selectCandidatePeersForRouting(candidateSources, requestProtocol, requestedService, explicitProvider, 'strict');
|
|
@@ -3132,8 +3539,7 @@ export class BuyerProxy {
|
|
|
3132
3539
|
? ` "${requestedService}" is a decision model; call POST /v1/systemone.`
|
|
3133
3540
|
: '';
|
|
3134
3541
|
log(`Request rejected: model ${requestedService} is served only via ${advertisedProtocols.join(', ')}`);
|
|
3135
|
-
|
|
3136
|
-
res.end(JSON.stringify({
|
|
3542
|
+
return dispatchFailure(400, { 'content-type': 'application/json' }, JSON.stringify({
|
|
3137
3543
|
error: {
|
|
3138
3544
|
type: 'unsupported_protocol',
|
|
3139
3545
|
code: 'unsupported_protocol',
|
|
@@ -3142,10 +3548,8 @@ export class BuyerProxy {
|
|
|
3142
3548
|
supported_protocols: advertisedProtocols,
|
|
3143
3549
|
},
|
|
3144
3550
|
}));
|
|
3145
|
-
return;
|
|
3146
3551
|
}
|
|
3147
|
-
|
|
3148
|
-
res.end(JSON.stringify({
|
|
3552
|
+
return dispatchFailure(capabilityRequired ? 422 : 502, { 'content-type': 'application/json' }, JSON.stringify({
|
|
3149
3553
|
error: capabilityRequired
|
|
3150
3554
|
? {
|
|
3151
3555
|
type: 'required_capability_unavailable',
|
|
@@ -3160,7 +3564,6 @@ export class BuyerProxy {
|
|
|
3160
3564
|
param: 'model',
|
|
3161
3565
|
},
|
|
3162
3566
|
}));
|
|
3163
|
-
return;
|
|
3164
3567
|
}
|
|
3165
3568
|
let lastRetry = null;
|
|
3166
3569
|
let lastVerificationError = null;
|
|
@@ -3172,8 +3575,8 @@ export class BuyerProxy {
|
|
|
3172
3575
|
if (unfunded && !isFree(selected))
|
|
3173
3576
|
continue;
|
|
3174
3577
|
if (this._verifier) {
|
|
3175
|
-
const makeReach = (chosenId) => makeVerifierReach(this._node, selected.peer, chosenId,
|
|
3176
|
-
const outcome = await this._verifyPeer(selected.peer, makeReach,
|
|
3578
|
+
const makeReach = (chosenId) => makeVerifierReach(this._node, selected.peer, chosenId, signal);
|
|
3579
|
+
const outcome = await this._verifyPeer(selected.peer, makeReach, signal, requireVerified);
|
|
3177
3580
|
if (!outcome.ok) {
|
|
3178
3581
|
lastVerificationError = `Peer ${selected.peer.peerId.slice(0, 12)}... failed required verification (${outcome.reason ?? 'failed'}).`;
|
|
3179
3582
|
log(`${lastVerificationError} Trying the next model peer.`);
|
|
@@ -3185,33 +3588,28 @@ export class BuyerProxy {
|
|
|
3185
3588
|
+ `service="${selected.serviceId}" reputation=${selected.reputation} `
|
|
3186
3589
|
+ `effective=${selected.effectiveReputationScore ?? 'unknown'} peer=${index + 1}/${candidates.length} `
|
|
3187
3590
|
+ `attempt=${peerAttempt + 1}/${MODEL_RATE_LIMIT_MAX_ATTEMPTS_PER_PEER}`);
|
|
3188
|
-
const result = await this._dispatchToPeer(res, selected.request, selected.peer, modelPlans, requestProtocol, selected.serviceId, explicitProvider, router, false,
|
|
3591
|
+
const result = await this._dispatchToPeer(res, selected.request, selected.peer, modelPlans, requestProtocol, selected.serviceId, explicitProvider, router, false, signal, requestPolicy !== null, routingConversationKey);
|
|
3189
3592
|
if (result.done) {
|
|
3190
3593
|
// Only a seller that answered becomes the chat's route: an error
|
|
3191
3594
|
// must not pin later turns to it.
|
|
3192
3595
|
if (trackedConversationId && res.statusCode < 400) {
|
|
3193
3596
|
this._conversations.recordRoutedModel(trackedConversationId, `${selected.peer.peerId}@${selected.serviceId}`, conversationPinAtRouting);
|
|
3194
3597
|
}
|
|
3195
|
-
return;
|
|
3598
|
+
return result;
|
|
3196
3599
|
}
|
|
3197
3600
|
// Never retry a video on another seller: a create may already have
|
|
3198
3601
|
// started (and been charged), and follow-ups only exist on one seller.
|
|
3199
|
-
if (nativeVideo)
|
|
3200
|
-
|
|
3201
|
-
res.end(result.responseBody);
|
|
3202
|
-
return;
|
|
3203
|
-
}
|
|
3602
|
+
if (nativeVideo)
|
|
3603
|
+
return result;
|
|
3204
3604
|
lastRetry = result;
|
|
3205
|
-
if (result
|
|
3605
|
+
if (isBuyerFault(result)) {
|
|
3206
3606
|
const freeLeft = candidates.slice(index + 1).some(isFree);
|
|
3207
3607
|
if (result.statusCode === 402 && freeLeft) {
|
|
3208
3608
|
unfunded = true;
|
|
3209
3609
|
log(`No deposits to pay peer ${selected.peer.peerId.slice(0, 12)}...; trying free sellers only.`);
|
|
3210
3610
|
break;
|
|
3211
3611
|
}
|
|
3212
|
-
|
|
3213
|
-
res.end(result.responseBody);
|
|
3214
|
-
return;
|
|
3612
|
+
return result;
|
|
3215
3613
|
}
|
|
3216
3614
|
const retrySamePeer = result.statusCode === 429
|
|
3217
3615
|
&& peerAttempt + 1 < MODEL_RATE_LIMIT_MAX_ATTEMPTS_PER_PEER;
|
|
@@ -3219,28 +3617,20 @@ export class BuyerProxy {
|
|
|
3219
3617
|
break;
|
|
3220
3618
|
const delayMs = rateLimitRetryDelayMs(result.responseHeaders, peerAttempt);
|
|
3221
3619
|
log(`Peer ${selected.peer.peerId.slice(0, 12)}... is rate-limited; retrying in ${delayMs}ms.`);
|
|
3222
|
-
if (!await waitForRetry(delayMs,
|
|
3223
|
-
return;
|
|
3620
|
+
if (!await waitForRetry(delayMs, signal))
|
|
3621
|
+
return { done: true };
|
|
3224
3622
|
}
|
|
3225
3623
|
if (index + 1 < candidates.length) {
|
|
3226
3624
|
log(`Peer ${selected.peer.peerId.slice(0, 12)}... failed retryably; trying next model peer.`);
|
|
3227
3625
|
}
|
|
3228
3626
|
}
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
error: {
|
|
3237
|
-
type: 'peer_verification_failed',
|
|
3238
|
-
code: 'peer_verification_failed',
|
|
3239
|
-
message: lastVerificationError ?? `No verified peer currently serves model "${requestedService}".`,
|
|
3240
|
-
},
|
|
3241
|
-
}));
|
|
3242
|
-
}
|
|
3243
|
-
return;
|
|
3627
|
+
return lastRetry ?? dispatchFailure(502, { 'content-type': 'application/json' }, JSON.stringify({
|
|
3628
|
+
error: {
|
|
3629
|
+
type: 'peer_verification_failed',
|
|
3630
|
+
code: 'peer_verification_failed',
|
|
3631
|
+
message: lastVerificationError ?? `No verified peer currently serves model "${requestedService}".`,
|
|
3632
|
+
},
|
|
3633
|
+
}));
|
|
3244
3634
|
}
|
|
3245
3635
|
const pinnedPeerId = explicitPeerId;
|
|
3246
3636
|
// Narrow the candidate set to just the pinned peer (if we already know
|
|
@@ -3288,12 +3678,12 @@ export class BuyerProxy {
|
|
|
3288
3678
|
: '--peer flag or session pin';
|
|
3289
3679
|
const diagnostics = this._formatPeerSelectionDiagnostics(discoveredPeers);
|
|
3290
3680
|
log(`Pinned peer ${pinnedPeerId.slice(0, 12)}... not discoverable in DHT (${logSource})`);
|
|
3291
|
-
|
|
3292
|
-
|
|
3681
|
+
// Also the answer for a `<peerId>@<routingServiceId>` pin whose router
|
|
3682
|
+
// is offline: an absent peer cannot be told apart from a seller.
|
|
3683
|
+
return dispatchFailure(502, { 'content-type': 'text/plain' }, `Pinned peer ${pinnedPeerId.slice(0, 12)}... (seller or routing service) is not reachable right now. `
|
|
3293
3684
|
+ 'It may be offline, not announcing, or temporarily unreachable. '
|
|
3294
3685
|
+ 'Pick a different service in Discover or try again later. '
|
|
3295
3686
|
+ diagnostics);
|
|
3296
|
-
return;
|
|
3297
3687
|
}
|
|
3298
3688
|
if (routingPeers.length === 0) {
|
|
3299
3689
|
const pinnedPeer = discoveredPeers.find((peer) => peer.peerId.toLowerCase() === pinnedPeerId) ?? null;
|
|
@@ -3308,20 +3698,16 @@ export class BuyerProxy {
|
|
|
3308
3698
|
if (!providers.includes(explicitProvider)) {
|
|
3309
3699
|
const providerList = providers.length > 0 ? providers.join(', ') : 'none';
|
|
3310
3700
|
log(`Pinned peer ${pinnedPeerId.slice(0, 12)}... does not offer explicit provider=${explicitProvider}`);
|
|
3311
|
-
|
|
3312
|
-
res.end(`Pinned peer ${pinnedPeerId.slice(0, 12)}... does not offer provider=${explicitProvider}. `
|
|
3701
|
+
return dispatchFailure(502, { 'content-type': 'text/plain' }, `Pinned peer ${pinnedPeerId.slice(0, 12)}... does not offer provider=${explicitProvider}. `
|
|
3313
3702
|
+ `Available providers: ${providerList}. `
|
|
3314
3703
|
+ 'Remove or change the x-antseed-provider header, or pick a different peer. '
|
|
3315
3704
|
+ diagnostics);
|
|
3316
|
-
return;
|
|
3317
3705
|
}
|
|
3318
3706
|
}
|
|
3319
3707
|
log(`Pinned peer ${pinnedPeerId.slice(0, 12)}... filtered out by protocol/service match`);
|
|
3320
|
-
|
|
3321
|
-
res.end(`Pinned peer ${pinnedPeerId.slice(0, 12)}... does not support this request `
|
|
3708
|
+
return dispatchFailure(502, { 'content-type': 'text/plain' }, `Pinned peer ${pinnedPeerId.slice(0, 12)}... does not support this request `
|
|
3322
3709
|
+ `(${protocolLabel}, ${providerLabel}, ${serviceLabel}). `
|
|
3323
3710
|
+ `Pick a different service in Discover. ${diagnostics}`);
|
|
3324
|
-
return;
|
|
3325
3711
|
}
|
|
3326
3712
|
log(`Routing candidates: ${routingPeers.length} peer(s)`);
|
|
3327
3713
|
const router = this._node.router;
|
|
@@ -3331,9 +3717,7 @@ export class BuyerProxy {
|
|
|
3331
3717
|
// if an invariant breaks.
|
|
3332
3718
|
if (!selectedPeer) {
|
|
3333
3719
|
log(`Invariant: pinned peer ${pinnedPeerId.slice(0, 12)}... present in DHT but missing from narrowed candidate list`);
|
|
3334
|
-
|
|
3335
|
-
res.end(`Pinned peer ${pinnedPeerId.slice(0, 12)}... is currently unreachable. Try again in a moment.`);
|
|
3336
|
-
return;
|
|
3720
|
+
return dispatchFailure(502, { 'content-type': 'text/plain' }, `Pinned peer ${pinnedPeerId.slice(0, 12)}... is currently unreachable. Try again in a moment.`);
|
|
3337
3721
|
}
|
|
3338
3722
|
const policyRouter = router;
|
|
3339
3723
|
const selectedPlan = routingPlans.get(selectedPeer.peerId)
|
|
@@ -3343,8 +3727,7 @@ export class BuyerProxy {
|
|
|
3343
3727
|
? findMissingRequiredParameters(selectedPeer, selectedPlan.provider, pinnedServiceId, requiredParameters)
|
|
3344
3728
|
: requiredParameters;
|
|
3345
3729
|
if (missingRequired.length > 0) {
|
|
3346
|
-
|
|
3347
|
-
res.end(JSON.stringify({
|
|
3730
|
+
return dispatchFailure(422, { 'content-type': 'application/json' }, JSON.stringify({
|
|
3348
3731
|
error: {
|
|
3349
3732
|
type: 'required_capability_unavailable',
|
|
3350
3733
|
code: 'required_capability_unavailable',
|
|
@@ -3352,7 +3735,6 @@ export class BuyerProxy {
|
|
|
3352
3735
|
param: REQUIRED_PARAMETERS_HEADER,
|
|
3353
3736
|
},
|
|
3354
3737
|
}));
|
|
3355
|
-
return;
|
|
3356
3738
|
}
|
|
3357
3739
|
const pinnedRequest = pinnedServiceId ? withRoutedModel(serializedReq, pinnedServiceId) : serializedReq;
|
|
3358
3740
|
// A hard pin cannot escape the gateway's policy: an excluded seller is
|
|
@@ -3370,26 +3752,23 @@ export class BuyerProxy {
|
|
|
3370
3752
|
addBuyerConfigReasons(reasons, policyExclusionReasons(this._buyerHardPolicy(), facts));
|
|
3371
3753
|
if (reasons.length > 0) {
|
|
3372
3754
|
log(`Pinned peer ${selectedPeer.peerId.slice(0, 12)}... refused by routing policy: ${reasons.join('; ')}`);
|
|
3373
|
-
|
|
3755
|
+
return dispatchFailure(403, { 'content-type': 'application/json' }, JSON.stringify({
|
|
3374
3756
|
error: {
|
|
3375
3757
|
type: 'permission_error',
|
|
3376
3758
|
code: 'peer_not_allowed',
|
|
3377
3759
|
message: `Seller ${selectedPeer.peerId.slice(0, 12)}... is not allowed by the routing policy (${reasons.join('; ')}).`,
|
|
3378
3760
|
},
|
|
3379
|
-
});
|
|
3380
|
-
return;
|
|
3761
|
+
}));
|
|
3381
3762
|
}
|
|
3382
3763
|
}
|
|
3383
3764
|
if (!peerAllowedByPolicy(policyRouter, pinnedRequest, selectedPeer)) {
|
|
3384
3765
|
log(`Pinned peer ${selectedPeer.peerId.slice(0, 12)}... filtered out by buyer routing policy`);
|
|
3385
|
-
|
|
3386
|
-
res.end(`Pinned peer ${selectedPeer.peerId.slice(0, 12)}... is outside your buyer routing policy. `
|
|
3766
|
+
return dispatchFailure(502, { 'content-type': 'text/plain' }, `Pinned peer ${selectedPeer.peerId.slice(0, 12)}... is outside your buyer routing policy. `
|
|
3387
3767
|
+ 'Pick a different service in Discover or adjust your buyer pricing/reputation limits.');
|
|
3388
|
-
return;
|
|
3389
3768
|
}
|
|
3390
3769
|
if (this._verifier) {
|
|
3391
|
-
const makeReach = (chosenId) => makeVerifierReach(this._node, selectedPeer, chosenId,
|
|
3392
|
-
const outcome = await this._verifyPeer(selectedPeer, makeReach,
|
|
3770
|
+
const makeReach = (chosenId) => makeVerifierReach(this._node, selectedPeer, chosenId, signal);
|
|
3771
|
+
const outcome = await this._verifyPeer(selectedPeer, makeReach, signal, pinnedPolicy?.requireVerified === true);
|
|
3393
3772
|
const short = selectedPeer.peerId.slice(0, 12);
|
|
3394
3773
|
if (outcome.verified) {
|
|
3395
3774
|
log(`Verified ${short}... via ${outcome.sdk}`);
|
|
@@ -3398,23 +3777,17 @@ export class BuyerProxy {
|
|
|
3398
3777
|
log(`Verification ${outcome.sdk ? `(${outcome.sdk}) ` : ''}did not pass for ${short}...: ${outcome.reason ?? 'failed'}${outcome.ok ? ' (optional — routing anyway)' : ''}`);
|
|
3399
3778
|
}
|
|
3400
3779
|
if (!outcome.ok) {
|
|
3401
|
-
|
|
3402
|
-
res.end(`Pinned peer ${short}... failed required verification (${outcome.reason ?? 'failed'}). `
|
|
3780
|
+
return dispatchFailure(502, { 'content-type': 'text/plain' }, `Pinned peer ${short}... failed required verification (${outcome.reason ?? 'failed'}). `
|
|
3403
3781
|
+ 'Pick a different peer, or run without --require-verifier.');
|
|
3404
|
-
return;
|
|
3405
3782
|
}
|
|
3406
3783
|
}
|
|
3407
3784
|
log(`Using pinned peer ${selectedPeer.peerId.slice(0, 12)}...`);
|
|
3408
|
-
const result = await this._dispatchToPeer(res, pinnedRequest, selectedPeer, routingPlans, requestProtocol, requestedService, explicitProvider, router, true,
|
|
3785
|
+
const result = await this._dispatchToPeer(res, pinnedRequest, selectedPeer, routingPlans, requestProtocol, requestedService, explicitProvider, router, true, signal, false, routingConversationKey);
|
|
3409
3786
|
if (result.done && trackedConversationId && pinnedServiceId) {
|
|
3410
3787
|
this._conversations.recordRoutedModel(trackedConversationId, `${selectedPeer.peerId}@${pinnedServiceId}`, conversationPinAtRouting);
|
|
3411
3788
|
}
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
// peer for an explicit pin, so surface the error to the client.
|
|
3415
|
-
res.writeHead(result.statusCode, result.responseHeaders);
|
|
3416
|
-
res.end(result.responseBody);
|
|
3417
|
-
}
|
|
3789
|
+
// A pinned peer is never retried against another peer; the caller reports any failure.
|
|
3790
|
+
return result;
|
|
3418
3791
|
}
|
|
3419
3792
|
async _verifyPeer(peer, makeReach, signal, requireVerified = false) {
|
|
3420
3793
|
const configured = this._verifier;
|
|
@@ -3499,7 +3872,7 @@ export class BuyerProxy {
|
|
|
3499
3872
|
* was sent to the client (success or non-retryable error), or retry info if the
|
|
3500
3873
|
* caller should try another peer.
|
|
3501
3874
|
*/
|
|
3502
|
-
async _dispatchToPeer(res, serializedReq, selectedPeer, routePlanByPeerId, requestProtocol, requestedService, explicitProvider, router, pinned, requestSignal, gatewayRouted = false) {
|
|
3875
|
+
async _dispatchToPeer(res, serializedReq, selectedPeer, routePlanByPeerId, requestProtocol, requestedService, explicitProvider, router, pinned, requestSignal, gatewayRouted = false, routingConversationKey = null) {
|
|
3503
3876
|
const gatewayAutoRoute = gatewayRouted && !pinned;
|
|
3504
3877
|
const selectedRoutePlan = routePlanByPeerId.get(selectedPeer.peerId)
|
|
3505
3878
|
?? resolvePeerRoutePlan(selectedPeer, requestProtocol, requestedService, explicitProvider, 'lenient');
|
|
@@ -3600,17 +3973,17 @@ export class BuyerProxy {
|
|
|
3600
3973
|
try {
|
|
3601
3974
|
if (wantsStreaming) {
|
|
3602
3975
|
let streamed = false;
|
|
3976
|
+
let firstFrameAt = 0;
|
|
3603
3977
|
const response = await this._node.sendRequestStream(selectedPeer, requestForPeer, {
|
|
3604
3978
|
onResponseStart: (startResponse, metadata) => {
|
|
3605
3979
|
if (!metadata.streaming)
|
|
3606
3980
|
return;
|
|
3607
3981
|
streamed = true;
|
|
3608
|
-
|
|
3609
|
-
this._recordPeerLatency(selectedPeer.peerId, latencyServiceId, Date.now() - startTime);
|
|
3982
|
+
firstFrameAt = Date.now();
|
|
3610
3983
|
const adaptedStartResponse = streamResponseAdapter
|
|
3611
3984
|
? streamResponseAdapter.adaptStart(startResponse)
|
|
3612
3985
|
: startResponse;
|
|
3613
|
-
const streamingHeaders = attachStreamingAntseedHeaders(adaptedStartResponse.headers, selectedPeer, requestForPeer);
|
|
3986
|
+
const streamingHeaders = attachStreamingAntseedHeaders(adaptedStartResponse.headers, selectedPeer, requestForPeer, firstFrameAt - startTime);
|
|
3614
3987
|
// Ensure content-type is set for SSE — some upstream APIs (e.g. Codex)
|
|
3615
3988
|
// omit it, which can cause the client's fetch body reader to not
|
|
3616
3989
|
// detect end-of-stream properly.
|
|
@@ -3636,6 +4009,7 @@ export class BuyerProxy {
|
|
|
3636
4009
|
}
|
|
3637
4010
|
},
|
|
3638
4011
|
}, { signal: requestSignal, pinned, ...(buyerIdentity ? { buyerIdentity } : {}) });
|
|
4012
|
+
recordRouterUsage(this._modelRoutingClient, routingConversationKey, requestForPeer, response, selectedPeer, requestSignal);
|
|
3639
4013
|
let responseForClient = adaptBuyerFaultErrorResponse(response, requestProtocol);
|
|
3640
4014
|
responseForClient = adaptPeerResponse(responseForClient);
|
|
3641
4015
|
if (!streamed
|
|
@@ -3648,15 +4022,17 @@ export class BuyerProxy {
|
|
|
3648
4022
|
if (responseForClient.statusCode === 402) {
|
|
3649
4023
|
responseForClient = inject402PeerId(responseForClient, selectedPeer.peerId);
|
|
3650
4024
|
}
|
|
3651
|
-
const
|
|
3652
|
-
|
|
3653
|
-
this._recordPeerLatency(selectedPeer.peerId, latencyServiceId, latencyMs);
|
|
4025
|
+
const finishedAt = Date.now();
|
|
4026
|
+
const latencyMs = finishedAt - startTime;
|
|
3654
4027
|
log(`Response: ${responseForClient.statusCode} (${latencyMs}ms, ${responseForClient.body.length} bytes)`);
|
|
3655
4028
|
if (responseForClient.statusCode >= 400) {
|
|
3656
4029
|
const prefix = adaptResponse && !streamed ? 'Upstream adapted error detail' : 'Upstream error detail';
|
|
3657
4030
|
log(`${prefix}: ${summarizeErrorResponse(responseForClient)}`);
|
|
3658
4031
|
}
|
|
3659
4032
|
const telemetry = computeResponseTelemetry(requestForPeer, responseForClient.headers, responseForClient.body, selectedPeer);
|
|
4033
|
+
if (streamed && responseForClient.statusCode < 400) {
|
|
4034
|
+
this._recordPeerSpeed(selectedPeer.peerId, latencyServiceId, firstFrameAt - startTime, finishedAt - firstFrameAt, telemetry.usage.outputTokens);
|
|
4035
|
+
}
|
|
3660
4036
|
const responseFault = responseFaultAttribution(responseForClient);
|
|
3661
4037
|
const modelNotFound = !streamed
|
|
3662
4038
|
&& !isControlPlaneServicesPath(requestForPeer.path)
|
|
@@ -3709,6 +4085,7 @@ export class BuyerProxy {
|
|
|
3709
4085
|
if (upstreamResponse.statusCode >= 400 && !adaptResponse) {
|
|
3710
4086
|
log(`Upstream raw error detail: ${summarizeErrorResponse(upstreamResponse)}`);
|
|
3711
4087
|
}
|
|
4088
|
+
recordRouterUsage(this._modelRoutingClient, routingConversationKey, requestForPeer, upstreamResponse, selectedPeer, requestSignal);
|
|
3712
4089
|
// Only the seller that accepted a video job knows its ID. Remember and
|
|
3713
4090
|
// persist job ID -> seller so later retrieve requests (even
|
|
3714
4091
|
// after a proxy restart) are pinned back to that seller.
|
|
@@ -3728,8 +4105,6 @@ export class BuyerProxy {
|
|
|
3728
4105
|
}
|
|
3729
4106
|
const latencyMs = Date.now() - startTime;
|
|
3730
4107
|
this._markModelActivity();
|
|
3731
|
-
if (response.statusCode < 400)
|
|
3732
|
-
this._recordPeerLatency(selectedPeer.peerId, latencyServiceId, latencyMs);
|
|
3733
4108
|
log(`Response: ${response.statusCode} (${latencyMs}ms, ${response.body.length} bytes)`);
|
|
3734
4109
|
if (response.statusCode >= 400) {
|
|
3735
4110
|
const prefix = adaptResponse ? 'Upstream adapted error detail' : 'Upstream error detail';
|