@antseed/cli 0.1.190 → 0.1.192
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/cli/commands/seller/start.d.ts.map +1 -1
- package/dist/cli/commands/seller/start.js +2 -1
- package/dist/cli/commands/seller/start.js.map +1 -1
- package/dist/cli/index.js +3 -5
- package/dist/cli/index.js.map +1 -1
- package/dist/config/service-metadata.test.js +4 -0
- package/dist/config/service-metadata.test.js.map +1 -1
- package/dist/gateway/gateway-policy.test.js +19 -0
- package/dist/gateway/gateway-policy.test.js.map +1 -1
- package/dist/gateway/routing-through-gateway.test.d.ts +2 -0
- package/dist/gateway/routing-through-gateway.test.d.ts.map +1 -0
- package/dist/gateway/routing-through-gateway.test.js +177 -0
- package/dist/gateway/routing-through-gateway.test.js.map +1 -0
- package/dist/plugins/registry.d.ts.map +1 -1
- package/dist/plugins/registry.js +12 -0
- package/dist/plugins/registry.js.map +1 -1
- package/dist/proxy/buyer-proxy.d.ts +62 -4
- package/dist/proxy/buyer-proxy.d.ts.map +1 -1
- package/dist/proxy/buyer-proxy.js +514 -120
- package/dist/proxy/buyer-proxy.js.map +1 -1
- package/dist/proxy/buyer-proxy.test.js +1347 -5
- package/dist/proxy/buyer-proxy.test.js.map +1 -1
- package/dist/proxy/conversation-store.d.ts +12 -0
- package/dist/proxy/conversation-store.d.ts.map +1 -1
- package/dist/proxy/conversation-store.js +30 -0
- package/dist/proxy/conversation-store.js.map +1 -1
- package/dist/proxy/conversation-store.test.js +69 -1
- package/dist/proxy/conversation-store.test.js.map +1 -1
- package/dist/proxy/gateway-policy.test.js +110 -0
- package/dist/proxy/gateway-policy.test.js.map +1 -1
- package/dist/proxy/network-models.d.ts +27 -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 +21 -1
- package/dist/proxy/request-utils.d.ts.map +1 -1
- package/dist/proxy/request-utils.js +24 -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 +222 -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 +48 -0
- package/dist/proxy/routing-services.d.ts.map +1 -0
- package/dist/proxy/routing-services.js +66 -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/package.json +10 -8
|
@@ -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 { InvalidRouterOptionsError, ModelRoutingClient, routingClientAdapter, routingProtocolOfTarget, validateRouterOptions } 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';
|
|
@@ -91,6 +95,8 @@ function isLoopbackAddress(address) {
|
|
|
91
95
|
// because model_not_found already has dedicated unadvertise handling while a
|
|
92
96
|
// generic 404 would repeat identically on every peer.
|
|
93
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]);
|
|
94
100
|
/**
|
|
95
101
|
* Gateway-policy requests on automatic routing also move on after a 402: the
|
|
96
102
|
* buyer could not fund that seller (no deposits for its reserve), but a free
|
|
@@ -147,6 +153,10 @@ function isValidRoutedModelTarget(value) {
|
|
|
147
153
|
return false;
|
|
148
154
|
return !value.includes('@') || parsePeerPinnedService(value) !== null;
|
|
149
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
|
+
}
|
|
150
160
|
/** Returns `request` with its body's model field rewritten to `serviceId`, or unchanged if nothing rewrote. */
|
|
151
161
|
function withRoutedModel(request, serviceId) {
|
|
152
162
|
const rewritten = overrideRoutedModelInBody(request.body, request.headers, serviceId);
|
|
@@ -254,6 +264,18 @@ function responseFaultAttribution(response) {
|
|
|
254
264
|
? attribution
|
|
255
265
|
: 'peer';
|
|
256
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
|
+
}
|
|
257
279
|
function adaptOpenAICompatibleErrorResponse(response, requestProtocol) {
|
|
258
280
|
if (response.statusCode !== 402) {
|
|
259
281
|
return response;
|
|
@@ -723,12 +745,14 @@ export class BuyerProxy {
|
|
|
723
745
|
_configFileWatching = false;
|
|
724
746
|
_pinnedPeer;
|
|
725
747
|
/**
|
|
726
|
-
*
|
|
727
|
-
*
|
|
728
|
-
*
|
|
729
|
-
* 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.
|
|
730
751
|
*/
|
|
731
752
|
_defaultRoutedModel = null;
|
|
753
|
+
_defaultRouter = null;
|
|
754
|
+
_routingModels = new RoutingModelsCache();
|
|
755
|
+
_modelRoutingClient;
|
|
732
756
|
_conversations;
|
|
733
757
|
/**
|
|
734
758
|
* Wall-clock of the last model-request activity (dispatch or streamed
|
|
@@ -818,6 +842,7 @@ export class BuyerProxy {
|
|
|
818
842
|
this._restartAllowedAt = Date.now() + (config.minRestartIntervalMs ?? MIN_RESTART_INTERVAL_MS);
|
|
819
843
|
this._settingOverrides = [...(config.settingOverrides ?? [])];
|
|
820
844
|
this._node = config.node;
|
|
845
|
+
this._modelRoutingClient = config.modelRoutingClient ?? new ModelRoutingClient();
|
|
821
846
|
this._verifier = config.verifier;
|
|
822
847
|
this._buyerIdentities = config.buyerIdentities ?? new BuyerIdentityLoader(config.node, config.dataDir);
|
|
823
848
|
this._teeVerification = new TeeVerification(config.verifier);
|
|
@@ -888,13 +913,15 @@ export class BuyerProxy {
|
|
|
888
913
|
const entry = this._requestConversations.get(event.requestId);
|
|
889
914
|
if (!entry)
|
|
890
915
|
return;
|
|
916
|
+
const routing = entry.purpose === 'routing';
|
|
891
917
|
this._conversations.addSpend(entry.convId, {
|
|
892
918
|
amountUsdc: event.amountUsdc,
|
|
893
919
|
inputTokens: event.inputTokens,
|
|
894
920
|
cachedInputTokens: event.cachedInputTokens,
|
|
895
921
|
outputTokens: event.outputTokens,
|
|
896
|
-
}, !entry.counted);
|
|
897
|
-
|
|
922
|
+
}, !routing && !entry.counted);
|
|
923
|
+
if (!routing)
|
|
924
|
+
entry.counted = true;
|
|
898
925
|
}
|
|
899
926
|
/**
|
|
900
927
|
* Identity named by a request: null for the default, the name when it is
|
|
@@ -1439,8 +1466,12 @@ export class BuyerProxy {
|
|
|
1439
1466
|
* a grace window — the seller-initiated auth path can land just after the
|
|
1440
1467
|
* response is returned. Bounded so a leaked id can't grow the map forever.
|
|
1441
1468
|
*/
|
|
1442
|
-
_trackRequestConversation(requestId, convId) {
|
|
1443
|
-
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 });
|
|
1444
1475
|
while (this._requestConversations.size > MAX_TRACKED_REQUEST_CONVERSATIONS) {
|
|
1445
1476
|
const oldest = this._requestConversations.keys().next().value;
|
|
1446
1477
|
if (oldest === undefined)
|
|
@@ -1617,6 +1648,46 @@ export class BuyerProxy {
|
|
|
1617
1648
|
// watcher setup failed; non-fatal
|
|
1618
1649
|
}
|
|
1619
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, options?, allowedModels? }`). */
|
|
1658
|
+
_parseDefaultRouter(value) {
|
|
1659
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
1660
|
+
throw new Error('router must be { service, options?, allowedModels? }');
|
|
1661
|
+
const selection = { ...value, kind: 'router' };
|
|
1662
|
+
this._validateRouterSelection(selection);
|
|
1663
|
+
return structuredClone(selection);
|
|
1664
|
+
}
|
|
1665
|
+
/**
|
|
1666
|
+
* Check a router selection's options against the protocol its router advertises, when the
|
|
1667
|
+
* router is in the peer cache; throws `InvalidRouterOptionsError`. An unknown router is
|
|
1668
|
+
* checked again before each ranking.
|
|
1669
|
+
*/
|
|
1670
|
+
async _checkRouterOptions(selection) {
|
|
1671
|
+
if (!selection.service || selection.options === undefined)
|
|
1672
|
+
return;
|
|
1673
|
+
let peers;
|
|
1674
|
+
try {
|
|
1675
|
+
peers = await this._getPeers();
|
|
1676
|
+
}
|
|
1677
|
+
catch {
|
|
1678
|
+
return; // Discovery is unavailable: the options are checked again before each ranking.
|
|
1679
|
+
}
|
|
1680
|
+
const protocol = routingProtocolOfTarget(peers, selection.service);
|
|
1681
|
+
if (protocol)
|
|
1682
|
+
validateRouterOptions(routingClientAdapter(protocol).options, selection.options);
|
|
1683
|
+
}
|
|
1684
|
+
/** The default router in its wire form, without the internal `kind` tag. */
|
|
1685
|
+
get _defaultRouterJson() {
|
|
1686
|
+
if (!this._defaultRouter)
|
|
1687
|
+
return null;
|
|
1688
|
+
const { kind: _kind, ...router } = this._defaultRouter;
|
|
1689
|
+
return router;
|
|
1690
|
+
}
|
|
1620
1691
|
async _reloadSessionOverrides(opts = {}) {
|
|
1621
1692
|
try {
|
|
1622
1693
|
const raw = await readFile(this._stateFile, 'utf-8');
|
|
@@ -1629,7 +1700,16 @@ export class BuyerProxy {
|
|
|
1629
1700
|
}
|
|
1630
1701
|
const routedModel = typeof parsed.defaultRoutedModel === 'string' ? parsed.defaultRoutedModel.trim() : '';
|
|
1631
1702
|
this._defaultRoutedModel = routedModel.length > 0 && isValidRoutedModelTarget(routedModel) ? routedModel : null;
|
|
1632
|
-
|
|
1703
|
+
try {
|
|
1704
|
+
this._defaultRouter = null;
|
|
1705
|
+
if (parsed.defaultRouter != null && this._defaultRoutedModel === null) {
|
|
1706
|
+
this._defaultRouter = this._parseDefaultRouter(parsed.defaultRouter);
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
catch {
|
|
1710
|
+
this._defaultRouter = null;
|
|
1711
|
+
}
|
|
1712
|
+
log(`Session overrides reloaded: peer=${this._pinnedPeer ?? 'none'} route=${this._defaultRoutedModel ?? 'none'} router=${JSON.stringify(this._defaultRouterJson)}`);
|
|
1633
1713
|
}
|
|
1634
1714
|
catch {
|
|
1635
1715
|
// state file unreadable; keep current values
|
|
@@ -1708,7 +1788,7 @@ export class BuyerProxy {
|
|
|
1708
1788
|
// in the file — the debounce may have been cancelled before
|
|
1709
1789
|
// _reloadSessionOverrides could commit the latest CLI-written values.
|
|
1710
1790
|
const sessionOverrides = state === 'connected'
|
|
1711
|
-
? { pinnedPeerId: this._pinnedPeer, defaultRoutedModel: this._defaultRoutedModel }
|
|
1791
|
+
? { pinnedPeerId: this._pinnedPeer, defaultRoutedModel: this._defaultRoutedModel, defaultRouter: this._defaultRouterJson }
|
|
1712
1792
|
: {};
|
|
1713
1793
|
await this._mergeStateFile({
|
|
1714
1794
|
state,
|
|
@@ -2254,9 +2334,14 @@ export class BuyerProxy {
|
|
|
2254
2334
|
res.end(JSON.stringify({ ok: true, peerId: normalized }));
|
|
2255
2335
|
return;
|
|
2256
2336
|
}
|
|
2337
|
+
if (path === '/_antseed/routing-services' && method === 'GET') {
|
|
2338
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
2339
|
+
res.end(JSON.stringify({ ok: true, services: await buildRoutingServices(await this._getPeers(), this._modelRoutingClient, this._node, this._routingModels) }));
|
|
2340
|
+
return;
|
|
2341
|
+
}
|
|
2257
2342
|
if (path === '/_antseed/route' && method === 'GET') {
|
|
2258
2343
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
2259
|
-
res.end(JSON.stringify({ ok: true, model: this._defaultRoutedModel }));
|
|
2344
|
+
res.end(JSON.stringify({ ok: true, model: this._defaultRoutedModel, router: this._defaultRouterJson }));
|
|
2260
2345
|
return;
|
|
2261
2346
|
}
|
|
2262
2347
|
if (path === '/_antseed/route' && method === 'POST') {
|
|
@@ -2271,9 +2356,10 @@ export class BuyerProxy {
|
|
|
2271
2356
|
}
|
|
2272
2357
|
chunks.push(chunk);
|
|
2273
2358
|
}
|
|
2359
|
+
let body;
|
|
2274
2360
|
let model;
|
|
2275
2361
|
try {
|
|
2276
|
-
|
|
2362
|
+
body = JSON.parse(Buffer.concat(chunks).toString());
|
|
2277
2363
|
model = typeof body.model === 'string' ? body.model.trim() : '';
|
|
2278
2364
|
}
|
|
2279
2365
|
catch {
|
|
@@ -2281,16 +2367,29 @@ export class BuyerProxy {
|
|
|
2281
2367
|
res.end(JSON.stringify({ ok: false, error: 'Invalid JSON body' }));
|
|
2282
2368
|
return;
|
|
2283
2369
|
}
|
|
2284
|
-
|
|
2370
|
+
let router = null;
|
|
2371
|
+
try {
|
|
2372
|
+
if (body.router != null) {
|
|
2373
|
+
if (model.length > 0)
|
|
2374
|
+
throw new Error('Set model or router, not both');
|
|
2375
|
+
router = this._parseDefaultRouter(body.router);
|
|
2376
|
+
await this._checkRouterOptions(router);
|
|
2377
|
+
}
|
|
2378
|
+
if (model.length > 0 && !isValidRoutedModelTarget(model)) {
|
|
2379
|
+
throw new Error('model must be "<service>", "<peerId>@<service>", or empty to clear');
|
|
2380
|
+
}
|
|
2381
|
+
}
|
|
2382
|
+
catch (error) {
|
|
2285
2383
|
res.writeHead(400, { 'content-type': 'application/json' });
|
|
2286
|
-
res.end(JSON.stringify({ ok: false, error:
|
|
2384
|
+
res.end(JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) }));
|
|
2287
2385
|
return;
|
|
2288
2386
|
}
|
|
2289
2387
|
this._defaultRoutedModel = model.length > 0 ? model : null;
|
|
2290
|
-
|
|
2291
|
-
|
|
2388
|
+
this._defaultRouter = router;
|
|
2389
|
+
await this._mergeStateFile({ defaultRoutedModel: this._defaultRoutedModel, defaultRouter: this._defaultRouterJson });
|
|
2390
|
+
log(`Default route set: model=${this._defaultRoutedModel ?? 'none'} router=${JSON.stringify(this._defaultRouterJson)}`);
|
|
2292
2391
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
2293
|
-
res.end(JSON.stringify({ ok: true, model: this._defaultRoutedModel }));
|
|
2392
|
+
res.end(JSON.stringify({ ok: true, model: this._defaultRoutedModel, router: this._defaultRouterJson }));
|
|
2294
2393
|
return;
|
|
2295
2394
|
}
|
|
2296
2395
|
if (path === '/_antseed/conversations' && method === 'GET') {
|
|
@@ -2331,6 +2430,8 @@ export class BuyerProxy {
|
|
|
2331
2430
|
let parsed;
|
|
2332
2431
|
try {
|
|
2333
2432
|
parsed = JSON.parse(Buffer.concat(chunks).toString());
|
|
2433
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
2434
|
+
throw new Error('Invalid conversation update');
|
|
2334
2435
|
}
|
|
2335
2436
|
catch {
|
|
2336
2437
|
res.writeHead(400, { 'content-type': 'application/json' });
|
|
@@ -2355,6 +2456,23 @@ export class BuyerProxy {
|
|
|
2355
2456
|
res.end(JSON.stringify({ ok: false, error: 'Unknown conversation' }));
|
|
2356
2457
|
return;
|
|
2357
2458
|
}
|
|
2459
|
+
if ('routingSelection' in parsed) {
|
|
2460
|
+
try {
|
|
2461
|
+
if ('pinnedModel' in parsed)
|
|
2462
|
+
throw new Error('Update routingSelection or pinnedModel, not both');
|
|
2463
|
+
if (parsed.routingSelection !== null) {
|
|
2464
|
+
this._validateRouterSelection(parsed.routingSelection);
|
|
2465
|
+
await this._checkRouterOptions(parsed.routingSelection);
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
2468
|
+
catch (error) {
|
|
2469
|
+
res.writeHead(400, { 'content-type': 'application/json' });
|
|
2470
|
+
res.end(JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) }));
|
|
2471
|
+
return;
|
|
2472
|
+
}
|
|
2473
|
+
const selection = parsed.routingSelection;
|
|
2474
|
+
conversation = this._conversations.setRoutingSelection(id, selection);
|
|
2475
|
+
}
|
|
2358
2476
|
if ('pinnedModel' in parsed) {
|
|
2359
2477
|
const pin = typeof parsed.pinnedModel === 'string' ? parsed.pinnedModel.trim() : '';
|
|
2360
2478
|
if (pin.length > 0 && !isValidRoutedModelTarget(pin)) {
|
|
@@ -2730,18 +2848,66 @@ export class BuyerProxy {
|
|
|
2730
2848
|
res.writeHead(400, responseHeaders);
|
|
2731
2849
|
res.end(JSON.stringify({
|
|
2732
2850
|
error: {
|
|
2733
|
-
message: `Unknown model type "${url.searchParams.get('type') ?? ''}" — expected "text", "images", "decisions", or "
|
|
2851
|
+
message: `Unknown model type "${url.searchParams.get('type') ?? ''}" — expected "text", "images", "decisions", "videos", or "routers".`,
|
|
2734
2852
|
type: 'invalid_request_error',
|
|
2735
2853
|
param: 'type',
|
|
2736
2854
|
},
|
|
2737
2855
|
}));
|
|
2738
2856
|
return;
|
|
2739
2857
|
}
|
|
2858
|
+
if (typeFilter === 'router') {
|
|
2859
|
+
const routers = await this._networkRouters(peers, requestPolicy);
|
|
2860
|
+
log(`GET /v1/models?type=router answered locally: ${routers.length} routers`);
|
|
2861
|
+
res.writeHead(200, responseHeaders);
|
|
2862
|
+
res.end(JSON.stringify({ object: 'list', data: routers }));
|
|
2863
|
+
return;
|
|
2864
|
+
}
|
|
2740
2865
|
const data = typeFilter === 'all' ? models : models.filter((entry) => entry.type === typeFilter);
|
|
2741
2866
|
log(`GET /v1/models answered locally: ${data.length} models across ${peers.length} peers${typeFilter ? ` (type=${typeFilter})` : ''}`);
|
|
2742
2867
|
res.writeHead(200, responseHeaders);
|
|
2743
2868
|
res.end(JSON.stringify({ object: 'list', data }));
|
|
2744
2869
|
}
|
|
2870
|
+
/**
|
|
2871
|
+
* Routing services on the network, one entry per service id with each
|
|
2872
|
+
* router peer offering it. A router is listed only when a request could pay
|
|
2873
|
+
* it (see `_routerExclusionReasons`), and under a gateway policy its models
|
|
2874
|
+
* are cut to the ones the policy allows. Model lists are free calls, cached
|
|
2875
|
+
* per router.
|
|
2876
|
+
*/
|
|
2877
|
+
async _networkRouters(peers, requestPolicy) {
|
|
2878
|
+
const policy = this._effectivePolicy(requestPolicy);
|
|
2879
|
+
const services = await buildRoutingServices(peers, this._modelRoutingClient, this._node, this._routingModels);
|
|
2880
|
+
const created = Math.floor(this._now() / 1000);
|
|
2881
|
+
const byId = new Map();
|
|
2882
|
+
for (const service of services) {
|
|
2883
|
+
const peer = peers.find((candidate) => candidate.peerId === service.peerId);
|
|
2884
|
+
if (!peer || this._routerExclusionReasons(peer, service.serviceId, requestPolicy).length > 0)
|
|
2885
|
+
continue;
|
|
2886
|
+
const models = service.catalog
|
|
2887
|
+
? [...new Set(service.catalog.models.map((model) => model.serviceId))]
|
|
2888
|
+
.filter((model) => !policy || policyAllowsModel(policy, model))
|
|
2889
|
+
.sort((a, b) => a.localeCompare(b))
|
|
2890
|
+
: null;
|
|
2891
|
+
const price = Number(service.priceMicroUsdc);
|
|
2892
|
+
let entry = byId.get(service.serviceId);
|
|
2893
|
+
if (!entry) {
|
|
2894
|
+
entry = { id: service.serviceId, name: service.label, object: 'model', created, owned_by: 'antseed', type: 'router', peers: [] };
|
|
2895
|
+
byId.set(service.serviceId, entry);
|
|
2896
|
+
}
|
|
2897
|
+
entry.peers.push({
|
|
2898
|
+
peerId: service.peerId,
|
|
2899
|
+
...(service.sellerName ? { displayName: service.sellerName } : {}),
|
|
2900
|
+
provider: service.provider,
|
|
2901
|
+
serviceId: service.serviceId,
|
|
2902
|
+
protocol: service.protocol,
|
|
2903
|
+
options: service.options,
|
|
2904
|
+
model: `${service.peerId}@${service.serviceId}`,
|
|
2905
|
+
priceUsdPerRequest: Number.isFinite(price) ? price / 1_000_000 : null,
|
|
2906
|
+
models,
|
|
2907
|
+
});
|
|
2908
|
+
}
|
|
2909
|
+
return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id));
|
|
2910
|
+
}
|
|
2745
2911
|
/**
|
|
2746
2912
|
* Whether a request under the gateway's policy could use this offer, by
|
|
2747
2913
|
* the checks that judge a pinned `peer@model`: the model allow list, the
|
|
@@ -2911,6 +3077,19 @@ export class BuyerProxy {
|
|
|
2911
3077
|
}));
|
|
2912
3078
|
return;
|
|
2913
3079
|
}
|
|
3080
|
+
// Per-request routing options (`routing` in the body) are read here and
|
|
3081
|
+
// stripped, so they never reach a seller, whatever the model. A malformed
|
|
3082
|
+
// `routing` is reported only when a router serves the request.
|
|
3083
|
+
let routingOptions = {};
|
|
3084
|
+
let invalidRouting = null;
|
|
3085
|
+
if (method === 'POST' && isCompletionRequestPath(path)) {
|
|
3086
|
+
const taken = takeRoutingOptions(serializedReq);
|
|
3087
|
+
if (taken.kind === 'options') {
|
|
3088
|
+
serializedReq = taken.request;
|
|
3089
|
+
routingOptions = taken.options;
|
|
3090
|
+
invalidRouting = taken.invalid ?? null;
|
|
3091
|
+
}
|
|
3092
|
+
}
|
|
2914
3093
|
if (nativeVideo) {
|
|
2915
3094
|
// Re-classify with the body: some APIs (Venice) carry the job ID in it.
|
|
2916
3095
|
const prepared = prepareVideoRequest(nativeVideoRoute(serializedReq) ?? nativeVideo, serializedReq.headers, this._resourceRoutes);
|
|
@@ -2936,6 +3115,7 @@ export class BuyerProxy {
|
|
|
2936
3115
|
const conversationIdentity = isConversationRequest
|
|
2937
3116
|
? extractConversationIdentity(serializedReq.headers, conversationBody)
|
|
2938
3117
|
: null;
|
|
3118
|
+
const routingConversationKey = conversationIdentity ? `${conversationIdentity.tool}:${conversationIdentity.sessionKey}` : null;
|
|
2939
3119
|
// Internal marker from the system proxy: source profile used only for
|
|
2940
3120
|
// local conversation attribution. Stripped here so it never reaches a seller.
|
|
2941
3121
|
delete serializedReq.headers[SYSTEM_PROXY_SOURCE_HEADER];
|
|
@@ -2945,6 +3125,10 @@ export class BuyerProxy {
|
|
|
2945
3125
|
const storedConversation = conversationIdentity && trackedConversationKey
|
|
2946
3126
|
? this._conversations.get(`${conversationIdentity.tool}:${trackedConversationKey}`)
|
|
2947
3127
|
: null;
|
|
3128
|
+
// Use this chat's router override when present; otherwise follow the buyer's default choice.
|
|
3129
|
+
const conversationSelection = storedConversation?.routingSelection ?? null;
|
|
3130
|
+
// Keep this request's choice stable; later selection changes affect subsequent requests.
|
|
3131
|
+
const routerSelection = structuredClone(conversationSelection ?? this._defaultRouter);
|
|
2948
3132
|
const storedAutoRoute = storedConversation?.peerSource === 'auto' && storedConversation.pinnedModel
|
|
2949
3133
|
? parsePeerPinnedService(storedConversation.pinnedModel)
|
|
2950
3134
|
: null;
|
|
@@ -2954,6 +3138,249 @@ export class BuyerProxy {
|
|
|
2954
3138
|
const preferredPeerHeader = normalizePeerId(serializedReq.headers['x-antseed-prefer-peer'] ?? '');
|
|
2955
3139
|
const preferredConversationPeerId = preferredPeerHeader ?? storedAutoRoute?.peerId ?? null;
|
|
2956
3140
|
const effectiveRoutedModel = chatPinnedModel ?? this._defaultRoutedModel;
|
|
3141
|
+
// Where a router pin can come from besides the request's own model: the
|
|
3142
|
+
// `antseed` alias resolves to the chat's explicit pin or, when the chat
|
|
3143
|
+
// chose no route of its own (neither a pin nor a router), the default
|
|
3144
|
+
// route; a system-proxied request follows the chat's explicit pin. An
|
|
3145
|
+
// automatic chat route (the seller a router chose last turn) never
|
|
3146
|
+
// counts, so every turn of a router-pinned chat is ranked again.
|
|
3147
|
+
const userChatPin = storedConversation?.peerSource === 'user' ? storedConversation.pinnedModel : null;
|
|
3148
|
+
const aliasTarget = userChatPin ?? (conversationSelection ? null : this._defaultRoutedModel);
|
|
3149
|
+
// Reuse disconnect cancellation for both the recommendation and the eventual inference call.
|
|
3150
|
+
const clientAbortController = new AbortController();
|
|
3151
|
+
const onClientAbort = () => {
|
|
3152
|
+
if (clientAbortController.signal.aborted)
|
|
3153
|
+
return;
|
|
3154
|
+
clientAbortController.abort();
|
|
3155
|
+
log(`Client disconnected; aborting upstream request reqId=${serializedReq.requestId.slice(0, 8)}`);
|
|
3156
|
+
};
|
|
3157
|
+
req.once('close', () => {
|
|
3158
|
+
if (!req.complete && !res.writableEnded)
|
|
3159
|
+
onClientAbort();
|
|
3160
|
+
});
|
|
3161
|
+
res.once('close', () => {
|
|
3162
|
+
if (!res.writableEnded)
|
|
3163
|
+
onClientAbort();
|
|
3164
|
+
});
|
|
3165
|
+
// Explicit model/seller choices bypass recommendations, even when the default is a router.
|
|
3166
|
+
// A system-proxy connection's model was assigned at connect time, not chosen by the tool:
|
|
3167
|
+
// under a router it stands for the automatic alias, unless this chat is pinned. The request
|
|
3168
|
+
// is only rewritten once the router path is certain (below), so a request that goes direct
|
|
3169
|
+
// keeps its connect-time model.
|
|
3170
|
+
const systemRoutedAsAlias = routerSelection !== null && systemRoutedModel && storedConversation?.peerSource !== 'user';
|
|
3171
|
+
const rawService = systemRoutedAsAlias ? ROUTED_MODEL_ALIAS : extractRequestedService(serializedReq);
|
|
3172
|
+
// `<routerPeer>@<routingServiceId>`: the request names a routing service as
|
|
3173
|
+
// its model, so that router picks the model and seller for this request.
|
|
3174
|
+
const routerPinSource = rawService === ROUTED_MODEL_ALIAS
|
|
3175
|
+
? aliasTarget
|
|
3176
|
+
: systemRoutedModel && userChatPin ? userChatPin : rawService;
|
|
3177
|
+
const pinnedRouter = await this._resolvePinnedRouter(routerPinSource);
|
|
3178
|
+
const autoRequested = routerSelection !== null
|
|
3179
|
+
&& rawService === ROUTED_MODEL_ALIAS;
|
|
3180
|
+
const chatModelPin = storedConversation?.peerSource === 'user' ? chatPinnedModel : null;
|
|
3181
|
+
if (autoRequested && chatModelPin) {
|
|
3182
|
+
serializedReq = withRoutedModel(serializedReq, chatModelPin);
|
|
3183
|
+
}
|
|
3184
|
+
const useModelRouter = pinnedRouter !== null || (autoRequested && !chatModelPin
|
|
3185
|
+
&& !getExplicitPeerIdOverride(serializedReq, effectivePinnedPeer ?? undefined));
|
|
3186
|
+
if (useModelRouter && systemRoutedAsAlias) {
|
|
3187
|
+
serializedReq = withRoutedModel(serializedReq, ROUTED_MODEL_ALIAS);
|
|
3188
|
+
}
|
|
3189
|
+
// A request without `routing` uses the saved router settings (options,
|
|
3190
|
+
// model allow list) when it pins the router those settings belong to;
|
|
3191
|
+
// a request's own `routing` options override the saved ones key by key.
|
|
3192
|
+
const savedRouter = this._defaultRouter;
|
|
3193
|
+
const savedForPinned = pinnedRouter && savedRouter && sameRoutingServiceTarget(savedRouter.service, pinnedRouter.service)
|
|
3194
|
+
? structuredClone(savedRouter)
|
|
3195
|
+
: null;
|
|
3196
|
+
const baseSelection = pinnedRouter ? savedForPinned ?? pinnedRouter : routerSelection;
|
|
3197
|
+
const activeSelection = baseSelection && Object.keys(routingOptions).length > 0
|
|
3198
|
+
? { ...baseSelection, options: { ...baseSelection.options, ...routingOptions } }
|
|
3199
|
+
: baseSelection;
|
|
3200
|
+
const context = {
|
|
3201
|
+
request: serializedReq, requiredParameters, requestPolicy, signal: clientAbortController.signal,
|
|
3202
|
+
systemRoutedModel, conversationIdentity, conversationBody, storedConversation,
|
|
3203
|
+
effectivePinnedPeer, effectiveRoutedModel, chatPinnedModel,
|
|
3204
|
+
preferredConversationPeerId,
|
|
3205
|
+
// Cache-reuse observations only feed a router, so skip them when none is configured.
|
|
3206
|
+
routingConversationKey: activeSelection ? routingConversationKey : null,
|
|
3207
|
+
spendAttributionTag,
|
|
3208
|
+
};
|
|
3209
|
+
// Both paths return an inference answer; only the router path buys a recommendation first.
|
|
3210
|
+
if (activeSelection && useModelRouter) {
|
|
3211
|
+
if (invalidRouting) {
|
|
3212
|
+
sendJson(res, 400, { error: { type: 'invalid_request_error', code: 'invalid_routing', message: invalidRouting, param: 'routing' } });
|
|
3213
|
+
return;
|
|
3214
|
+
}
|
|
3215
|
+
const refusal = await this._routerRefusal(activeSelection, requestPolicy);
|
|
3216
|
+
if (refusal) {
|
|
3217
|
+
log(`Routing service refused before ranking: ${refusal.error.message}`);
|
|
3218
|
+
sendJson(res, 403, refusal);
|
|
3219
|
+
return;
|
|
3220
|
+
}
|
|
3221
|
+
await this._dispatchViaModelRouter(res, context, activeSelection);
|
|
3222
|
+
}
|
|
3223
|
+
else {
|
|
3224
|
+
await this._dispatchDirectModelRequest(res, context);
|
|
3225
|
+
}
|
|
3226
|
+
}
|
|
3227
|
+
/**
|
|
3228
|
+
* Buy ranked recommendations, then send each one through the normal inference path
|
|
3229
|
+
* until one succeeds. A failed exact peer falls back to the next recommendation.
|
|
3230
|
+
*/
|
|
3231
|
+
async _dispatchViaModelRouter(res, context, selection) {
|
|
3232
|
+
const { request, requiredParameters, requestPolicy, signal, routingConversationKey, spendAttributionTag } = context;
|
|
3233
|
+
const conversationId = this._trackRouterConversation(context);
|
|
3234
|
+
if (conversationId && selection.service)
|
|
3235
|
+
this._conversations.setLastRouter(conversationId, `${selection.service.peerId}@${selection.service.serviceId}`);
|
|
3236
|
+
let recommendations;
|
|
3237
|
+
try {
|
|
3238
|
+
const client = this._modelRoutingClient;
|
|
3239
|
+
if (!selection.service)
|
|
3240
|
+
throw new Error('Select an exact routing-service target');
|
|
3241
|
+
const peers = await this._getPeers();
|
|
3242
|
+
// The router may only recommend destinations the buyer's own policies allow.
|
|
3243
|
+
const candidates = eligibleRouterCandidates(request, peers, requiredParameters, this._routingPreferences, (policyRequest, peer) => peerAllowedByPolicy(this._node.router, policyRequest, peer)
|
|
3244
|
+
&& this._routerCandidateAllowedByRequestPolicy(requestPolicy, policyRequest, peer)
|
|
3245
|
+
&& !isCoolingDown(this._peerHealth.get(peer.peerId), this._now()));
|
|
3246
|
+
recommendations = await executeRouterSelection({
|
|
3247
|
+
node: this._node, client, request, peers, candidates,
|
|
3248
|
+
conversationKey: routingConversationKey, selection, modelsCache: this._routingModels,
|
|
3249
|
+
...(request.headers[BUYER_IDENTITY_HEADER] ? { buyerIdentity: request.headers[BUYER_IDENTITY_HEADER] } : {}),
|
|
3250
|
+
signal: AbortSignal.any([signal, AbortSignal.timeout(120_000)]),
|
|
3251
|
+
onRoutingRequest: requestId => {
|
|
3252
|
+
if (spendAttributionTag)
|
|
3253
|
+
this._spendAttribution.track(requestId, spendAttributionTag);
|
|
3254
|
+
if (conversationId)
|
|
3255
|
+
this._trackRequestConversation(requestId, conversationId, undefined, 'routing');
|
|
3256
|
+
},
|
|
3257
|
+
});
|
|
3258
|
+
}
|
|
3259
|
+
catch (error) {
|
|
3260
|
+
// Report routing failures rather than silently choosing a model for the user.
|
|
3261
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3262
|
+
if (error instanceof InvalidRouterOptionsError) {
|
|
3263
|
+
sendJson(res, 400, { error: { type: 'invalid_request_error', code: 'invalid_routing', message, param: 'routing' } });
|
|
3264
|
+
return;
|
|
3265
|
+
}
|
|
3266
|
+
writeDispatchFailure(res, error instanceof NoEligibleCandidatesError
|
|
3267
|
+
? dispatchFailure(422, { 'content-type': 'application/json' }, JSON.stringify({
|
|
3268
|
+
error: { type: 'invalid_request_error', code: 'no_eligible_candidates', message },
|
|
3269
|
+
}))
|
|
3270
|
+
: dispatchFailure(502, { 'content-type': 'application/json' }, JSON.stringify({
|
|
3271
|
+
error: { type: 'routing_error', message },
|
|
3272
|
+
})));
|
|
3273
|
+
return;
|
|
3274
|
+
}
|
|
3275
|
+
let lastFailure = null;
|
|
3276
|
+
for (const recommendation of recommendations) {
|
|
3277
|
+
// Never switch sellers once the user has disconnected or a response has started.
|
|
3278
|
+
if (signal.aborted || res.headersSent || res.writableEnded)
|
|
3279
|
+
return;
|
|
3280
|
+
// Each attempt has its own billing ID, but all attempts count as one chat turn.
|
|
3281
|
+
const attempt = requestForRecommendation(request, recommendation, randomUUID());
|
|
3282
|
+
if (spendAttributionTag)
|
|
3283
|
+
this._spendAttribution.track(attempt.requestId, spendAttributionTag);
|
|
3284
|
+
if (conversationId)
|
|
3285
|
+
this._trackRequestConversation(attempt.requestId, conversationId, request.requestId);
|
|
3286
|
+
const result = await this._dispatchInference(res, context, attempt, conversationId);
|
|
3287
|
+
if (result.done)
|
|
3288
|
+
return;
|
|
3289
|
+
lastFailure = result;
|
|
3290
|
+
// Only try the next recommendation after a clear seller refusal, never after an ambiguous
|
|
3291
|
+
// transport failure or timeout (the seller may have done paid work) or a buyer-side fault.
|
|
3292
|
+
if (result.errorMessage !== null || isBuyerFault(result) || !RECOMMENDATION_FALLBACK_STATUS_CODES.has(result.statusCode))
|
|
3293
|
+
break;
|
|
3294
|
+
log(`Recommendation ${recommendation.peerId ? `${recommendation.peerId.slice(0, 12)}...@` : ''}${recommendation.serviceId} failed (${result.statusCode}); trying the next recommendation.`);
|
|
3295
|
+
}
|
|
3296
|
+
if (signal.aborted)
|
|
3297
|
+
return;
|
|
3298
|
+
writeDispatchFailure(res, lastFailure ?? dispatchFailure(502, { 'content-type': 'application/json' }, JSON.stringify({
|
|
3299
|
+
error: { type: 'routing_error', message: 'No recommended destination could serve this request' },
|
|
3300
|
+
})));
|
|
3301
|
+
}
|
|
3302
|
+
/**
|
|
3303
|
+
* The router selection a `<peerId>@<serviceId>` model names, when that
|
|
3304
|
+
* peer's routing service is `<serviceId>`. A peer missing from the cache
|
|
3305
|
+
* gets one refresh first, so a router that just came online is not taken
|
|
3306
|
+
* for a seller. Still missing, the pin goes down the seller-pin path, which
|
|
3307
|
+
* reports the peer as unreachable.
|
|
3308
|
+
*/
|
|
3309
|
+
async _resolvePinnedRouter(model) {
|
|
3310
|
+
const pin = model ? parsePeerPinnedService(model) : null;
|
|
3311
|
+
if (!pin)
|
|
3312
|
+
return null;
|
|
3313
|
+
let peers = await this._getPeers();
|
|
3314
|
+
if (!peers.some((peer) => peer.peerId.toLowerCase() === pin.peerId)) {
|
|
3315
|
+
peers = await this._getPeers({ forceRefresh: true });
|
|
3316
|
+
}
|
|
3317
|
+
const service = findPinnedRoutingService(peers, pin.peerId, pin.service);
|
|
3318
|
+
return service ? { kind: 'router', service } : null;
|
|
3319
|
+
}
|
|
3320
|
+
/**
|
|
3321
|
+
* Why a routing service may not be paid, the way a pinned seller is judged:
|
|
3322
|
+
* the gateway policy's model allow list (the router's service id counts as
|
|
3323
|
+
* its model), its seller rules and strict model routes, and the buyer's
|
|
3324
|
+
* hard limits, all through `routingServicePolicy`. Empty when allowed.
|
|
3325
|
+
*/
|
|
3326
|
+
_routerExclusionReasons(peer, serviceId, requestPolicy) {
|
|
3327
|
+
const facts = this._policyFacts(peer, null);
|
|
3328
|
+
const reasons = requestPolicy ? pinnedPeerExclusionReasons(routingServicePolicy(requestPolicy), facts, serviceId) : [];
|
|
3329
|
+
if (requestPolicy && !policyAllowsModel(this._effectivePolicy(requestPolicy), serviceId))
|
|
3330
|
+
reasons.unshift('model not allowed');
|
|
3331
|
+
addBuyerConfigReasons(reasons, policyExclusionReasons(routingServicePolicy(this._buyerHardPolicy()), facts));
|
|
3332
|
+
return reasons;
|
|
3333
|
+
}
|
|
3334
|
+
/** A 403 body when the policy forbids paying this router for a ranking; null when allowed or its peer is unknown. */
|
|
3335
|
+
async _routerRefusal(selection, requestPolicy) {
|
|
3336
|
+
const service = selection.service;
|
|
3337
|
+
if (!service)
|
|
3338
|
+
return null;
|
|
3339
|
+
const peer = (await this._getPeers()).find((candidate) => candidate.peerId.toLowerCase() === service.peerId.toLowerCase());
|
|
3340
|
+
if (!peer)
|
|
3341
|
+
return null;
|
|
3342
|
+
const reasons = this._routerExclusionReasons(peer, service.serviceId, requestPolicy);
|
|
3343
|
+
if (reasons.length === 0)
|
|
3344
|
+
return null;
|
|
3345
|
+
const label = `${service.peerId.slice(0, 12)}...@${service.serviceId}`;
|
|
3346
|
+
if (reasons[0] === 'model not allowed') {
|
|
3347
|
+
return { error: { type: 'permission_error', code: 'model_not_allowed', param: 'model',
|
|
3348
|
+
message: `Routing service "${service.serviceId}" is not allowed by the routing policy.` } };
|
|
3349
|
+
}
|
|
3350
|
+
return { error: { type: 'permission_error', code: 'peer_not_allowed',
|
|
3351
|
+
message: `Routing service ${label} is not allowed by the routing policy (${reasons.join('; ')}).` } };
|
|
3352
|
+
}
|
|
3353
|
+
_routerCandidateAllowedByRequestPolicy(requestPolicy, request, peer) {
|
|
3354
|
+
if (!requestPolicy)
|
|
3355
|
+
return true;
|
|
3356
|
+
const provider = getExplicitProviderOverride(request);
|
|
3357
|
+
const serviceId = extractRequestedService(request);
|
|
3358
|
+
const offer = provider && serviceId ? findAdvertisedServiceOffer(peer, provider, serviceId) : null;
|
|
3359
|
+
return offer !== null
|
|
3360
|
+
&& this._policyAllowsOffer(requestPolicy, peer, offer)
|
|
3361
|
+
&& policyExclusionReasons(this._buyerHardPolicy(), this._policyFacts(peer, offer)).length === 0;
|
|
3362
|
+
}
|
|
3363
|
+
/** Track a router-served chat before buying its recommendation, so routing costs are attributed. */
|
|
3364
|
+
_trackRouterConversation(context) {
|
|
3365
|
+
const { request, conversationIdentity, conversationBody, storedConversation } = context;
|
|
3366
|
+
if (!conversationIdentity?.isUserThread)
|
|
3367
|
+
return storedConversation?.id ?? null;
|
|
3368
|
+
if (!storedConversation && isTitleGenerationRequest(conversationBody))
|
|
3369
|
+
return null;
|
|
3370
|
+
const { id } = this._conversations.touch({
|
|
3371
|
+
tool: conversationIdentity.tool,
|
|
3372
|
+
sessionKey: conversationIdentity.parentSessionKey ?? conversationIdentity.sessionKey,
|
|
3373
|
+
snippet: storedConversation?.snippet ? null : extractFirstUserSnippet(conversationBody),
|
|
3374
|
+
lastModel: null,
|
|
3375
|
+
});
|
|
3376
|
+
// Inference attempts link to this ID so fallbacks count as one chat turn.
|
|
3377
|
+
this._trackRequestConversation(request.requestId, id);
|
|
3378
|
+
return id;
|
|
3379
|
+
}
|
|
3380
|
+
/** Apply model aliases and chat pins, then attach inference to its conversation for accounting. */
|
|
3381
|
+
_prepareInferenceRequest(res, context) {
|
|
3382
|
+
const { effectiveRoutedModel, chatPinnedModel, systemRoutedModel, storedConversation, conversationIdentity, conversationBody } = context;
|
|
3383
|
+
let serializedReq = context.request;
|
|
2957
3384
|
let trackedConversationId = storedConversation?.id ?? null;
|
|
2958
3385
|
// Resolve the `antseed` model alias to the session's default route first,
|
|
2959
3386
|
// so the regular `<peerId>@<service>` pin rewrite below picks up the
|
|
@@ -2972,7 +3399,7 @@ export class BuyerProxy {
|
|
|
2972
3399
|
param: 'model',
|
|
2973
3400
|
},
|
|
2974
3401
|
}));
|
|
2975
|
-
return;
|
|
3402
|
+
return null;
|
|
2976
3403
|
}
|
|
2977
3404
|
if (aliasResult.substituted) {
|
|
2978
3405
|
serializedReq = { ...serializedReq, body: aliasResult.body, headers: aliasResult.headers };
|
|
@@ -3035,10 +3462,32 @@ export class BuyerProxy {
|
|
|
3035
3462
|
this._trackRequestConversation(serializedReq.requestId, tracked.id);
|
|
3036
3463
|
}
|
|
3037
3464
|
}
|
|
3465
|
+
return { request: serializedReq, conversationId: trackedConversationId };
|
|
3466
|
+
}
|
|
3467
|
+
/** Send a fixed-model or pinned-seller request without buying a model recommendation. */
|
|
3468
|
+
async _dispatchDirectModelRequest(res, context) {
|
|
3469
|
+
const prepared = this._prepareInferenceRequest(res, context);
|
|
3470
|
+
if (!prepared)
|
|
3471
|
+
return;
|
|
3472
|
+
// No router chose this turn's model.
|
|
3473
|
+
if (prepared.conversationId)
|
|
3474
|
+
this._conversations.setLastRouter(prepared.conversationId, null);
|
|
3475
|
+
const result = await this._dispatchInference(res, context, prepared.request, prepared.conversationId);
|
|
3476
|
+
if (!result.done)
|
|
3477
|
+
writeDispatchFailure(res, result);
|
|
3478
|
+
}
|
|
3479
|
+
/**
|
|
3480
|
+
* Select a seller for the requested model (or the pinned seller) and send the request.
|
|
3481
|
+
* Success is written to `res`; failures are returned so callers can fall back or report them.
|
|
3482
|
+
*/
|
|
3483
|
+
async _dispatchInference(res, context, request, trackedConversationId) {
|
|
3038
3484
|
// A chat switched while this request is in flight must keep the new route.
|
|
3039
3485
|
const conversationPinAtRouting = trackedConversationId
|
|
3040
3486
|
? this._conversations.get(trackedConversationId)?.pinnedModel ?? null
|
|
3041
3487
|
: null;
|
|
3488
|
+
const nativeVideo = nativeVideoRoute(request);
|
|
3489
|
+
let serializedReq = request;
|
|
3490
|
+
const { requiredParameters, requestPolicy, signal, effectivePinnedPeer, preferredConversationPeerId, routingConversationKey } = context;
|
|
3042
3491
|
const { body: servicePinBody, headers: servicePinHeaders, pinnedPeerId: bodyPinnedPeer, } = rewritePeerPinnedServiceInBody(serializedReq.body, serializedReq.headers);
|
|
3043
3492
|
if (servicePinBody !== serializedReq.body) {
|
|
3044
3493
|
serializedReq = { ...serializedReq, body: servicePinBody, headers: servicePinHeaders };
|
|
@@ -3046,24 +3495,6 @@ export class BuyerProxy {
|
|
|
3046
3495
|
log(`Model peer pin applied: peer=${bodyPinnedPeer.slice(0, 12)}...`);
|
|
3047
3496
|
}
|
|
3048
3497
|
}
|
|
3049
|
-
const clientAbortController = new AbortController();
|
|
3050
|
-
const onClientAbort = () => {
|
|
3051
|
-
if (clientAbortController.signal.aborted) {
|
|
3052
|
-
return;
|
|
3053
|
-
}
|
|
3054
|
-
clientAbortController.abort();
|
|
3055
|
-
log(`Client disconnected; aborting upstream request reqId=${serializedReq.requestId.slice(0, 8)}`);
|
|
3056
|
-
};
|
|
3057
|
-
req.once('close', () => {
|
|
3058
|
-
if (!req.complete && !res.writableEnded) {
|
|
3059
|
-
onClientAbort();
|
|
3060
|
-
}
|
|
3061
|
-
});
|
|
3062
|
-
res.once('close', () => {
|
|
3063
|
-
if (!res.writableEnded) {
|
|
3064
|
-
onClientAbort();
|
|
3065
|
-
}
|
|
3066
|
-
});
|
|
3067
3498
|
const requestProtocol = detectRequestServiceApiProtocol(serializedReq);
|
|
3068
3499
|
const requestedService = extractRequestedService(serializedReq);
|
|
3069
3500
|
log(`Routing: protocol=${requestProtocol ?? 'null'} service=${requestedService ?? 'null'}`);
|
|
@@ -3078,8 +3509,7 @@ export class BuyerProxy {
|
|
|
3078
3509
|
+ ' • Model name prefix: <peerId>@<model>\n'
|
|
3079
3510
|
+ ' • Session pin: antseed buyer connection set --peer <peerId>\n'
|
|
3080
3511
|
+ 'Discover peers with: antseed network browse';
|
|
3081
|
-
|
|
3082
|
-
res.end(JSON.stringify({
|
|
3512
|
+
return dispatchFailure(400, { 'content-type': 'application/json' }, JSON.stringify({
|
|
3083
3513
|
error: {
|
|
3084
3514
|
type: 'missing_routing_target',
|
|
3085
3515
|
code: 'missing_routing_target',
|
|
@@ -3093,26 +3523,22 @@ export class BuyerProxy {
|
|
|
3093
3523
|
},
|
|
3094
3524
|
},
|
|
3095
3525
|
}));
|
|
3096
|
-
return;
|
|
3097
3526
|
}
|
|
3098
3527
|
if (requestPolicy && !policyAllowsModel(this._effectivePolicy(requestPolicy), requestedService)) {
|
|
3099
|
-
|
|
3528
|
+
return dispatchFailure(403, { 'content-type': 'application/json' }, JSON.stringify({
|
|
3100
3529
|
error: {
|
|
3101
3530
|
type: 'permission_error',
|
|
3102
3531
|
code: 'model_not_allowed',
|
|
3103
3532
|
message: `Model "${requestedService ?? 'unknown'}" is not allowed by the routing policy.`,
|
|
3104
3533
|
param: 'model',
|
|
3105
3534
|
},
|
|
3106
|
-
});
|
|
3107
|
-
return;
|
|
3535
|
+
}));
|
|
3108
3536
|
}
|
|
3109
3537
|
// Discover peers
|
|
3110
3538
|
const peers = await this._getPeers();
|
|
3111
3539
|
if (peers.length === 0) {
|
|
3112
3540
|
log('No sellers available');
|
|
3113
|
-
|
|
3114
|
-
res.end('No sellers available on the network. Is a seeder running?');
|
|
3115
|
-
return;
|
|
3541
|
+
return dispatchFailure(502, { 'content-type': 'text/plain' }, 'No sellers available on the network. Is a seeder running?');
|
|
3116
3542
|
}
|
|
3117
3543
|
if (!explicitPeerId && requestedService) {
|
|
3118
3544
|
const selectModelPeers = (candidateSources) => selectCandidatePeersForRouting(candidateSources, requestProtocol, requestedService, explicitProvider, 'strict');
|
|
@@ -3147,8 +3573,7 @@ export class BuyerProxy {
|
|
|
3147
3573
|
? ` "${requestedService}" is a decision model; call POST /v1/systemone.`
|
|
3148
3574
|
: '';
|
|
3149
3575
|
log(`Request rejected: model ${requestedService} is served only via ${advertisedProtocols.join(', ')}`);
|
|
3150
|
-
|
|
3151
|
-
res.end(JSON.stringify({
|
|
3576
|
+
return dispatchFailure(400, { 'content-type': 'application/json' }, JSON.stringify({
|
|
3152
3577
|
error: {
|
|
3153
3578
|
type: 'unsupported_protocol',
|
|
3154
3579
|
code: 'unsupported_protocol',
|
|
@@ -3157,10 +3582,8 @@ export class BuyerProxy {
|
|
|
3157
3582
|
supported_protocols: advertisedProtocols,
|
|
3158
3583
|
},
|
|
3159
3584
|
}));
|
|
3160
|
-
return;
|
|
3161
3585
|
}
|
|
3162
|
-
|
|
3163
|
-
res.end(JSON.stringify({
|
|
3586
|
+
return dispatchFailure(capabilityRequired ? 422 : 502, { 'content-type': 'application/json' }, JSON.stringify({
|
|
3164
3587
|
error: capabilityRequired
|
|
3165
3588
|
? {
|
|
3166
3589
|
type: 'required_capability_unavailable',
|
|
@@ -3175,7 +3598,6 @@ export class BuyerProxy {
|
|
|
3175
3598
|
param: 'model',
|
|
3176
3599
|
},
|
|
3177
3600
|
}));
|
|
3178
|
-
return;
|
|
3179
3601
|
}
|
|
3180
3602
|
let lastRetry = null;
|
|
3181
3603
|
let lastVerificationError = null;
|
|
@@ -3187,8 +3609,8 @@ export class BuyerProxy {
|
|
|
3187
3609
|
if (unfunded && !isFree(selected))
|
|
3188
3610
|
continue;
|
|
3189
3611
|
if (this._verifier) {
|
|
3190
|
-
const makeReach = (chosenId) => makeVerifierReach(this._node, selected.peer, chosenId,
|
|
3191
|
-
const outcome = await this._verifyPeer(selected.peer, makeReach,
|
|
3612
|
+
const makeReach = (chosenId) => makeVerifierReach(this._node, selected.peer, chosenId, signal);
|
|
3613
|
+
const outcome = await this._verifyPeer(selected.peer, makeReach, signal, requireVerified);
|
|
3192
3614
|
if (!outcome.ok) {
|
|
3193
3615
|
lastVerificationError = `Peer ${selected.peer.peerId.slice(0, 12)}... failed required verification (${outcome.reason ?? 'failed'}).`;
|
|
3194
3616
|
log(`${lastVerificationError} Trying the next model peer.`);
|
|
@@ -3200,33 +3622,28 @@ export class BuyerProxy {
|
|
|
3200
3622
|
+ `service="${selected.serviceId}" reputation=${selected.reputation} `
|
|
3201
3623
|
+ `effective=${selected.effectiveReputationScore ?? 'unknown'} peer=${index + 1}/${candidates.length} `
|
|
3202
3624
|
+ `attempt=${peerAttempt + 1}/${MODEL_RATE_LIMIT_MAX_ATTEMPTS_PER_PEER}`);
|
|
3203
|
-
const result = await this._dispatchToPeer(res, selected.request, selected.peer, modelPlans, requestProtocol, selected.serviceId, explicitProvider, router, false,
|
|
3625
|
+
const result = await this._dispatchToPeer(res, selected.request, selected.peer, modelPlans, requestProtocol, selected.serviceId, explicitProvider, router, false, signal, requestPolicy !== null, routingConversationKey);
|
|
3204
3626
|
if (result.done) {
|
|
3205
3627
|
// Only a seller that answered becomes the chat's route: an error
|
|
3206
3628
|
// must not pin later turns to it.
|
|
3207
3629
|
if (trackedConversationId && res.statusCode < 400) {
|
|
3208
3630
|
this._conversations.recordRoutedModel(trackedConversationId, `${selected.peer.peerId}@${selected.serviceId}`, conversationPinAtRouting);
|
|
3209
3631
|
}
|
|
3210
|
-
return;
|
|
3632
|
+
return result;
|
|
3211
3633
|
}
|
|
3212
3634
|
// Never retry a video on another seller: a create may already have
|
|
3213
3635
|
// started (and been charged), and follow-ups only exist on one seller.
|
|
3214
|
-
if (nativeVideo)
|
|
3215
|
-
|
|
3216
|
-
res.end(result.responseBody);
|
|
3217
|
-
return;
|
|
3218
|
-
}
|
|
3636
|
+
if (nativeVideo)
|
|
3637
|
+
return result;
|
|
3219
3638
|
lastRetry = result;
|
|
3220
|
-
if (result
|
|
3639
|
+
if (isBuyerFault(result)) {
|
|
3221
3640
|
const freeLeft = candidates.slice(index + 1).some(isFree);
|
|
3222
3641
|
if (result.statusCode === 402 && freeLeft) {
|
|
3223
3642
|
unfunded = true;
|
|
3224
3643
|
log(`No deposits to pay peer ${selected.peer.peerId.slice(0, 12)}...; trying free sellers only.`);
|
|
3225
3644
|
break;
|
|
3226
3645
|
}
|
|
3227
|
-
|
|
3228
|
-
res.end(result.responseBody);
|
|
3229
|
-
return;
|
|
3646
|
+
return result;
|
|
3230
3647
|
}
|
|
3231
3648
|
const retrySamePeer = result.statusCode === 429
|
|
3232
3649
|
&& peerAttempt + 1 < MODEL_RATE_LIMIT_MAX_ATTEMPTS_PER_PEER;
|
|
@@ -3234,28 +3651,20 @@ export class BuyerProxy {
|
|
|
3234
3651
|
break;
|
|
3235
3652
|
const delayMs = rateLimitRetryDelayMs(result.responseHeaders, peerAttempt);
|
|
3236
3653
|
log(`Peer ${selected.peer.peerId.slice(0, 12)}... is rate-limited; retrying in ${delayMs}ms.`);
|
|
3237
|
-
if (!await waitForRetry(delayMs,
|
|
3238
|
-
return;
|
|
3654
|
+
if (!await waitForRetry(delayMs, signal))
|
|
3655
|
+
return { done: true };
|
|
3239
3656
|
}
|
|
3240
3657
|
if (index + 1 < candidates.length) {
|
|
3241
3658
|
log(`Peer ${selected.peer.peerId.slice(0, 12)}... failed retryably; trying next model peer.`);
|
|
3242
3659
|
}
|
|
3243
3660
|
}
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
error: {
|
|
3252
|
-
type: 'peer_verification_failed',
|
|
3253
|
-
code: 'peer_verification_failed',
|
|
3254
|
-
message: lastVerificationError ?? `No verified peer currently serves model "${requestedService}".`,
|
|
3255
|
-
},
|
|
3256
|
-
}));
|
|
3257
|
-
}
|
|
3258
|
-
return;
|
|
3661
|
+
return lastRetry ?? dispatchFailure(502, { 'content-type': 'application/json' }, JSON.stringify({
|
|
3662
|
+
error: {
|
|
3663
|
+
type: 'peer_verification_failed',
|
|
3664
|
+
code: 'peer_verification_failed',
|
|
3665
|
+
message: lastVerificationError ?? `No verified peer currently serves model "${requestedService}".`,
|
|
3666
|
+
},
|
|
3667
|
+
}));
|
|
3259
3668
|
}
|
|
3260
3669
|
const pinnedPeerId = explicitPeerId;
|
|
3261
3670
|
// Narrow the candidate set to just the pinned peer (if we already know
|
|
@@ -3303,12 +3712,12 @@ export class BuyerProxy {
|
|
|
3303
3712
|
: '--peer flag or session pin';
|
|
3304
3713
|
const diagnostics = this._formatPeerSelectionDiagnostics(discoveredPeers);
|
|
3305
3714
|
log(`Pinned peer ${pinnedPeerId.slice(0, 12)}... not discoverable in DHT (${logSource})`);
|
|
3306
|
-
|
|
3307
|
-
|
|
3715
|
+
// Also the answer for a `<peerId>@<routingServiceId>` pin whose router
|
|
3716
|
+
// is offline: an absent peer cannot be told apart from a seller.
|
|
3717
|
+
return dispatchFailure(502, { 'content-type': 'text/plain' }, `Pinned peer ${pinnedPeerId.slice(0, 12)}... (seller or routing service) is not reachable right now. `
|
|
3308
3718
|
+ 'It may be offline, not announcing, or temporarily unreachable. '
|
|
3309
3719
|
+ 'Pick a different service in Discover or try again later. '
|
|
3310
3720
|
+ diagnostics);
|
|
3311
|
-
return;
|
|
3312
3721
|
}
|
|
3313
3722
|
if (routingPeers.length === 0) {
|
|
3314
3723
|
const pinnedPeer = discoveredPeers.find((peer) => peer.peerId.toLowerCase() === pinnedPeerId) ?? null;
|
|
@@ -3323,20 +3732,16 @@ export class BuyerProxy {
|
|
|
3323
3732
|
if (!providers.includes(explicitProvider)) {
|
|
3324
3733
|
const providerList = providers.length > 0 ? providers.join(', ') : 'none';
|
|
3325
3734
|
log(`Pinned peer ${pinnedPeerId.slice(0, 12)}... does not offer explicit provider=${explicitProvider}`);
|
|
3326
|
-
|
|
3327
|
-
res.end(`Pinned peer ${pinnedPeerId.slice(0, 12)}... does not offer provider=${explicitProvider}. `
|
|
3735
|
+
return dispatchFailure(502, { 'content-type': 'text/plain' }, `Pinned peer ${pinnedPeerId.slice(0, 12)}... does not offer provider=${explicitProvider}. `
|
|
3328
3736
|
+ `Available providers: ${providerList}. `
|
|
3329
3737
|
+ 'Remove or change the x-antseed-provider header, or pick a different peer. '
|
|
3330
3738
|
+ diagnostics);
|
|
3331
|
-
return;
|
|
3332
3739
|
}
|
|
3333
3740
|
}
|
|
3334
3741
|
log(`Pinned peer ${pinnedPeerId.slice(0, 12)}... filtered out by protocol/service match`);
|
|
3335
|
-
|
|
3336
|
-
res.end(`Pinned peer ${pinnedPeerId.slice(0, 12)}... does not support this request `
|
|
3742
|
+
return dispatchFailure(502, { 'content-type': 'text/plain' }, `Pinned peer ${pinnedPeerId.slice(0, 12)}... does not support this request `
|
|
3337
3743
|
+ `(${protocolLabel}, ${providerLabel}, ${serviceLabel}). `
|
|
3338
3744
|
+ `Pick a different service in Discover. ${diagnostics}`);
|
|
3339
|
-
return;
|
|
3340
3745
|
}
|
|
3341
3746
|
log(`Routing candidates: ${routingPeers.length} peer(s)`);
|
|
3342
3747
|
const router = this._node.router;
|
|
@@ -3346,9 +3751,7 @@ export class BuyerProxy {
|
|
|
3346
3751
|
// if an invariant breaks.
|
|
3347
3752
|
if (!selectedPeer) {
|
|
3348
3753
|
log(`Invariant: pinned peer ${pinnedPeerId.slice(0, 12)}... present in DHT but missing from narrowed candidate list`);
|
|
3349
|
-
|
|
3350
|
-
res.end(`Pinned peer ${pinnedPeerId.slice(0, 12)}... is currently unreachable. Try again in a moment.`);
|
|
3351
|
-
return;
|
|
3754
|
+
return dispatchFailure(502, { 'content-type': 'text/plain' }, `Pinned peer ${pinnedPeerId.slice(0, 12)}... is currently unreachable. Try again in a moment.`);
|
|
3352
3755
|
}
|
|
3353
3756
|
const policyRouter = router;
|
|
3354
3757
|
const selectedPlan = routingPlans.get(selectedPeer.peerId)
|
|
@@ -3358,8 +3761,7 @@ export class BuyerProxy {
|
|
|
3358
3761
|
? findMissingRequiredParameters(selectedPeer, selectedPlan.provider, pinnedServiceId, requiredParameters)
|
|
3359
3762
|
: requiredParameters;
|
|
3360
3763
|
if (missingRequired.length > 0) {
|
|
3361
|
-
|
|
3362
|
-
res.end(JSON.stringify({
|
|
3764
|
+
return dispatchFailure(422, { 'content-type': 'application/json' }, JSON.stringify({
|
|
3363
3765
|
error: {
|
|
3364
3766
|
type: 'required_capability_unavailable',
|
|
3365
3767
|
code: 'required_capability_unavailable',
|
|
@@ -3367,7 +3769,6 @@ export class BuyerProxy {
|
|
|
3367
3769
|
param: REQUIRED_PARAMETERS_HEADER,
|
|
3368
3770
|
},
|
|
3369
3771
|
}));
|
|
3370
|
-
return;
|
|
3371
3772
|
}
|
|
3372
3773
|
const pinnedRequest = pinnedServiceId ? withRoutedModel(serializedReq, pinnedServiceId) : serializedReq;
|
|
3373
3774
|
// A hard pin cannot escape the gateway's policy: an excluded seller is
|
|
@@ -3385,26 +3786,23 @@ export class BuyerProxy {
|
|
|
3385
3786
|
addBuyerConfigReasons(reasons, policyExclusionReasons(this._buyerHardPolicy(), facts));
|
|
3386
3787
|
if (reasons.length > 0) {
|
|
3387
3788
|
log(`Pinned peer ${selectedPeer.peerId.slice(0, 12)}... refused by routing policy: ${reasons.join('; ')}`);
|
|
3388
|
-
|
|
3789
|
+
return dispatchFailure(403, { 'content-type': 'application/json' }, JSON.stringify({
|
|
3389
3790
|
error: {
|
|
3390
3791
|
type: 'permission_error',
|
|
3391
3792
|
code: 'peer_not_allowed',
|
|
3392
3793
|
message: `Seller ${selectedPeer.peerId.slice(0, 12)}... is not allowed by the routing policy (${reasons.join('; ')}).`,
|
|
3393
3794
|
},
|
|
3394
|
-
});
|
|
3395
|
-
return;
|
|
3795
|
+
}));
|
|
3396
3796
|
}
|
|
3397
3797
|
}
|
|
3398
3798
|
if (!peerAllowedByPolicy(policyRouter, pinnedRequest, selectedPeer)) {
|
|
3399
3799
|
log(`Pinned peer ${selectedPeer.peerId.slice(0, 12)}... filtered out by buyer routing policy`);
|
|
3400
|
-
|
|
3401
|
-
res.end(`Pinned peer ${selectedPeer.peerId.slice(0, 12)}... is outside your buyer routing policy. `
|
|
3800
|
+
return dispatchFailure(502, { 'content-type': 'text/plain' }, `Pinned peer ${selectedPeer.peerId.slice(0, 12)}... is outside your buyer routing policy. `
|
|
3402
3801
|
+ 'Pick a different service in Discover or adjust your buyer pricing/reputation limits.');
|
|
3403
|
-
return;
|
|
3404
3802
|
}
|
|
3405
3803
|
if (this._verifier) {
|
|
3406
|
-
const makeReach = (chosenId) => makeVerifierReach(this._node, selectedPeer, chosenId,
|
|
3407
|
-
const outcome = await this._verifyPeer(selectedPeer, makeReach,
|
|
3804
|
+
const makeReach = (chosenId) => makeVerifierReach(this._node, selectedPeer, chosenId, signal);
|
|
3805
|
+
const outcome = await this._verifyPeer(selectedPeer, makeReach, signal, pinnedPolicy?.requireVerified === true);
|
|
3408
3806
|
const short = selectedPeer.peerId.slice(0, 12);
|
|
3409
3807
|
if (outcome.verified) {
|
|
3410
3808
|
log(`Verified ${short}... via ${outcome.sdk}`);
|
|
@@ -3413,23 +3811,17 @@ export class BuyerProxy {
|
|
|
3413
3811
|
log(`Verification ${outcome.sdk ? `(${outcome.sdk}) ` : ''}did not pass for ${short}...: ${outcome.reason ?? 'failed'}${outcome.ok ? ' (optional — routing anyway)' : ''}`);
|
|
3414
3812
|
}
|
|
3415
3813
|
if (!outcome.ok) {
|
|
3416
|
-
|
|
3417
|
-
res.end(`Pinned peer ${short}... failed required verification (${outcome.reason ?? 'failed'}). `
|
|
3814
|
+
return dispatchFailure(502, { 'content-type': 'text/plain' }, `Pinned peer ${short}... failed required verification (${outcome.reason ?? 'failed'}). `
|
|
3418
3815
|
+ 'Pick a different peer, or run without --require-verifier.');
|
|
3419
|
-
return;
|
|
3420
3816
|
}
|
|
3421
3817
|
}
|
|
3422
3818
|
log(`Using pinned peer ${selectedPeer.peerId.slice(0, 12)}...`);
|
|
3423
|
-
const result = await this._dispatchToPeer(res, pinnedRequest, selectedPeer, routingPlans, requestProtocol, requestedService, explicitProvider, router, true,
|
|
3819
|
+
const result = await this._dispatchToPeer(res, pinnedRequest, selectedPeer, routingPlans, requestProtocol, requestedService, explicitProvider, router, true, signal, false, routingConversationKey);
|
|
3424
3820
|
if (result.done && trackedConversationId && pinnedServiceId) {
|
|
3425
3821
|
this._conversations.recordRoutedModel(trackedConversationId, `${selectedPeer.peerId}@${pinnedServiceId}`, conversationPinAtRouting);
|
|
3426
3822
|
}
|
|
3427
|
-
|
|
3428
|
-
|
|
3429
|
-
// peer for an explicit pin, so surface the error to the client.
|
|
3430
|
-
res.writeHead(result.statusCode, result.responseHeaders);
|
|
3431
|
-
res.end(result.responseBody);
|
|
3432
|
-
}
|
|
3823
|
+
// A pinned peer is never retried against another peer; the caller reports any failure.
|
|
3824
|
+
return result;
|
|
3433
3825
|
}
|
|
3434
3826
|
async _verifyPeer(peer, makeReach, signal, requireVerified = false) {
|
|
3435
3827
|
const configured = this._verifier;
|
|
@@ -3514,7 +3906,7 @@ export class BuyerProxy {
|
|
|
3514
3906
|
* was sent to the client (success or non-retryable error), or retry info if the
|
|
3515
3907
|
* caller should try another peer.
|
|
3516
3908
|
*/
|
|
3517
|
-
async _dispatchToPeer(res, serializedReq, selectedPeer, routePlanByPeerId, requestProtocol, requestedService, explicitProvider, router, pinned, requestSignal, gatewayRouted = false) {
|
|
3909
|
+
async _dispatchToPeer(res, serializedReq, selectedPeer, routePlanByPeerId, requestProtocol, requestedService, explicitProvider, router, pinned, requestSignal, gatewayRouted = false, routingConversationKey = null) {
|
|
3518
3910
|
const gatewayAutoRoute = gatewayRouted && !pinned;
|
|
3519
3911
|
const selectedRoutePlan = routePlanByPeerId.get(selectedPeer.peerId)
|
|
3520
3912
|
?? resolvePeerRoutePlan(selectedPeer, requestProtocol, requestedService, explicitProvider, 'lenient');
|
|
@@ -3651,6 +4043,7 @@ export class BuyerProxy {
|
|
|
3651
4043
|
}
|
|
3652
4044
|
},
|
|
3653
4045
|
}, { signal: requestSignal, pinned, ...(buyerIdentity ? { buyerIdentity } : {}) });
|
|
4046
|
+
recordRouterUsage(this._modelRoutingClient, routingConversationKey, requestForPeer, response, selectedPeer, requestSignal);
|
|
3654
4047
|
let responseForClient = adaptBuyerFaultErrorResponse(response, requestProtocol);
|
|
3655
4048
|
responseForClient = adaptPeerResponse(responseForClient);
|
|
3656
4049
|
if (!streamed
|
|
@@ -3726,6 +4119,7 @@ export class BuyerProxy {
|
|
|
3726
4119
|
if (upstreamResponse.statusCode >= 400 && !adaptResponse) {
|
|
3727
4120
|
log(`Upstream raw error detail: ${summarizeErrorResponse(upstreamResponse)}`);
|
|
3728
4121
|
}
|
|
4122
|
+
recordRouterUsage(this._modelRoutingClient, routingConversationKey, requestForPeer, upstreamResponse, selectedPeer, requestSignal);
|
|
3729
4123
|
// Only the seller that accepted a video job knows its ID. Remember and
|
|
3730
4124
|
// persist job ID -> seller so later retrieve requests (even
|
|
3731
4125
|
// after a proxy restart) are pinned back to that seller.
|