@antseed/cli 0.1.190 → 0.1.191
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/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 +509 -120
- package/dist/proxy/buyer-proxy.js.map +1 -1
- package/dist/proxy/buyer-proxy.test.js +1341 -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 +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 +9 -7
|
@@ -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,247 @@ 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
|
+
let recommendations;
|
|
3235
|
+
try {
|
|
3236
|
+
const client = this._modelRoutingClient;
|
|
3237
|
+
if (!selection.service)
|
|
3238
|
+
throw new Error('Select an exact routing-service target');
|
|
3239
|
+
const peers = await this._getPeers();
|
|
3240
|
+
// The router may only recommend destinations the buyer's own policies allow.
|
|
3241
|
+
const candidates = eligibleRouterCandidates(request, peers, requiredParameters, this._routingPreferences, (policyRequest, peer) => peerAllowedByPolicy(this._node.router, policyRequest, peer)
|
|
3242
|
+
&& this._routerCandidateAllowedByRequestPolicy(requestPolicy, policyRequest, peer)
|
|
3243
|
+
&& !isCoolingDown(this._peerHealth.get(peer.peerId), this._now()));
|
|
3244
|
+
recommendations = await executeRouterSelection({
|
|
3245
|
+
node: this._node, client, request, peers, candidates,
|
|
3246
|
+
conversationKey: routingConversationKey, selection, modelsCache: this._routingModels,
|
|
3247
|
+
...(request.headers[BUYER_IDENTITY_HEADER] ? { buyerIdentity: request.headers[BUYER_IDENTITY_HEADER] } : {}),
|
|
3248
|
+
signal: AbortSignal.any([signal, AbortSignal.timeout(120_000)]),
|
|
3249
|
+
onRoutingRequest: requestId => {
|
|
3250
|
+
if (spendAttributionTag)
|
|
3251
|
+
this._spendAttribution.track(requestId, spendAttributionTag);
|
|
3252
|
+
if (conversationId)
|
|
3253
|
+
this._trackRequestConversation(requestId, conversationId, undefined, 'routing');
|
|
3254
|
+
},
|
|
3255
|
+
});
|
|
3256
|
+
}
|
|
3257
|
+
catch (error) {
|
|
3258
|
+
// Report routing failures rather than silently choosing a model for the user.
|
|
3259
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3260
|
+
if (error instanceof InvalidRouterOptionsError) {
|
|
3261
|
+
sendJson(res, 400, { error: { type: 'invalid_request_error', code: 'invalid_routing', message, param: 'routing' } });
|
|
3262
|
+
return;
|
|
3263
|
+
}
|
|
3264
|
+
writeDispatchFailure(res, error instanceof NoEligibleCandidatesError
|
|
3265
|
+
? dispatchFailure(422, { 'content-type': 'application/json' }, JSON.stringify({
|
|
3266
|
+
error: { type: 'invalid_request_error', code: 'no_eligible_candidates', message },
|
|
3267
|
+
}))
|
|
3268
|
+
: dispatchFailure(502, { 'content-type': 'application/json' }, JSON.stringify({
|
|
3269
|
+
error: { type: 'routing_error', message },
|
|
3270
|
+
})));
|
|
3271
|
+
return;
|
|
3272
|
+
}
|
|
3273
|
+
let lastFailure = null;
|
|
3274
|
+
for (const recommendation of recommendations) {
|
|
3275
|
+
// Never switch sellers once the user has disconnected or a response has started.
|
|
3276
|
+
if (signal.aborted || res.headersSent || res.writableEnded)
|
|
3277
|
+
return;
|
|
3278
|
+
// Each attempt has its own billing ID, but all attempts count as one chat turn.
|
|
3279
|
+
const attempt = requestForRecommendation(request, recommendation, randomUUID());
|
|
3280
|
+
if (spendAttributionTag)
|
|
3281
|
+
this._spendAttribution.track(attempt.requestId, spendAttributionTag);
|
|
3282
|
+
if (conversationId)
|
|
3283
|
+
this._trackRequestConversation(attempt.requestId, conversationId, request.requestId);
|
|
3284
|
+
const result = await this._dispatchInference(res, context, attempt, conversationId);
|
|
3285
|
+
if (result.done)
|
|
3286
|
+
return;
|
|
3287
|
+
lastFailure = result;
|
|
3288
|
+
// Only try the next recommendation after a clear seller refusal, never after an ambiguous
|
|
3289
|
+
// transport failure or timeout (the seller may have done paid work) or a buyer-side fault.
|
|
3290
|
+
if (result.errorMessage !== null || isBuyerFault(result) || !RECOMMENDATION_FALLBACK_STATUS_CODES.has(result.statusCode))
|
|
3291
|
+
break;
|
|
3292
|
+
log(`Recommendation ${recommendation.peerId ? `${recommendation.peerId.slice(0, 12)}...@` : ''}${recommendation.serviceId} failed (${result.statusCode}); trying the next recommendation.`);
|
|
3293
|
+
}
|
|
3294
|
+
if (signal.aborted)
|
|
3295
|
+
return;
|
|
3296
|
+
writeDispatchFailure(res, lastFailure ?? dispatchFailure(502, { 'content-type': 'application/json' }, JSON.stringify({
|
|
3297
|
+
error: { type: 'routing_error', message: 'No recommended destination could serve this request' },
|
|
3298
|
+
})));
|
|
3299
|
+
}
|
|
3300
|
+
/**
|
|
3301
|
+
* The router selection a `<peerId>@<serviceId>` model names, when that
|
|
3302
|
+
* peer's routing service is `<serviceId>`. A peer missing from the cache
|
|
3303
|
+
* gets one refresh first, so a router that just came online is not taken
|
|
3304
|
+
* for a seller. Still missing, the pin goes down the seller-pin path, which
|
|
3305
|
+
* reports the peer as unreachable.
|
|
3306
|
+
*/
|
|
3307
|
+
async _resolvePinnedRouter(model) {
|
|
3308
|
+
const pin = model ? parsePeerPinnedService(model) : null;
|
|
3309
|
+
if (!pin)
|
|
3310
|
+
return null;
|
|
3311
|
+
let peers = await this._getPeers();
|
|
3312
|
+
if (!peers.some((peer) => peer.peerId.toLowerCase() === pin.peerId)) {
|
|
3313
|
+
peers = await this._getPeers({ forceRefresh: true });
|
|
3314
|
+
}
|
|
3315
|
+
const service = findPinnedRoutingService(peers, pin.peerId, pin.service);
|
|
3316
|
+
return service ? { kind: 'router', service } : null;
|
|
3317
|
+
}
|
|
3318
|
+
/**
|
|
3319
|
+
* Why a routing service may not be paid, the way a pinned seller is judged:
|
|
3320
|
+
* the gateway policy's model allow list (the router's service id counts as
|
|
3321
|
+
* its model), its seller rules and strict model routes, and the buyer's
|
|
3322
|
+
* hard limits, all through `routingServicePolicy`. Empty when allowed.
|
|
3323
|
+
*/
|
|
3324
|
+
_routerExclusionReasons(peer, serviceId, requestPolicy) {
|
|
3325
|
+
const facts = this._policyFacts(peer, null);
|
|
3326
|
+
const reasons = requestPolicy ? pinnedPeerExclusionReasons(routingServicePolicy(requestPolicy), facts, serviceId) : [];
|
|
3327
|
+
if (requestPolicy && !policyAllowsModel(this._effectivePolicy(requestPolicy), serviceId))
|
|
3328
|
+
reasons.unshift('model not allowed');
|
|
3329
|
+
addBuyerConfigReasons(reasons, policyExclusionReasons(routingServicePolicy(this._buyerHardPolicy()), facts));
|
|
3330
|
+
return reasons;
|
|
3331
|
+
}
|
|
3332
|
+
/** A 403 body when the policy forbids paying this router for a ranking; null when allowed or its peer is unknown. */
|
|
3333
|
+
async _routerRefusal(selection, requestPolicy) {
|
|
3334
|
+
const service = selection.service;
|
|
3335
|
+
if (!service)
|
|
3336
|
+
return null;
|
|
3337
|
+
const peer = (await this._getPeers()).find((candidate) => candidate.peerId.toLowerCase() === service.peerId.toLowerCase());
|
|
3338
|
+
if (!peer)
|
|
3339
|
+
return null;
|
|
3340
|
+
const reasons = this._routerExclusionReasons(peer, service.serviceId, requestPolicy);
|
|
3341
|
+
if (reasons.length === 0)
|
|
3342
|
+
return null;
|
|
3343
|
+
const label = `${service.peerId.slice(0, 12)}...@${service.serviceId}`;
|
|
3344
|
+
if (reasons[0] === 'model not allowed') {
|
|
3345
|
+
return { error: { type: 'permission_error', code: 'model_not_allowed', param: 'model',
|
|
3346
|
+
message: `Routing service "${service.serviceId}" is not allowed by the routing policy.` } };
|
|
3347
|
+
}
|
|
3348
|
+
return { error: { type: 'permission_error', code: 'peer_not_allowed',
|
|
3349
|
+
message: `Routing service ${label} is not allowed by the routing policy (${reasons.join('; ')}).` } };
|
|
3350
|
+
}
|
|
3351
|
+
_routerCandidateAllowedByRequestPolicy(requestPolicy, request, peer) {
|
|
3352
|
+
if (!requestPolicy)
|
|
3353
|
+
return true;
|
|
3354
|
+
const provider = getExplicitProviderOverride(request);
|
|
3355
|
+
const serviceId = extractRequestedService(request);
|
|
3356
|
+
const offer = provider && serviceId ? findAdvertisedServiceOffer(peer, provider, serviceId) : null;
|
|
3357
|
+
return offer !== null
|
|
3358
|
+
&& this._policyAllowsOffer(requestPolicy, peer, offer)
|
|
3359
|
+
&& policyExclusionReasons(this._buyerHardPolicy(), this._policyFacts(peer, offer)).length === 0;
|
|
3360
|
+
}
|
|
3361
|
+
/** Track a router-served chat before buying its recommendation, so routing costs are attributed. */
|
|
3362
|
+
_trackRouterConversation(context) {
|
|
3363
|
+
const { request, conversationIdentity, conversationBody, storedConversation } = context;
|
|
3364
|
+
if (!conversationIdentity?.isUserThread)
|
|
3365
|
+
return storedConversation?.id ?? null;
|
|
3366
|
+
if (!storedConversation && isTitleGenerationRequest(conversationBody))
|
|
3367
|
+
return null;
|
|
3368
|
+
const { id } = this._conversations.touch({
|
|
3369
|
+
tool: conversationIdentity.tool,
|
|
3370
|
+
sessionKey: conversationIdentity.parentSessionKey ?? conversationIdentity.sessionKey,
|
|
3371
|
+
snippet: storedConversation?.snippet ? null : extractFirstUserSnippet(conversationBody),
|
|
3372
|
+
lastModel: null,
|
|
3373
|
+
});
|
|
3374
|
+
// Inference attempts link to this ID so fallbacks count as one chat turn.
|
|
3375
|
+
this._trackRequestConversation(request.requestId, id);
|
|
3376
|
+
return id;
|
|
3377
|
+
}
|
|
3378
|
+
/** Apply model aliases and chat pins, then attach inference to its conversation for accounting. */
|
|
3379
|
+
_prepareInferenceRequest(res, context) {
|
|
3380
|
+
const { effectiveRoutedModel, chatPinnedModel, systemRoutedModel, storedConversation, conversationIdentity, conversationBody } = context;
|
|
3381
|
+
let serializedReq = context.request;
|
|
2957
3382
|
let trackedConversationId = storedConversation?.id ?? null;
|
|
2958
3383
|
// Resolve the `antseed` model alias to the session's default route first,
|
|
2959
3384
|
// so the regular `<peerId>@<service>` pin rewrite below picks up the
|
|
@@ -2972,7 +3397,7 @@ export class BuyerProxy {
|
|
|
2972
3397
|
param: 'model',
|
|
2973
3398
|
},
|
|
2974
3399
|
}));
|
|
2975
|
-
return;
|
|
3400
|
+
return null;
|
|
2976
3401
|
}
|
|
2977
3402
|
if (aliasResult.substituted) {
|
|
2978
3403
|
serializedReq = { ...serializedReq, body: aliasResult.body, headers: aliasResult.headers };
|
|
@@ -3035,10 +3460,29 @@ export class BuyerProxy {
|
|
|
3035
3460
|
this._trackRequestConversation(serializedReq.requestId, tracked.id);
|
|
3036
3461
|
}
|
|
3037
3462
|
}
|
|
3463
|
+
return { request: serializedReq, conversationId: trackedConversationId };
|
|
3464
|
+
}
|
|
3465
|
+
/** Send a fixed-model or pinned-seller request without buying a model recommendation. */
|
|
3466
|
+
async _dispatchDirectModelRequest(res, context) {
|
|
3467
|
+
const prepared = this._prepareInferenceRequest(res, context);
|
|
3468
|
+
if (!prepared)
|
|
3469
|
+
return;
|
|
3470
|
+
const result = await this._dispatchInference(res, context, prepared.request, prepared.conversationId);
|
|
3471
|
+
if (!result.done)
|
|
3472
|
+
writeDispatchFailure(res, result);
|
|
3473
|
+
}
|
|
3474
|
+
/**
|
|
3475
|
+
* Select a seller for the requested model (or the pinned seller) and send the request.
|
|
3476
|
+
* Success is written to `res`; failures are returned so callers can fall back or report them.
|
|
3477
|
+
*/
|
|
3478
|
+
async _dispatchInference(res, context, request, trackedConversationId) {
|
|
3038
3479
|
// A chat switched while this request is in flight must keep the new route.
|
|
3039
3480
|
const conversationPinAtRouting = trackedConversationId
|
|
3040
3481
|
? this._conversations.get(trackedConversationId)?.pinnedModel ?? null
|
|
3041
3482
|
: null;
|
|
3483
|
+
const nativeVideo = nativeVideoRoute(request);
|
|
3484
|
+
let serializedReq = request;
|
|
3485
|
+
const { requiredParameters, requestPolicy, signal, effectivePinnedPeer, preferredConversationPeerId, routingConversationKey } = context;
|
|
3042
3486
|
const { body: servicePinBody, headers: servicePinHeaders, pinnedPeerId: bodyPinnedPeer, } = rewritePeerPinnedServiceInBody(serializedReq.body, serializedReq.headers);
|
|
3043
3487
|
if (servicePinBody !== serializedReq.body) {
|
|
3044
3488
|
serializedReq = { ...serializedReq, body: servicePinBody, headers: servicePinHeaders };
|
|
@@ -3046,24 +3490,6 @@ export class BuyerProxy {
|
|
|
3046
3490
|
log(`Model peer pin applied: peer=${bodyPinnedPeer.slice(0, 12)}...`);
|
|
3047
3491
|
}
|
|
3048
3492
|
}
|
|
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
3493
|
const requestProtocol = detectRequestServiceApiProtocol(serializedReq);
|
|
3068
3494
|
const requestedService = extractRequestedService(serializedReq);
|
|
3069
3495
|
log(`Routing: protocol=${requestProtocol ?? 'null'} service=${requestedService ?? 'null'}`);
|
|
@@ -3078,8 +3504,7 @@ export class BuyerProxy {
|
|
|
3078
3504
|
+ ' • Model name prefix: <peerId>@<model>\n'
|
|
3079
3505
|
+ ' • Session pin: antseed buyer connection set --peer <peerId>\n'
|
|
3080
3506
|
+ 'Discover peers with: antseed network browse';
|
|
3081
|
-
|
|
3082
|
-
res.end(JSON.stringify({
|
|
3507
|
+
return dispatchFailure(400, { 'content-type': 'application/json' }, JSON.stringify({
|
|
3083
3508
|
error: {
|
|
3084
3509
|
type: 'missing_routing_target',
|
|
3085
3510
|
code: 'missing_routing_target',
|
|
@@ -3093,26 +3518,22 @@ export class BuyerProxy {
|
|
|
3093
3518
|
},
|
|
3094
3519
|
},
|
|
3095
3520
|
}));
|
|
3096
|
-
return;
|
|
3097
3521
|
}
|
|
3098
3522
|
if (requestPolicy && !policyAllowsModel(this._effectivePolicy(requestPolicy), requestedService)) {
|
|
3099
|
-
|
|
3523
|
+
return dispatchFailure(403, { 'content-type': 'application/json' }, JSON.stringify({
|
|
3100
3524
|
error: {
|
|
3101
3525
|
type: 'permission_error',
|
|
3102
3526
|
code: 'model_not_allowed',
|
|
3103
3527
|
message: `Model "${requestedService ?? 'unknown'}" is not allowed by the routing policy.`,
|
|
3104
3528
|
param: 'model',
|
|
3105
3529
|
},
|
|
3106
|
-
});
|
|
3107
|
-
return;
|
|
3530
|
+
}));
|
|
3108
3531
|
}
|
|
3109
3532
|
// Discover peers
|
|
3110
3533
|
const peers = await this._getPeers();
|
|
3111
3534
|
if (peers.length === 0) {
|
|
3112
3535
|
log('No sellers available');
|
|
3113
|
-
|
|
3114
|
-
res.end('No sellers available on the network. Is a seeder running?');
|
|
3115
|
-
return;
|
|
3536
|
+
return dispatchFailure(502, { 'content-type': 'text/plain' }, 'No sellers available on the network. Is a seeder running?');
|
|
3116
3537
|
}
|
|
3117
3538
|
if (!explicitPeerId && requestedService) {
|
|
3118
3539
|
const selectModelPeers = (candidateSources) => selectCandidatePeersForRouting(candidateSources, requestProtocol, requestedService, explicitProvider, 'strict');
|
|
@@ -3147,8 +3568,7 @@ export class BuyerProxy {
|
|
|
3147
3568
|
? ` "${requestedService}" is a decision model; call POST /v1/systemone.`
|
|
3148
3569
|
: '';
|
|
3149
3570
|
log(`Request rejected: model ${requestedService} is served only via ${advertisedProtocols.join(', ')}`);
|
|
3150
|
-
|
|
3151
|
-
res.end(JSON.stringify({
|
|
3571
|
+
return dispatchFailure(400, { 'content-type': 'application/json' }, JSON.stringify({
|
|
3152
3572
|
error: {
|
|
3153
3573
|
type: 'unsupported_protocol',
|
|
3154
3574
|
code: 'unsupported_protocol',
|
|
@@ -3157,10 +3577,8 @@ export class BuyerProxy {
|
|
|
3157
3577
|
supported_protocols: advertisedProtocols,
|
|
3158
3578
|
},
|
|
3159
3579
|
}));
|
|
3160
|
-
return;
|
|
3161
3580
|
}
|
|
3162
|
-
|
|
3163
|
-
res.end(JSON.stringify({
|
|
3581
|
+
return dispatchFailure(capabilityRequired ? 422 : 502, { 'content-type': 'application/json' }, JSON.stringify({
|
|
3164
3582
|
error: capabilityRequired
|
|
3165
3583
|
? {
|
|
3166
3584
|
type: 'required_capability_unavailable',
|
|
@@ -3175,7 +3593,6 @@ export class BuyerProxy {
|
|
|
3175
3593
|
param: 'model',
|
|
3176
3594
|
},
|
|
3177
3595
|
}));
|
|
3178
|
-
return;
|
|
3179
3596
|
}
|
|
3180
3597
|
let lastRetry = null;
|
|
3181
3598
|
let lastVerificationError = null;
|
|
@@ -3187,8 +3604,8 @@ export class BuyerProxy {
|
|
|
3187
3604
|
if (unfunded && !isFree(selected))
|
|
3188
3605
|
continue;
|
|
3189
3606
|
if (this._verifier) {
|
|
3190
|
-
const makeReach = (chosenId) => makeVerifierReach(this._node, selected.peer, chosenId,
|
|
3191
|
-
const outcome = await this._verifyPeer(selected.peer, makeReach,
|
|
3607
|
+
const makeReach = (chosenId) => makeVerifierReach(this._node, selected.peer, chosenId, signal);
|
|
3608
|
+
const outcome = await this._verifyPeer(selected.peer, makeReach, signal, requireVerified);
|
|
3192
3609
|
if (!outcome.ok) {
|
|
3193
3610
|
lastVerificationError = `Peer ${selected.peer.peerId.slice(0, 12)}... failed required verification (${outcome.reason ?? 'failed'}).`;
|
|
3194
3611
|
log(`${lastVerificationError} Trying the next model peer.`);
|
|
@@ -3200,33 +3617,28 @@ export class BuyerProxy {
|
|
|
3200
3617
|
+ `service="${selected.serviceId}" reputation=${selected.reputation} `
|
|
3201
3618
|
+ `effective=${selected.effectiveReputationScore ?? 'unknown'} peer=${index + 1}/${candidates.length} `
|
|
3202
3619
|
+ `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,
|
|
3620
|
+
const result = await this._dispatchToPeer(res, selected.request, selected.peer, modelPlans, requestProtocol, selected.serviceId, explicitProvider, router, false, signal, requestPolicy !== null, routingConversationKey);
|
|
3204
3621
|
if (result.done) {
|
|
3205
3622
|
// Only a seller that answered becomes the chat's route: an error
|
|
3206
3623
|
// must not pin later turns to it.
|
|
3207
3624
|
if (trackedConversationId && res.statusCode < 400) {
|
|
3208
3625
|
this._conversations.recordRoutedModel(trackedConversationId, `${selected.peer.peerId}@${selected.serviceId}`, conversationPinAtRouting);
|
|
3209
3626
|
}
|
|
3210
|
-
return;
|
|
3627
|
+
return result;
|
|
3211
3628
|
}
|
|
3212
3629
|
// Never retry a video on another seller: a create may already have
|
|
3213
3630
|
// started (and been charged), and follow-ups only exist on one seller.
|
|
3214
|
-
if (nativeVideo)
|
|
3215
|
-
|
|
3216
|
-
res.end(result.responseBody);
|
|
3217
|
-
return;
|
|
3218
|
-
}
|
|
3631
|
+
if (nativeVideo)
|
|
3632
|
+
return result;
|
|
3219
3633
|
lastRetry = result;
|
|
3220
|
-
if (result
|
|
3634
|
+
if (isBuyerFault(result)) {
|
|
3221
3635
|
const freeLeft = candidates.slice(index + 1).some(isFree);
|
|
3222
3636
|
if (result.statusCode === 402 && freeLeft) {
|
|
3223
3637
|
unfunded = true;
|
|
3224
3638
|
log(`No deposits to pay peer ${selected.peer.peerId.slice(0, 12)}...; trying free sellers only.`);
|
|
3225
3639
|
break;
|
|
3226
3640
|
}
|
|
3227
|
-
|
|
3228
|
-
res.end(result.responseBody);
|
|
3229
|
-
return;
|
|
3641
|
+
return result;
|
|
3230
3642
|
}
|
|
3231
3643
|
const retrySamePeer = result.statusCode === 429
|
|
3232
3644
|
&& peerAttempt + 1 < MODEL_RATE_LIMIT_MAX_ATTEMPTS_PER_PEER;
|
|
@@ -3234,28 +3646,20 @@ export class BuyerProxy {
|
|
|
3234
3646
|
break;
|
|
3235
3647
|
const delayMs = rateLimitRetryDelayMs(result.responseHeaders, peerAttempt);
|
|
3236
3648
|
log(`Peer ${selected.peer.peerId.slice(0, 12)}... is rate-limited; retrying in ${delayMs}ms.`);
|
|
3237
|
-
if (!await waitForRetry(delayMs,
|
|
3238
|
-
return;
|
|
3649
|
+
if (!await waitForRetry(delayMs, signal))
|
|
3650
|
+
return { done: true };
|
|
3239
3651
|
}
|
|
3240
3652
|
if (index + 1 < candidates.length) {
|
|
3241
3653
|
log(`Peer ${selected.peer.peerId.slice(0, 12)}... failed retryably; trying next model peer.`);
|
|
3242
3654
|
}
|
|
3243
3655
|
}
|
|
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;
|
|
3656
|
+
return lastRetry ?? dispatchFailure(502, { 'content-type': 'application/json' }, JSON.stringify({
|
|
3657
|
+
error: {
|
|
3658
|
+
type: 'peer_verification_failed',
|
|
3659
|
+
code: 'peer_verification_failed',
|
|
3660
|
+
message: lastVerificationError ?? `No verified peer currently serves model "${requestedService}".`,
|
|
3661
|
+
},
|
|
3662
|
+
}));
|
|
3259
3663
|
}
|
|
3260
3664
|
const pinnedPeerId = explicitPeerId;
|
|
3261
3665
|
// Narrow the candidate set to just the pinned peer (if we already know
|
|
@@ -3303,12 +3707,12 @@ export class BuyerProxy {
|
|
|
3303
3707
|
: '--peer flag or session pin';
|
|
3304
3708
|
const diagnostics = this._formatPeerSelectionDiagnostics(discoveredPeers);
|
|
3305
3709
|
log(`Pinned peer ${pinnedPeerId.slice(0, 12)}... not discoverable in DHT (${logSource})`);
|
|
3306
|
-
|
|
3307
|
-
|
|
3710
|
+
// Also the answer for a `<peerId>@<routingServiceId>` pin whose router
|
|
3711
|
+
// is offline: an absent peer cannot be told apart from a seller.
|
|
3712
|
+
return dispatchFailure(502, { 'content-type': 'text/plain' }, `Pinned peer ${pinnedPeerId.slice(0, 12)}... (seller or routing service) is not reachable right now. `
|
|
3308
3713
|
+ 'It may be offline, not announcing, or temporarily unreachable. '
|
|
3309
3714
|
+ 'Pick a different service in Discover or try again later. '
|
|
3310
3715
|
+ diagnostics);
|
|
3311
|
-
return;
|
|
3312
3716
|
}
|
|
3313
3717
|
if (routingPeers.length === 0) {
|
|
3314
3718
|
const pinnedPeer = discoveredPeers.find((peer) => peer.peerId.toLowerCase() === pinnedPeerId) ?? null;
|
|
@@ -3323,20 +3727,16 @@ export class BuyerProxy {
|
|
|
3323
3727
|
if (!providers.includes(explicitProvider)) {
|
|
3324
3728
|
const providerList = providers.length > 0 ? providers.join(', ') : 'none';
|
|
3325
3729
|
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}. `
|
|
3730
|
+
return dispatchFailure(502, { 'content-type': 'text/plain' }, `Pinned peer ${pinnedPeerId.slice(0, 12)}... does not offer provider=${explicitProvider}. `
|
|
3328
3731
|
+ `Available providers: ${providerList}. `
|
|
3329
3732
|
+ 'Remove or change the x-antseed-provider header, or pick a different peer. '
|
|
3330
3733
|
+ diagnostics);
|
|
3331
|
-
return;
|
|
3332
3734
|
}
|
|
3333
3735
|
}
|
|
3334
3736
|
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 `
|
|
3737
|
+
return dispatchFailure(502, { 'content-type': 'text/plain' }, `Pinned peer ${pinnedPeerId.slice(0, 12)}... does not support this request `
|
|
3337
3738
|
+ `(${protocolLabel}, ${providerLabel}, ${serviceLabel}). `
|
|
3338
3739
|
+ `Pick a different service in Discover. ${diagnostics}`);
|
|
3339
|
-
return;
|
|
3340
3740
|
}
|
|
3341
3741
|
log(`Routing candidates: ${routingPeers.length} peer(s)`);
|
|
3342
3742
|
const router = this._node.router;
|
|
@@ -3346,9 +3746,7 @@ export class BuyerProxy {
|
|
|
3346
3746
|
// if an invariant breaks.
|
|
3347
3747
|
if (!selectedPeer) {
|
|
3348
3748
|
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;
|
|
3749
|
+
return dispatchFailure(502, { 'content-type': 'text/plain' }, `Pinned peer ${pinnedPeerId.slice(0, 12)}... is currently unreachable. Try again in a moment.`);
|
|
3352
3750
|
}
|
|
3353
3751
|
const policyRouter = router;
|
|
3354
3752
|
const selectedPlan = routingPlans.get(selectedPeer.peerId)
|
|
@@ -3358,8 +3756,7 @@ export class BuyerProxy {
|
|
|
3358
3756
|
? findMissingRequiredParameters(selectedPeer, selectedPlan.provider, pinnedServiceId, requiredParameters)
|
|
3359
3757
|
: requiredParameters;
|
|
3360
3758
|
if (missingRequired.length > 0) {
|
|
3361
|
-
|
|
3362
|
-
res.end(JSON.stringify({
|
|
3759
|
+
return dispatchFailure(422, { 'content-type': 'application/json' }, JSON.stringify({
|
|
3363
3760
|
error: {
|
|
3364
3761
|
type: 'required_capability_unavailable',
|
|
3365
3762
|
code: 'required_capability_unavailable',
|
|
@@ -3367,7 +3764,6 @@ export class BuyerProxy {
|
|
|
3367
3764
|
param: REQUIRED_PARAMETERS_HEADER,
|
|
3368
3765
|
},
|
|
3369
3766
|
}));
|
|
3370
|
-
return;
|
|
3371
3767
|
}
|
|
3372
3768
|
const pinnedRequest = pinnedServiceId ? withRoutedModel(serializedReq, pinnedServiceId) : serializedReq;
|
|
3373
3769
|
// A hard pin cannot escape the gateway's policy: an excluded seller is
|
|
@@ -3385,26 +3781,23 @@ export class BuyerProxy {
|
|
|
3385
3781
|
addBuyerConfigReasons(reasons, policyExclusionReasons(this._buyerHardPolicy(), facts));
|
|
3386
3782
|
if (reasons.length > 0) {
|
|
3387
3783
|
log(`Pinned peer ${selectedPeer.peerId.slice(0, 12)}... refused by routing policy: ${reasons.join('; ')}`);
|
|
3388
|
-
|
|
3784
|
+
return dispatchFailure(403, { 'content-type': 'application/json' }, JSON.stringify({
|
|
3389
3785
|
error: {
|
|
3390
3786
|
type: 'permission_error',
|
|
3391
3787
|
code: 'peer_not_allowed',
|
|
3392
3788
|
message: `Seller ${selectedPeer.peerId.slice(0, 12)}... is not allowed by the routing policy (${reasons.join('; ')}).`,
|
|
3393
3789
|
},
|
|
3394
|
-
});
|
|
3395
|
-
return;
|
|
3790
|
+
}));
|
|
3396
3791
|
}
|
|
3397
3792
|
}
|
|
3398
3793
|
if (!peerAllowedByPolicy(policyRouter, pinnedRequest, selectedPeer)) {
|
|
3399
3794
|
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. `
|
|
3795
|
+
return dispatchFailure(502, { 'content-type': 'text/plain' }, `Pinned peer ${selectedPeer.peerId.slice(0, 12)}... is outside your buyer routing policy. `
|
|
3402
3796
|
+ 'Pick a different service in Discover or adjust your buyer pricing/reputation limits.');
|
|
3403
|
-
return;
|
|
3404
3797
|
}
|
|
3405
3798
|
if (this._verifier) {
|
|
3406
|
-
const makeReach = (chosenId) => makeVerifierReach(this._node, selectedPeer, chosenId,
|
|
3407
|
-
const outcome = await this._verifyPeer(selectedPeer, makeReach,
|
|
3799
|
+
const makeReach = (chosenId) => makeVerifierReach(this._node, selectedPeer, chosenId, signal);
|
|
3800
|
+
const outcome = await this._verifyPeer(selectedPeer, makeReach, signal, pinnedPolicy?.requireVerified === true);
|
|
3408
3801
|
const short = selectedPeer.peerId.slice(0, 12);
|
|
3409
3802
|
if (outcome.verified) {
|
|
3410
3803
|
log(`Verified ${short}... via ${outcome.sdk}`);
|
|
@@ -3413,23 +3806,17 @@ export class BuyerProxy {
|
|
|
3413
3806
|
log(`Verification ${outcome.sdk ? `(${outcome.sdk}) ` : ''}did not pass for ${short}...: ${outcome.reason ?? 'failed'}${outcome.ok ? ' (optional — routing anyway)' : ''}`);
|
|
3414
3807
|
}
|
|
3415
3808
|
if (!outcome.ok) {
|
|
3416
|
-
|
|
3417
|
-
res.end(`Pinned peer ${short}... failed required verification (${outcome.reason ?? 'failed'}). `
|
|
3809
|
+
return dispatchFailure(502, { 'content-type': 'text/plain' }, `Pinned peer ${short}... failed required verification (${outcome.reason ?? 'failed'}). `
|
|
3418
3810
|
+ 'Pick a different peer, or run without --require-verifier.');
|
|
3419
|
-
return;
|
|
3420
3811
|
}
|
|
3421
3812
|
}
|
|
3422
3813
|
log(`Using pinned peer ${selectedPeer.peerId.slice(0, 12)}...`);
|
|
3423
|
-
const result = await this._dispatchToPeer(res, pinnedRequest, selectedPeer, routingPlans, requestProtocol, requestedService, explicitProvider, router, true,
|
|
3814
|
+
const result = await this._dispatchToPeer(res, pinnedRequest, selectedPeer, routingPlans, requestProtocol, requestedService, explicitProvider, router, true, signal, false, routingConversationKey);
|
|
3424
3815
|
if (result.done && trackedConversationId && pinnedServiceId) {
|
|
3425
3816
|
this._conversations.recordRoutedModel(trackedConversationId, `${selectedPeer.peerId}@${pinnedServiceId}`, conversationPinAtRouting);
|
|
3426
3817
|
}
|
|
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
|
-
}
|
|
3818
|
+
// A pinned peer is never retried against another peer; the caller reports any failure.
|
|
3819
|
+
return result;
|
|
3433
3820
|
}
|
|
3434
3821
|
async _verifyPeer(peer, makeReach, signal, requireVerified = false) {
|
|
3435
3822
|
const configured = this._verifier;
|
|
@@ -3514,7 +3901,7 @@ export class BuyerProxy {
|
|
|
3514
3901
|
* was sent to the client (success or non-retryable error), or retry info if the
|
|
3515
3902
|
* caller should try another peer.
|
|
3516
3903
|
*/
|
|
3517
|
-
async _dispatchToPeer(res, serializedReq, selectedPeer, routePlanByPeerId, requestProtocol, requestedService, explicitProvider, router, pinned, requestSignal, gatewayRouted = false) {
|
|
3904
|
+
async _dispatchToPeer(res, serializedReq, selectedPeer, routePlanByPeerId, requestProtocol, requestedService, explicitProvider, router, pinned, requestSignal, gatewayRouted = false, routingConversationKey = null) {
|
|
3518
3905
|
const gatewayAutoRoute = gatewayRouted && !pinned;
|
|
3519
3906
|
const selectedRoutePlan = routePlanByPeerId.get(selectedPeer.peerId)
|
|
3520
3907
|
?? resolvePeerRoutePlan(selectedPeer, requestProtocol, requestedService, explicitProvider, 'lenient');
|
|
@@ -3651,6 +4038,7 @@ export class BuyerProxy {
|
|
|
3651
4038
|
}
|
|
3652
4039
|
},
|
|
3653
4040
|
}, { signal: requestSignal, pinned, ...(buyerIdentity ? { buyerIdentity } : {}) });
|
|
4041
|
+
recordRouterUsage(this._modelRoutingClient, routingConversationKey, requestForPeer, response, selectedPeer, requestSignal);
|
|
3654
4042
|
let responseForClient = adaptBuyerFaultErrorResponse(response, requestProtocol);
|
|
3655
4043
|
responseForClient = adaptPeerResponse(responseForClient);
|
|
3656
4044
|
if (!streamed
|
|
@@ -3726,6 +4114,7 @@ export class BuyerProxy {
|
|
|
3726
4114
|
if (upstreamResponse.statusCode >= 400 && !adaptResponse) {
|
|
3727
4115
|
log(`Upstream raw error detail: ${summarizeErrorResponse(upstreamResponse)}`);
|
|
3728
4116
|
}
|
|
4117
|
+
recordRouterUsage(this._modelRoutingClient, routingConversationKey, requestForPeer, upstreamResponse, selectedPeer, requestSignal);
|
|
3729
4118
|
// Only the seller that accepted a video job knows its ID. Remember and
|
|
3730
4119
|
// persist job ID -> seller so later retrieve requests (even
|
|
3731
4120
|
// after a proxy restart) are pinned back to that seller.
|