@antseed/cli 0.1.147 → 0.1.149
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 +18 -3
- package/dist/cli/commands/buyer/start.d.ts +1 -0
- package/dist/cli/commands/buyer/start.d.ts.map +1 -1
- package/dist/cli/commands/buyer/start.js +10 -9
- package/dist/cli/commands/buyer/start.js.map +1 -1
- package/dist/cli/commands/buyer/start.test.js +30 -1
- package/dist/cli/commands/buyer/start.test.js.map +1 -1
- package/dist/cli/commands/network/browse.d.ts.map +1 -1
- package/dist/cli/commands/network/browse.js +65 -114
- package/dist/cli/commands/network/browse.js.map +1 -1
- package/dist/config/defaults.d.ts.map +1 -1
- package/dist/config/defaults.js +6 -0
- package/dist/config/defaults.js.map +1 -1
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +39 -0
- package/dist/config/loader.js.map +1 -1
- package/dist/config/loader.test.js +34 -0
- package/dist/config/loader.test.js.map +1 -1
- package/dist/config/types.d.ts +3 -0
- package/dist/config/types.d.ts.map +1 -1
- package/dist/config/validation.d.ts.map +1 -1
- package/dist/config/validation.js +17 -0
- package/dist/config/validation.js.map +1 -1
- package/dist/proxy/buyer-proxy.d.ts +20 -2
- package/dist/proxy/buyer-proxy.d.ts.map +1 -1
- package/dist/proxy/buyer-proxy.js +408 -58
- package/dist/proxy/buyer-proxy.js.map +1 -1
- package/dist/proxy/buyer-proxy.test.js +828 -56
- package/dist/proxy/buyer-proxy.test.js.map +1 -1
- package/dist/proxy/conversation-store.d.ts +6 -6
- package/dist/proxy/conversation-store.d.ts.map +1 -1
- package/dist/proxy/conversation-store.js +16 -0
- package/dist/proxy/conversation-store.js.map +1 -1
- package/dist/proxy/network-models.d.ts +76 -0
- package/dist/proxy/network-models.d.ts.map +1 -0
- package/dist/proxy/network-models.js +229 -0
- package/dist/proxy/network-models.js.map +1 -0
- package/dist/proxy/network-models.test.d.ts +2 -0
- package/dist/proxy/network-models.test.d.ts.map +1 -0
- package/dist/proxy/network-models.test.js +734 -0
- package/dist/proxy/network-models.test.js.map +1 -0
- package/dist/proxy/routing.d.ts +3 -1
- package/dist/proxy/routing.d.ts.map +1 -1
- package/dist/proxy/routing.js +69 -16
- package/dist/proxy/routing.js.map +1 -1
- package/package.json +5 -5
|
@@ -3,10 +3,12 @@ import { randomUUID } from 'node:crypto';
|
|
|
3
3
|
import { watchFile, unwatchFile } from 'node:fs';
|
|
4
4
|
import { readFile, writeFile, rename, mkdir, readdir, stat, unlink } from 'node:fs/promises';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
|
-
import { ANTSEED_BUYER_FAULT_ERROR_CODE, ANTSEED_FAULT_ATTRIBUTION_HEADER, ANTSEED_ATTEST_PATH, computeOnChainReputationScore, decodeSweepRequest, faultAttributionOf, faultCodeOf, peerSupportsCooperativeClose, } from '@antseed/node';
|
|
6
|
+
import { ANTSEED_BUYER_FAULT_ERROR_CODE, ANTSEED_FAULT_ATTRIBUTION_HEADER, ANTSEED_ATTEST_PATH, computeOnChainReputationScore, decodeSweepRequest, faultAttributionOf, faultCodeOf, isModelRouteEligible, peerSupportsCooperativeClose, rankModelRoutes, } from '@antseed/node';
|
|
7
|
+
import { canonicalModelKey } from '@antseed/node/model-identity';
|
|
7
8
|
import { createStreamingAdapter, detectRequestServiceApiProtocol, transformRequest, transformResponse, } from './service-api-adapter.js';
|
|
8
9
|
import { DEBUG, log, extractRequestedService, summarizeRequestShape, summarizeErrorResponse, requestWantsStreaming, parsePeerPinnedService, rewritePeerPinnedServiceInBody, substituteRoutedModelAlias, overrideRoutedModelInBody, ROUTED_MODEL_ALIAS, SYSTEM_PROXY_SOURCE_HEADER, SYSTEM_ROUTED_MODEL_HEADER, normalizePeerId, } from './request-utils.js';
|
|
9
|
-
import {
|
|
10
|
+
import { buildNetworkModels, effectiveModelReputationScore, normalizedModelReputationScore, parseModelTypeFilter, } from './network-models.js';
|
|
11
|
+
import { findUnannouncedRequestParameters, findAdvertisedServiceOffer, getExplicitProviderOverride, getExplicitPeerIdOverride, resolvePeerRoutePlan, selectCandidatePeersForRouting, } from './routing.js';
|
|
10
12
|
import { computeResponseTelemetry, attachAntseedTelemetryHeaders, attachStreamingAntseedHeaders, } from './telemetry.js';
|
|
11
13
|
import { DEFAULT_BUYER_PEER_REFRESH_INTERVAL_MS } from '../config/defaults.js';
|
|
12
14
|
import { extractConversationIdentity, extractFirstUserSnippet, isCompletionRequestPath, isTitleGenerationRequest, parseRequestBodyObject, } from './conversation-identity.js';
|
|
@@ -15,10 +17,63 @@ import { recordPeerFailureEntry, clearPeerHealthEntry, isCoolingDown, parsePersi
|
|
|
15
17
|
import { PeerAttributionTracker, HEARTBEAT_MS } from './peer-attribution.js';
|
|
16
18
|
import { estimateAnthropicPromptTokens, isCountTokensPath } from './count-tokens.js';
|
|
17
19
|
import { getCachedVerdict, runVerifier, verifierSupportFingerprint } from '../plugins/verifier.js';
|
|
20
|
+
import { loadConfig } from '../config/loader.js';
|
|
18
21
|
// Re-export for backward compatibility (used by tests and other consumers)
|
|
19
22
|
export { selectCandidatePeersForRouting } from './routing.js';
|
|
20
23
|
export { parsePeerPinnedService, rewritePeerPinnedServiceInBody, substituteRoutedModelAlias, ROUTED_MODEL_ALIAS } from './request-utils.js';
|
|
21
24
|
const RETRYABLE_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504]);
|
|
25
|
+
const MODEL_RATE_LIMIT_MAX_ATTEMPTS_PER_PEER = 3;
|
|
26
|
+
const MODEL_RATE_LIMIT_RETRY_DELAYS_MS = [250, 750];
|
|
27
|
+
const MODEL_RATE_LIMIT_MAX_RETRY_AFTER_MS = 2_000;
|
|
28
|
+
function rateLimitRetryDelayMs(headers, retryIndex) {
|
|
29
|
+
const retryAfter = headers['retry-after'] ?? headers['Retry-After'];
|
|
30
|
+
if (retryAfter) {
|
|
31
|
+
const seconds = Number(retryAfter);
|
|
32
|
+
if (Number.isFinite(seconds) && seconds >= 0) {
|
|
33
|
+
return Math.min(MODEL_RATE_LIMIT_MAX_RETRY_AFTER_MS, Math.round(seconds * 1_000));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return MODEL_RATE_LIMIT_RETRY_DELAYS_MS[retryIndex] ?? MODEL_RATE_LIMIT_RETRY_DELAYS_MS.at(-1);
|
|
37
|
+
}
|
|
38
|
+
async function waitForRetry(delayMs, signal) {
|
|
39
|
+
if (signal.aborted)
|
|
40
|
+
return false;
|
|
41
|
+
return new Promise((resolve) => {
|
|
42
|
+
const onAbort = () => {
|
|
43
|
+
clearTimeout(timeout);
|
|
44
|
+
resolve(false);
|
|
45
|
+
};
|
|
46
|
+
const timeout = setTimeout(() => {
|
|
47
|
+
signal.removeEventListener('abort', onAbort);
|
|
48
|
+
resolve(true);
|
|
49
|
+
}, delayMs);
|
|
50
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* A routed-model target is either a bare `<service>` (automatic peer
|
|
55
|
+
* selection) or an explicit `<peerId>@<service>` pin. The `antseed` alias
|
|
56
|
+
* itself can never be a target — it would recurse.
|
|
57
|
+
*/
|
|
58
|
+
function isValidRoutedModelTarget(value) {
|
|
59
|
+
if (value === ROUTED_MODEL_ALIAS)
|
|
60
|
+
return false;
|
|
61
|
+
return !value.includes('@') || parsePeerPinnedService(value) !== null;
|
|
62
|
+
}
|
|
63
|
+
/** Returns `request` with its body's model field rewritten to `serviceId`, or unchanged if nothing rewrote. */
|
|
64
|
+
function withRoutedModel(request, serviceId) {
|
|
65
|
+
const rewritten = overrideRoutedModelInBody(request.body, request.headers, serviceId);
|
|
66
|
+
return rewritten.overridden
|
|
67
|
+
? { ...request, body: rewritten.body, headers: rewritten.headers }
|
|
68
|
+
: request;
|
|
69
|
+
}
|
|
70
|
+
function peerAllowedByPolicy(policyRouter, request, peer) {
|
|
71
|
+
if (policyRouter?.allowsPeerForPolicy)
|
|
72
|
+
return policyRouter.allowsPeerForPolicy(request, peer);
|
|
73
|
+
if (policyRouter?.allowsPeerForPricing)
|
|
74
|
+
return policyRouter.allowsPeerForPricing(request, peer);
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
22
77
|
function isControlPlaneServicesPath(path) {
|
|
23
78
|
return path.toLowerCase().startsWith('/v1/models');
|
|
24
79
|
}
|
|
@@ -390,6 +445,11 @@ export function parsePersistedPeers(parsed, nowMs = Date.now(), maxAgeMs = CARRY
|
|
|
390
445
|
if (entry.verificationResults && typeof entry.verificationResults === 'object') {
|
|
391
446
|
peer.verificationResults = entry.verificationResults;
|
|
392
447
|
}
|
|
448
|
+
if (peer.onChainReputationScore === undefined) {
|
|
449
|
+
const derivedScore = computeOnChainReputationScore(peer, nowMs);
|
|
450
|
+
if (derivedScore !== null)
|
|
451
|
+
peer.onChainReputationScore = derivedScore;
|
|
452
|
+
}
|
|
393
453
|
peers.push(peer);
|
|
394
454
|
}
|
|
395
455
|
return peers;
|
|
@@ -501,10 +561,13 @@ export class BuyerProxy {
|
|
|
501
561
|
_peerCacheTtlMs;
|
|
502
562
|
_stateDir;
|
|
503
563
|
_stateFile;
|
|
564
|
+
_configPath;
|
|
504
565
|
_stateFileWatching = false;
|
|
566
|
+
_configFileWatching = false;
|
|
505
567
|
_pinnedPeer;
|
|
506
568
|
/**
|
|
507
|
-
* Route substituted for the `antseed` model alias (`<
|
|
569
|
+
* Route substituted for the `antseed` model alias (`<service>` for automatic
|
|
570
|
+
* peer selection, or `<peerId>@<service>` for an explicit seller pin).
|
|
508
571
|
* Set via `POST /_antseed/route` (the desktop keeps it on the current VPR
|
|
509
572
|
* selection) and persisted in buyer.state.json like the session peer pin.
|
|
510
573
|
*/
|
|
@@ -520,6 +583,8 @@ export class BuyerProxy {
|
|
|
520
583
|
_verifier;
|
|
521
584
|
_verifyCache = new Map();
|
|
522
585
|
_stateWatchDebounce = null;
|
|
586
|
+
_configWatchDebounce = null;
|
|
587
|
+
_routingPreferences;
|
|
523
588
|
_stateWriteChain = Promise.resolve();
|
|
524
589
|
_cachedPeers = [];
|
|
525
590
|
_cacheLastUpdatedAtMs = 0;
|
|
@@ -561,8 +626,16 @@ export class BuyerProxy {
|
|
|
561
626
|
this._peerCacheTtlMs = Math.max(0, config.peerCacheTtlMs ?? Math.max(6 * 60_000, this._bgRefreshIntervalMs + 60_000));
|
|
562
627
|
this._stateDir = config.dataDir;
|
|
563
628
|
this._stateFile = join(config.dataDir, 'buyer.state.json');
|
|
629
|
+
this._configPath = config.configPath ?? null;
|
|
564
630
|
this._conversations = new ConversationStore(config.dataDir);
|
|
565
631
|
this._pinnedPeer = config.pinnedPeerId?.toLowerCase() ?? null;
|
|
632
|
+
this._routingPreferences = config.routingPreferences
|
|
633
|
+
? {
|
|
634
|
+
...config.routingPreferences,
|
|
635
|
+
allowedPeerIds: [...config.routingPreferences.allowedPeerIds],
|
|
636
|
+
blockedPeerIds: [...config.routingPreferences.blockedPeerIds],
|
|
637
|
+
}
|
|
638
|
+
: null;
|
|
566
639
|
this._now = config.now ?? (() => Date.now());
|
|
567
640
|
this._server = createServer((req, res) => {
|
|
568
641
|
this._handleRequest(req, res).catch((err) => {
|
|
@@ -661,6 +734,7 @@ export class BuyerProxy {
|
|
|
661
734
|
this._startIncrementalDiscoverySweep();
|
|
662
735
|
await this._writeStateFile('connected');
|
|
663
736
|
this._watchStateFile();
|
|
737
|
+
this._watchConfigFile();
|
|
664
738
|
}
|
|
665
739
|
async _hydratePeersFromStateFile() {
|
|
666
740
|
try {
|
|
@@ -699,6 +773,14 @@ export class BuyerProxy {
|
|
|
699
773
|
unwatchFile(this._stateFile);
|
|
700
774
|
this._stateFileWatching = false;
|
|
701
775
|
}
|
|
776
|
+
if (this._configWatchDebounce) {
|
|
777
|
+
clearTimeout(this._configWatchDebounce);
|
|
778
|
+
this._configWatchDebounce = null;
|
|
779
|
+
}
|
|
780
|
+
if (this._configFileWatching && this._configPath) {
|
|
781
|
+
unwatchFile(this._configPath);
|
|
782
|
+
this._configFileWatching = false;
|
|
783
|
+
}
|
|
702
784
|
if (this._bgRefreshHandle) {
|
|
703
785
|
clearInterval(this._bgRefreshHandle);
|
|
704
786
|
this._bgRefreshHandle = null;
|
|
@@ -747,13 +829,51 @@ export class BuyerProxy {
|
|
|
747
829
|
this._pinnedPeer = pinnedPeer;
|
|
748
830
|
}
|
|
749
831
|
const routedModel = typeof parsed.defaultRoutedModel === 'string' ? parsed.defaultRoutedModel.trim() : '';
|
|
750
|
-
this._defaultRoutedModel =
|
|
832
|
+
this._defaultRoutedModel = routedModel.length > 0 && isValidRoutedModelTarget(routedModel) ? routedModel : null;
|
|
751
833
|
log(`Session overrides reloaded: peer=${this._pinnedPeer ?? 'none'} route=${this._defaultRoutedModel ?? 'none'}`);
|
|
752
834
|
}
|
|
753
835
|
catch {
|
|
754
836
|
// state file unreadable; keep current values
|
|
755
837
|
}
|
|
756
838
|
}
|
|
839
|
+
_watchConfigFile() {
|
|
840
|
+
if (!this._configPath)
|
|
841
|
+
return;
|
|
842
|
+
try {
|
|
843
|
+
watchFile(this._configPath, { persistent: false, interval: 500 }, (curr, prev) => {
|
|
844
|
+
if (curr.mtimeMs === prev.mtimeMs && curr.ino === prev.ino)
|
|
845
|
+
return;
|
|
846
|
+
if (this._configWatchDebounce)
|
|
847
|
+
clearTimeout(this._configWatchDebounce);
|
|
848
|
+
this._configWatchDebounce = setTimeout(() => {
|
|
849
|
+
this._configWatchDebounce = null;
|
|
850
|
+
void this._reloadRoutingPreferences().catch(() => { });
|
|
851
|
+
}, 50);
|
|
852
|
+
});
|
|
853
|
+
this._configFileWatching = true;
|
|
854
|
+
}
|
|
855
|
+
catch {
|
|
856
|
+
// Config watcher failure is non-fatal; startup preferences remain active.
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
async _reloadRoutingPreferences() {
|
|
860
|
+
if (!this._configPath)
|
|
861
|
+
return;
|
|
862
|
+
try {
|
|
863
|
+
const config = await loadConfig(this._configPath);
|
|
864
|
+
const next = config.buyer.routingPreferences;
|
|
865
|
+
this._routingPreferences = {
|
|
866
|
+
...next,
|
|
867
|
+
allowedPeerIds: [...next.allowedPeerIds],
|
|
868
|
+
blockedPeerIds: [...next.blockedPeerIds],
|
|
869
|
+
};
|
|
870
|
+
log(`Routing preferences reloaded: minTrust=${next.minTrustScore} maxInput=${next.maxInputUsdPerMillion} `
|
|
871
|
+
+ `preferFree=${next.preferFreePeers} allow=${next.allowedPeerIds.length} block=${next.blockedPeerIds.length}`);
|
|
872
|
+
}
|
|
873
|
+
catch (err) {
|
|
874
|
+
log(`Routing preferences reload ignored: ${err instanceof Error ? err.message : String(err)}`);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
757
877
|
/** Stamp the last model-request activity time (dispatch or streamed frame). */
|
|
758
878
|
_markModelActivity() {
|
|
759
879
|
this._lastModelActivityAt = Date.now();
|
|
@@ -1309,9 +1429,9 @@ export class BuyerProxy {
|
|
|
1309
1429
|
res.end(JSON.stringify({ ok: false, error: 'Invalid JSON body' }));
|
|
1310
1430
|
return;
|
|
1311
1431
|
}
|
|
1312
|
-
if (model.length > 0 && !
|
|
1432
|
+
if (model.length > 0 && !isValidRoutedModelTarget(model)) {
|
|
1313
1433
|
res.writeHead(400, { 'content-type': 'application/json' });
|
|
1314
|
-
res.end(JSON.stringify({ ok: false, error: 'model must be "<peerId>@<service>"
|
|
1434
|
+
res.end(JSON.stringify({ ok: false, error: 'model must be "<service>", "<peerId>@<service>", or empty to clear' }));
|
|
1315
1435
|
return;
|
|
1316
1436
|
}
|
|
1317
1437
|
this._defaultRoutedModel = model.length > 0 ? model : null;
|
|
@@ -1326,6 +1446,24 @@ export class BuyerProxy {
|
|
|
1326
1446
|
res.end(JSON.stringify({ ok: true, conversations: this._conversations.list() }));
|
|
1327
1447
|
return;
|
|
1328
1448
|
}
|
|
1449
|
+
const conversationMatch = path.match(/^\/_antseed\/conversations\/(.+)$/);
|
|
1450
|
+
if (conversationMatch && method === 'GET') {
|
|
1451
|
+
let id = '';
|
|
1452
|
+
try {
|
|
1453
|
+
id = decodeURIComponent(conversationMatch[1] ?? '');
|
|
1454
|
+
}
|
|
1455
|
+
catch {
|
|
1456
|
+
res.writeHead(400, { 'content-type': 'application/json' });
|
|
1457
|
+
res.end(JSON.stringify({ ok: false, error: 'Invalid conversation id' }));
|
|
1458
|
+
return;
|
|
1459
|
+
}
|
|
1460
|
+
const conversation = this._conversations.get(id);
|
|
1461
|
+
res.writeHead(conversation ? 200 : 404, { 'content-type': 'application/json' });
|
|
1462
|
+
res.end(JSON.stringify(conversation
|
|
1463
|
+
? { ok: true, conversation }
|
|
1464
|
+
: { ok: false, error: 'Unknown conversation' }));
|
|
1465
|
+
return;
|
|
1466
|
+
}
|
|
1329
1467
|
if (path === '/_antseed/conversations/update' && method === 'POST') {
|
|
1330
1468
|
const chunks = [];
|
|
1331
1469
|
let totalSize = 0;
|
|
@@ -1367,9 +1505,9 @@ export class BuyerProxy {
|
|
|
1367
1505
|
}
|
|
1368
1506
|
if ('pinnedModel' in parsed) {
|
|
1369
1507
|
const pin = typeof parsed.pinnedModel === 'string' ? parsed.pinnedModel.trim() : '';
|
|
1370
|
-
if (pin.length > 0 && !
|
|
1508
|
+
if (pin.length > 0 && !isValidRoutedModelTarget(pin)) {
|
|
1371
1509
|
res.writeHead(400, { 'content-type': 'application/json' });
|
|
1372
|
-
res.end(JSON.stringify({ ok: false, error: 'pinnedModel must be "<peerId>@<service>"
|
|
1510
|
+
res.end(JSON.stringify({ ok: false, error: 'pinnedModel must be "<service>", "<peerId>@<service>", or empty to clear' }));
|
|
1373
1511
|
return;
|
|
1374
1512
|
}
|
|
1375
1513
|
// 'user' marks a seller the user chose for this specific chat — the
|
|
@@ -1556,6 +1694,63 @@ export class BuyerProxy {
|
|
|
1556
1694
|
res.writeHead(404, { 'content-type': 'application/json' });
|
|
1557
1695
|
res.end(JSON.stringify({ ok: false, error: 'Unknown control-plane endpoint' }));
|
|
1558
1696
|
}
|
|
1697
|
+
/**
|
|
1698
|
+
* GET /v1/models[?type=text|images] and GET /v1/models/:id — answered
|
|
1699
|
+
* locally from the discovered-peer cache, aggregated across the network. The
|
|
1700
|
+
* `x-antseed-request-id` response header keeps the port-reuse probe in
|
|
1701
|
+
* `buyer start` recognizing this as an AntSeed proxy.
|
|
1702
|
+
*/
|
|
1703
|
+
async _handleNetworkModels(res, rawPath) {
|
|
1704
|
+
const url = new URL(rawPath, 'http://localhost');
|
|
1705
|
+
const responseHeaders = { 'content-type': 'application/json', 'x-antseed-request-id': randomUUID() };
|
|
1706
|
+
const peers = await this._getPeers();
|
|
1707
|
+
const models = buildNetworkModels(peers, this._now(), {
|
|
1708
|
+
routingPreferences: this._routingPreferences,
|
|
1709
|
+
peerHealth: this._peerHealth,
|
|
1710
|
+
});
|
|
1711
|
+
const modelIdRaw = url.pathname.replace(/^\/v1\/models\/?/i, '');
|
|
1712
|
+
if (modelIdRaw.length > 0) {
|
|
1713
|
+
let modelId;
|
|
1714
|
+
try {
|
|
1715
|
+
modelId = decodeURIComponent(modelIdRaw).trim();
|
|
1716
|
+
}
|
|
1717
|
+
catch {
|
|
1718
|
+
modelId = modelIdRaw.trim();
|
|
1719
|
+
}
|
|
1720
|
+
const modelKey = canonicalModelKey(modelId);
|
|
1721
|
+
const model = models.find((entry) => canonicalModelKey(entry.id) === modelKey);
|
|
1722
|
+
if (!model) {
|
|
1723
|
+
res.writeHead(404, responseHeaders);
|
|
1724
|
+
res.end(JSON.stringify({
|
|
1725
|
+
error: {
|
|
1726
|
+
message: `Model "${modelId}" was not found on the network.`,
|
|
1727
|
+
type: 'invalid_request_error',
|
|
1728
|
+
code: 'model_not_found',
|
|
1729
|
+
},
|
|
1730
|
+
}));
|
|
1731
|
+
return;
|
|
1732
|
+
}
|
|
1733
|
+
res.writeHead(200, responseHeaders);
|
|
1734
|
+
res.end(JSON.stringify(model));
|
|
1735
|
+
return;
|
|
1736
|
+
}
|
|
1737
|
+
const typeFilter = parseModelTypeFilter(url.searchParams.get('type'));
|
|
1738
|
+
if (typeFilter === 'invalid') {
|
|
1739
|
+
res.writeHead(400, responseHeaders);
|
|
1740
|
+
res.end(JSON.stringify({
|
|
1741
|
+
error: {
|
|
1742
|
+
message: `Unknown model type "${url.searchParams.get('type') ?? ''}" — expected "text" or "images".`,
|
|
1743
|
+
type: 'invalid_request_error',
|
|
1744
|
+
param: 'type',
|
|
1745
|
+
},
|
|
1746
|
+
}));
|
|
1747
|
+
return;
|
|
1748
|
+
}
|
|
1749
|
+
const data = typeFilter === 'all' ? models : models.filter((entry) => entry.type === typeFilter);
|
|
1750
|
+
log(`GET /v1/models answered locally: ${data.length} models across ${peers.length} peers${typeFilter ? ` (type=${typeFilter})` : ''}`);
|
|
1751
|
+
res.writeHead(200, responseHeaders);
|
|
1752
|
+
res.end(JSON.stringify({ object: 'list', data }));
|
|
1753
|
+
}
|
|
1559
1754
|
async _handleRequest(req, res) {
|
|
1560
1755
|
const method = req.method ?? 'GET';
|
|
1561
1756
|
const path = req.url ?? '/';
|
|
@@ -1577,6 +1772,13 @@ export class BuyerProxy {
|
|
|
1577
1772
|
res.end(JSON.stringify({ error: { message: 'Not found', type: 'invalid_request_error' } }));
|
|
1578
1773
|
return;
|
|
1579
1774
|
}
|
|
1775
|
+
// `/v1/models` is answered locally from the discovered-peer cache: one
|
|
1776
|
+
// entry per model across the whole network, with the peers serving it.
|
|
1777
|
+
// Routed to a single pinned seller it would only cover that seller's
|
|
1778
|
+
// services — and would require a pin just to browse the network.
|
|
1779
|
+
if (method === 'GET' && normalizedPath.startsWith('/v1/models')) {
|
|
1780
|
+
return this._handleNetworkModels(res, path);
|
|
1781
|
+
}
|
|
1580
1782
|
// Collect request body
|
|
1581
1783
|
const chunks = [];
|
|
1582
1784
|
for await (const chunk of req) {
|
|
@@ -1622,12 +1824,10 @@ export class BuyerProxy {
|
|
|
1622
1824
|
// _reloadSessionOverrides() cannot change routing mid-request.
|
|
1623
1825
|
const effectivePinnedPeer = this._pinnedPeer;
|
|
1624
1826
|
// Per-chat routing: completion requests carry a stable per-conversation
|
|
1625
|
-
// identity (see conversation-identity.ts).
|
|
1626
|
-
//
|
|
1627
|
-
//
|
|
1628
|
-
//
|
|
1629
|
-
// the chat's own pin (ConversationStore.touch), so changing the default
|
|
1630
|
-
// later applies to new chats only.
|
|
1827
|
+
// identity (see conversation-identity.ts). Explicit chat pins remain hard;
|
|
1828
|
+
// automatically selected routes become soft affinity, so later turns stay
|
|
1829
|
+
// on the same seller unless it is cooling, unavailable, or fails retryably.
|
|
1830
|
+
// Subagent sessions inherit their parent chat's route.
|
|
1631
1831
|
const isConversationRequest = method === 'POST' && isCompletionRequestPath(path);
|
|
1632
1832
|
const conversationBody = isConversationRequest
|
|
1633
1833
|
? parseRequestBodyObject(serializedReq.body, serializedReq.headers)
|
|
@@ -1638,13 +1838,22 @@ export class BuyerProxy {
|
|
|
1638
1838
|
// Internal marker from the system proxy: source profile used only for
|
|
1639
1839
|
// local conversation attribution. Stripped here so it never reaches a seller.
|
|
1640
1840
|
delete serializedReq.headers[SYSTEM_PROXY_SOURCE_HEADER];
|
|
1641
|
-
const
|
|
1642
|
-
?
|
|
1643
|
-
?? (conversationIdentity.parentSessionKey
|
|
1644
|
-
? this._conversations.getPinnedModel(conversationIdentity.tool, conversationIdentity.parentSessionKey)
|
|
1645
|
-
: null)
|
|
1841
|
+
const trackedConversationKey = conversationIdentity
|
|
1842
|
+
? conversationIdentity.parentSessionKey ?? conversationIdentity.sessionKey
|
|
1646
1843
|
: null;
|
|
1844
|
+
const storedConversation = conversationIdentity && trackedConversationKey
|
|
1845
|
+
? this._conversations.get(`${conversationIdentity.tool}:${trackedConversationKey}`)
|
|
1846
|
+
: null;
|
|
1847
|
+
const storedAutoRoute = storedConversation?.peerSource === 'auto' && storedConversation.pinnedModel
|
|
1848
|
+
? parsePeerPinnedService(storedConversation.pinnedModel)
|
|
1849
|
+
: null;
|
|
1850
|
+
const chatPinnedModel = storedConversation?.peerSource === 'user'
|
|
1851
|
+
? storedConversation.pinnedModel
|
|
1852
|
+
: storedAutoRoute?.service ?? storedConversation?.pinnedModel ?? null;
|
|
1853
|
+
const preferredPeerHeader = normalizePeerId(serializedReq.headers['x-antseed-prefer-peer'] ?? '');
|
|
1854
|
+
const preferredConversationPeerId = preferredPeerHeader ?? storedAutoRoute?.peerId ?? null;
|
|
1647
1855
|
const effectiveRoutedModel = chatPinnedModel ?? this._defaultRoutedModel;
|
|
1856
|
+
let trackedConversationId = storedConversation?.id ?? null;
|
|
1648
1857
|
// Resolve the `antseed` model alias to the session's default route first,
|
|
1649
1858
|
// so the regular `<peerId>@<service>` pin rewrite below picks up the
|
|
1650
1859
|
// substituted value. Tool configs written by the desktop carry the alias
|
|
@@ -1713,6 +1922,13 @@ export class BuyerProxy {
|
|
|
1713
1922
|
snippet,
|
|
1714
1923
|
lastModel: titleTurn ? null : resolvedModel,
|
|
1715
1924
|
});
|
|
1925
|
+
const explicitConversationPin = resolvedModel && (parsePeerPinnedService(rawModel)
|
|
1926
|
+
|| (aliasResult.substituted && parsePeerPinnedService(effectiveRoutedModel ?? ''))
|
|
1927
|
+
|| (chatPinOverrideApplied && parsePeerPinnedService(chatPinnedModel ?? '')));
|
|
1928
|
+
if (explicitConversationPin) {
|
|
1929
|
+
this._conversations.setPinnedModel(tracked.id, resolvedModel, 'user');
|
|
1930
|
+
}
|
|
1931
|
+
trackedConversationId = tracked.id;
|
|
1716
1932
|
// Bind the request to the chat so its cost can be attributed when the
|
|
1717
1933
|
// payment layer signs for it (see _attributeSpend).
|
|
1718
1934
|
this._trackRequestConversation(serializedReq.requestId, tracked.id);
|
|
@@ -1749,21 +1965,10 @@ export class BuyerProxy {
|
|
|
1749
1965
|
const explicitProvider = getExplicitProviderOverride(serializedReq);
|
|
1750
1966
|
const explicitPeerId = getExplicitPeerIdOverride(serializedReq, effectivePinnedPeer ?? undefined, bodyPinnedPeer);
|
|
1751
1967
|
log(`Routing hints: provider=${explicitProvider ?? 'auto'} pin-peer=${explicitPeerId ?? 'none'}`);
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
//
|
|
1757
|
-
// Surface the error in the structured shape OpenAI/Anthropic SDKs expect
|
|
1758
|
-
// (`{ error: { type, code, message, ... } }`) so callers see a proper
|
|
1759
|
-
// .message on their error objects instead of a raw text/plain body. We
|
|
1760
|
-
// use HTTP 400 — the request is missing required information the buyer
|
|
1761
|
-
// cannot infer on its own — which is what SDK retry/error logic treats
|
|
1762
|
-
// as a non-retryable client mistake.
|
|
1763
|
-
if (!explicitPeerId) {
|
|
1764
|
-
log('Request rejected: no peer pinned');
|
|
1765
|
-
const errorMessage = 'No peer pinned. Auto-selection is disabled.\n'
|
|
1766
|
-
+ 'Pin a peer one of three ways:\n'
|
|
1968
|
+
if (!explicitPeerId && !requestedService) {
|
|
1969
|
+
log('Request rejected: no peer pinned and no model requested');
|
|
1970
|
+
const errorMessage = 'No model or peer was specified.\n'
|
|
1971
|
+
+ 'Set the request model, or pin a peer one of three ways:\n'
|
|
1767
1972
|
+ ' • Per-request header: x-antseed-pin-peer: <peerId> (40-char hex EVM address)\n'
|
|
1768
1973
|
+ ' • Model name prefix: <peerId>@<model>\n'
|
|
1769
1974
|
+ ' • Session pin: antseed buyer connection set --peer <peerId>\n'
|
|
@@ -1771,10 +1976,10 @@ export class BuyerProxy {
|
|
|
1771
1976
|
res.writeHead(400, { 'content-type': 'application/json' });
|
|
1772
1977
|
res.end(JSON.stringify({
|
|
1773
1978
|
error: {
|
|
1774
|
-
type: '
|
|
1775
|
-
code: '
|
|
1979
|
+
type: 'missing_routing_target',
|
|
1980
|
+
code: 'missing_routing_target',
|
|
1776
1981
|
message: errorMessage,
|
|
1777
|
-
param: '
|
|
1982
|
+
param: 'model',
|
|
1778
1983
|
help: {
|
|
1779
1984
|
perRequestHeader: 'x-antseed-pin-peer: <peerId>',
|
|
1780
1985
|
modelPrefix: '<peerId>@<model>',
|
|
@@ -1793,6 +1998,146 @@ export class BuyerProxy {
|
|
|
1793
1998
|
res.end('No sellers available on the network. Is a seeder running?');
|
|
1794
1999
|
return;
|
|
1795
2000
|
}
|
|
2001
|
+
if (!explicitPeerId && requestedService) {
|
|
2002
|
+
const selectModelPeers = (candidateSources) => selectCandidatePeersForRouting(candidateSources, requestProtocol, requestedService, explicitProvider, 'strict');
|
|
2003
|
+
let discoveredPeers = peers;
|
|
2004
|
+
let { candidatePeers: modelPeers, routePlanByPeerId: modelPlans } = selectModelPeers(discoveredPeers);
|
|
2005
|
+
const cacheAgeMs = Date.now() - this._cacheLastUpdatedAtMs;
|
|
2006
|
+
if (modelPeers.length === 0 || cacheAgeMs > this._peerCacheTtlMs) {
|
|
2007
|
+
discoveredPeers = await this._getPeers({ forceRefresh: true });
|
|
2008
|
+
({ candidatePeers: modelPeers, routePlanByPeerId: modelPlans } = selectModelPeers(discoveredPeers));
|
|
2009
|
+
}
|
|
2010
|
+
const router = this._node.router;
|
|
2011
|
+
const policyRouter = router;
|
|
2012
|
+
const routeCandidates = modelPeers
|
|
2013
|
+
.map((peer) => {
|
|
2014
|
+
const plan = modelPlans.get(peer.peerId)
|
|
2015
|
+
?? resolvePeerRoutePlan(peer, requestProtocol, requestedService, explicitProvider, 'strict');
|
|
2016
|
+
if (!plan?.serviceId)
|
|
2017
|
+
return null;
|
|
2018
|
+
const offer = findAdvertisedServiceOffer(peer, plan.provider, plan.serviceId);
|
|
2019
|
+
if (!offer)
|
|
2020
|
+
return null;
|
|
2021
|
+
const requestForPolicy = withRoutedModel(serializedReq, plan.serviceId);
|
|
2022
|
+
if (!peerAllowedByPolicy(policyRouter, requestForPolicy, peer))
|
|
2023
|
+
return null;
|
|
2024
|
+
return {
|
|
2025
|
+
peer,
|
|
2026
|
+
serviceId: plan.serviceId,
|
|
2027
|
+
request: requestForPolicy,
|
|
2028
|
+
reputation: normalizedModelReputationScore(peer, this._now()) ?? -1,
|
|
2029
|
+
hasCachedInputPricing: offer.cachedInputUsdPerMillion !== undefined,
|
|
2030
|
+
inputUsdPerMillion: offer.inputUsdPerMillion ?? null,
|
|
2031
|
+
outputUsdPerMillion: offer.outputUsdPerMillion ?? null,
|
|
2032
|
+
minImageUsdPerImage: offer.minImageUsdPerImage ?? null,
|
|
2033
|
+
};
|
|
2034
|
+
})
|
|
2035
|
+
.filter((candidate) => candidate !== null);
|
|
2036
|
+
const now = this._now();
|
|
2037
|
+
const preferCachedPricing = routeCandidates.some((candidate) => candidate.hasCachedInputPricing);
|
|
2038
|
+
const ranked = routeCandidates.map((candidate) => {
|
|
2039
|
+
const health = this._peerHealth.get(candidate.peer.peerId);
|
|
2040
|
+
return {
|
|
2041
|
+
...candidate,
|
|
2042
|
+
peerId: candidate.peer.peerId,
|
|
2043
|
+
effectiveReputationScore: effectiveModelReputationScore(candidate.reputation >= 0 ? candidate.reputation : null, candidate.hasCachedInputPricing, preferCachedPricing),
|
|
2044
|
+
peerCooldownUntil: health?.cooldownUntil ?? null,
|
|
2045
|
+
peerFailureStreak: health?.failureStreak ?? 0,
|
|
2046
|
+
};
|
|
2047
|
+
});
|
|
2048
|
+
let candidates;
|
|
2049
|
+
const routingPreferences = this._routingPreferences;
|
|
2050
|
+
if (routingPreferences) {
|
|
2051
|
+
candidates = rankModelRoutes(ranked, routingPreferences, now)
|
|
2052
|
+
.filter((candidate) => isModelRouteEligible(candidate, routingPreferences));
|
|
2053
|
+
}
|
|
2054
|
+
else {
|
|
2055
|
+
ranked.sort((a, b) => (b.effectiveReputationScore ?? -1) - (a.effectiveReputationScore ?? -1)
|
|
2056
|
+
|| a.peer.peerId.localeCompare(b.peer.peerId));
|
|
2057
|
+
const ready = ranked.filter((candidate) => !isCoolingDown(this._peerHealth.get(candidate.peer.peerId), now));
|
|
2058
|
+
candidates = ready.length > 0 ? ready : ranked;
|
|
2059
|
+
}
|
|
2060
|
+
if (preferredConversationPeerId) {
|
|
2061
|
+
const preferredIndex = candidates.findIndex((candidate) => (candidate.peer.peerId.toLowerCase() === preferredConversationPeerId
|
|
2062
|
+
&& !isCoolingDown(this._peerHealth.get(candidate.peer.peerId), now)));
|
|
2063
|
+
if (preferredIndex > 0) {
|
|
2064
|
+
const [preferred] = candidates.splice(preferredIndex, 1);
|
|
2065
|
+
if (preferred)
|
|
2066
|
+
candidates.unshift(preferred);
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
if (candidates.length === 0) {
|
|
2070
|
+
res.writeHead(502, { 'content-type': 'application/json' });
|
|
2071
|
+
res.end(JSON.stringify({
|
|
2072
|
+
error: {
|
|
2073
|
+
type: 'model_not_found',
|
|
2074
|
+
code: 'model_not_found',
|
|
2075
|
+
message: `No policy-allowed peer currently serves model "${requestedService}".`,
|
|
2076
|
+
param: 'model',
|
|
2077
|
+
},
|
|
2078
|
+
}));
|
|
2079
|
+
return;
|
|
2080
|
+
}
|
|
2081
|
+
let lastRetry = null;
|
|
2082
|
+
let lastVerificationError = null;
|
|
2083
|
+
for (const [index, selected] of candidates.entries()) {
|
|
2084
|
+
if (this._verifier) {
|
|
2085
|
+
const makeReach = (chosenId) => makeVerifierReach(this._node, selected.peer, chosenId, clientAbortController.signal);
|
|
2086
|
+
const outcome = await this._verifyPeer(selected.peer, makeReach, clientAbortController.signal);
|
|
2087
|
+
if (!outcome.ok) {
|
|
2088
|
+
lastVerificationError = `Peer ${selected.peer.peerId.slice(0, 12)}... failed required verification (${outcome.reason ?? 'failed'}).`;
|
|
2089
|
+
log(`${lastVerificationError} Trying the next model peer.`);
|
|
2090
|
+
continue;
|
|
2091
|
+
}
|
|
2092
|
+
}
|
|
2093
|
+
for (let peerAttempt = 0; peerAttempt < MODEL_RATE_LIMIT_MAX_ATTEMPTS_PER_PEER; peerAttempt += 1) {
|
|
2094
|
+
log(`Auto-selected peer ${selected.peer.peerId.slice(0, 12)}... for model="${requestedService}" `
|
|
2095
|
+
+ `service="${selected.serviceId}" reputation=${selected.reputation} `
|
|
2096
|
+
+ `effective=${selected.effectiveReputationScore ?? 'unknown'} peer=${index + 1}/${candidates.length} `
|
|
2097
|
+
+ `attempt=${peerAttempt + 1}/${MODEL_RATE_LIMIT_MAX_ATTEMPTS_PER_PEER}`);
|
|
2098
|
+
const result = await this._dispatchToPeer(res, selected.request, selected.peer, modelPlans, requestProtocol, selected.serviceId, explicitProvider, router, RETRYABLE_STATUS_CODES, clientAbortController.signal);
|
|
2099
|
+
if (result.done) {
|
|
2100
|
+
if (trackedConversationId) {
|
|
2101
|
+
this._conversations.recordRoutedModel(trackedConversationId, `${selected.peer.peerId}@${selected.serviceId}`);
|
|
2102
|
+
}
|
|
2103
|
+
return;
|
|
2104
|
+
}
|
|
2105
|
+
lastRetry = result;
|
|
2106
|
+
if (result.responseHeaders[ANTSEED_FAULT_ATTRIBUTION_HEADER]?.toLowerCase() === 'buyer') {
|
|
2107
|
+
res.writeHead(result.statusCode, result.responseHeaders);
|
|
2108
|
+
res.end(result.responseBody);
|
|
2109
|
+
return;
|
|
2110
|
+
}
|
|
2111
|
+
const retrySamePeer = result.statusCode === 429
|
|
2112
|
+
&& peerAttempt + 1 < MODEL_RATE_LIMIT_MAX_ATTEMPTS_PER_PEER;
|
|
2113
|
+
if (!retrySamePeer)
|
|
2114
|
+
break;
|
|
2115
|
+
const delayMs = rateLimitRetryDelayMs(result.responseHeaders, peerAttempt);
|
|
2116
|
+
log(`Peer ${selected.peer.peerId.slice(0, 12)}... is rate-limited; retrying in ${delayMs}ms.`);
|
|
2117
|
+
if (!await waitForRetry(delayMs, clientAbortController.signal))
|
|
2118
|
+
return;
|
|
2119
|
+
}
|
|
2120
|
+
if (index + 1 < candidates.length) {
|
|
2121
|
+
log(`Peer ${selected.peer.peerId.slice(0, 12)}... failed retryably; trying next model peer.`);
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
if (lastRetry) {
|
|
2125
|
+
res.writeHead(lastRetry.statusCode, lastRetry.responseHeaders);
|
|
2126
|
+
res.end(lastRetry.responseBody);
|
|
2127
|
+
}
|
|
2128
|
+
else {
|
|
2129
|
+
res.writeHead(502, { 'content-type': 'application/json' });
|
|
2130
|
+
res.end(JSON.stringify({
|
|
2131
|
+
error: {
|
|
2132
|
+
type: 'peer_verification_failed',
|
|
2133
|
+
code: 'peer_verification_failed',
|
|
2134
|
+
message: lastVerificationError ?? `No verified peer currently serves model "${requestedService}".`,
|
|
2135
|
+
},
|
|
2136
|
+
}));
|
|
2137
|
+
}
|
|
2138
|
+
return;
|
|
2139
|
+
}
|
|
2140
|
+
const pinnedPeerId = explicitPeerId;
|
|
1796
2141
|
// Narrow the candidate set to just the pinned peer (if we already know
|
|
1797
2142
|
// about it) before running the per-peer protocol/service match. This
|
|
1798
2143
|
// avoids wasting work — and spamming "Service strict-miss" log lines —
|
|
@@ -1800,7 +2145,7 @@ export class BuyerProxy {
|
|
|
1800
2145
|
// fall through with the full list so the "not in candidate set → force
|
|
1801
2146
|
// refresh" path still works.
|
|
1802
2147
|
const narrowToPinned = (sources) => {
|
|
1803
|
-
const match = sources.find((p) => p.peerId.toLowerCase() ===
|
|
2148
|
+
const match = sources.find((p) => p.peerId.toLowerCase() === pinnedPeerId);
|
|
1804
2149
|
return match ? [match] : sources;
|
|
1805
2150
|
};
|
|
1806
2151
|
const selectPeers = (candidateSources) => selectCandidatePeersForRouting(narrowToPinned(candidateSources), requestProtocol, requestedService, explicitProvider, 'lenient');
|
|
@@ -1821,7 +2166,7 @@ export class BuyerProxy {
|
|
|
1821
2166
|
let routingPeers = candidatePeers;
|
|
1822
2167
|
let routingPlans = routePlanByPeerId;
|
|
1823
2168
|
let discoveredPeers = peers;
|
|
1824
|
-
const isPinnedDiscovered = () => discoveredPeers.some((peer) => peer.peerId.toLowerCase() ===
|
|
2169
|
+
const isPinnedDiscovered = () => discoveredPeers.some((peer) => peer.peerId.toLowerCase() === pinnedPeerId);
|
|
1825
2170
|
// Single refresh guard covers all three doubts about the cache: pin
|
|
1826
2171
|
// missing, candidate filter empty, or cache past TTL.
|
|
1827
2172
|
const cacheAgeMs = Date.now() - this._cacheLastUpdatedAtMs;
|
|
@@ -1837,16 +2182,16 @@ export class BuyerProxy {
|
|
|
1837
2182
|
? 'model peer prefix'
|
|
1838
2183
|
: '--peer flag or session pin';
|
|
1839
2184
|
const diagnostics = this._formatPeerSelectionDiagnostics(discoveredPeers);
|
|
1840
|
-
log(`Pinned peer ${
|
|
2185
|
+
log(`Pinned peer ${pinnedPeerId.slice(0, 12)}... not discoverable in DHT (${logSource})`);
|
|
1841
2186
|
res.writeHead(502, { 'content-type': 'text/plain' });
|
|
1842
|
-
res.end(`Pinned peer ${
|
|
2187
|
+
res.end(`Pinned peer ${pinnedPeerId.slice(0, 12)}... is not reachable right now. `
|
|
1843
2188
|
+ 'It may be offline, not announcing, or temporarily unreachable. '
|
|
1844
2189
|
+ 'Pick a different service in Discover or try again later. '
|
|
1845
2190
|
+ diagnostics);
|
|
1846
2191
|
return;
|
|
1847
2192
|
}
|
|
1848
2193
|
if (routingPeers.length === 0) {
|
|
1849
|
-
const pinnedPeer = discoveredPeers.find((peer) => peer.peerId.toLowerCase() ===
|
|
2194
|
+
const pinnedPeer = discoveredPeers.find((peer) => peer.peerId.toLowerCase() === pinnedPeerId) ?? null;
|
|
1850
2195
|
const protocolLabel = requestProtocol ? `protocol=${requestProtocol}` : 'protocol=unknown';
|
|
1851
2196
|
const providerLabel = explicitProvider ? `provider=${explicitProvider}` : 'provider=auto';
|
|
1852
2197
|
const serviceLabel = requestedService ? `service=${requestedService}` : 'service=none';
|
|
@@ -1857,41 +2202,40 @@ export class BuyerProxy {
|
|
|
1857
2202
|
.filter((provider) => provider.length > 0);
|
|
1858
2203
|
if (!providers.includes(explicitProvider)) {
|
|
1859
2204
|
const providerList = providers.length > 0 ? providers.join(', ') : 'none';
|
|
1860
|
-
log(`Pinned peer ${
|
|
2205
|
+
log(`Pinned peer ${pinnedPeerId.slice(0, 12)}... does not offer explicit provider=${explicitProvider}`);
|
|
1861
2206
|
res.writeHead(502, { 'content-type': 'text/plain' });
|
|
1862
|
-
res.end(`Pinned peer ${
|
|
2207
|
+
res.end(`Pinned peer ${pinnedPeerId.slice(0, 12)}... does not offer provider=${explicitProvider}. `
|
|
1863
2208
|
+ `Available providers: ${providerList}. `
|
|
1864
2209
|
+ 'Remove or change the x-antseed-provider header, or pick a different peer. '
|
|
1865
2210
|
+ diagnostics);
|
|
1866
2211
|
return;
|
|
1867
2212
|
}
|
|
1868
2213
|
}
|
|
1869
|
-
log(`Pinned peer ${
|
|
2214
|
+
log(`Pinned peer ${pinnedPeerId.slice(0, 12)}... filtered out by protocol/service match`);
|
|
1870
2215
|
res.writeHead(502, { 'content-type': 'text/plain' });
|
|
1871
|
-
res.end(`Pinned peer ${
|
|
2216
|
+
res.end(`Pinned peer ${pinnedPeerId.slice(0, 12)}... does not support this request `
|
|
1872
2217
|
+ `(${protocolLabel}, ${providerLabel}, ${serviceLabel}). `
|
|
1873
2218
|
+ `Pick a different service in Discover. ${diagnostics}`);
|
|
1874
2219
|
return;
|
|
1875
2220
|
}
|
|
1876
2221
|
log(`Routing candidates: ${routingPeers.length} peer(s)`);
|
|
1877
2222
|
const router = this._node.router;
|
|
1878
|
-
const selectedPeer = routingPeers.find((p) => p.peerId.toLowerCase() ===
|
|
2223
|
+
const selectedPeer = routingPeers.find((p) => p.peerId.toLowerCase() === pinnedPeerId) ?? null;
|
|
1879
2224
|
// Defence in depth: the discovered+narrowed checks above should make this
|
|
1880
2225
|
// branch unreachable. Keep a structured fallback so we don't silently hang
|
|
1881
2226
|
// if an invariant breaks.
|
|
1882
2227
|
if (!selectedPeer) {
|
|
1883
|
-
log(`Invariant: pinned peer ${
|
|
2228
|
+
log(`Invariant: pinned peer ${pinnedPeerId.slice(0, 12)}... present in DHT but missing from narrowed candidate list`);
|
|
1884
2229
|
res.writeHead(502, { 'content-type': 'text/plain' });
|
|
1885
|
-
res.end(`Pinned peer ${
|
|
2230
|
+
res.end(`Pinned peer ${pinnedPeerId.slice(0, 12)}... is currently unreachable. Try again in a moment.`);
|
|
1886
2231
|
return;
|
|
1887
2232
|
}
|
|
1888
2233
|
const policyRouter = router;
|
|
1889
|
-
const
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
if (!policyAllowed) {
|
|
2234
|
+
const selectedPlan = routingPlans.get(selectedPeer.peerId)
|
|
2235
|
+
?? resolvePeerRoutePlan(selectedPeer, requestProtocol, requestedService, explicitProvider, 'lenient');
|
|
2236
|
+
const pinnedServiceId = selectedPlan?.serviceId ?? requestedService;
|
|
2237
|
+
const pinnedRequest = pinnedServiceId ? withRoutedModel(serializedReq, pinnedServiceId) : serializedReq;
|
|
2238
|
+
if (!peerAllowedByPolicy(policyRouter, pinnedRequest, selectedPeer)) {
|
|
1895
2239
|
log(`Pinned peer ${selectedPeer.peerId.slice(0, 12)}... filtered out by buyer routing policy`);
|
|
1896
2240
|
res.writeHead(502, { 'content-type': 'text/plain' });
|
|
1897
2241
|
res.end(`Pinned peer ${selectedPeer.peerId.slice(0, 12)}... is outside your buyer routing policy. `
|
|
@@ -1916,10 +2260,13 @@ export class BuyerProxy {
|
|
|
1916
2260
|
}
|
|
1917
2261
|
}
|
|
1918
2262
|
log(`Using pinned peer ${selectedPeer.peerId.slice(0, 12)}...`);
|
|
1919
|
-
const result = await this._dispatchToPeer(res,
|
|
2263
|
+
const result = await this._dispatchToPeer(res, pinnedRequest, selectedPeer, routingPlans, requestProtocol, requestedService, explicitProvider, router, RETRYABLE_STATUS_CODES, clientAbortController.signal);
|
|
2264
|
+
if (result.done && trackedConversationId && pinnedServiceId) {
|
|
2265
|
+
this._conversations.recordRoutedModel(trackedConversationId, `${selectedPeer.peerId}@${pinnedServiceId}`);
|
|
2266
|
+
}
|
|
1920
2267
|
if (!result.done) {
|
|
1921
2268
|
// Pinned peer returned a retryable error. We never retry against another
|
|
1922
|
-
// peer
|
|
2269
|
+
// peer for an explicit pin, so surface the error to the client.
|
|
1923
2270
|
res.writeHead(result.statusCode, result.responseHeaders);
|
|
1924
2271
|
res.end(result.responseBody);
|
|
1925
2272
|
}
|
|
@@ -2001,7 +2348,7 @@ export class BuyerProxy {
|
|
|
2001
2348
|
+ `did not announce for this service: ${unannounced.join(', ')} — the upstream may ignore or reject them`);
|
|
2002
2349
|
}
|
|
2003
2350
|
}
|
|
2004
|
-
const { 'x-antseed-pin-peer': _pinPeer, 'x-antseed-prefer-peer': _preferPeer, ...headersForPeer } = serializedReq.headers;
|
|
2351
|
+
const { 'x-antseed-pin-peer': _pinPeer, 'x-antseed-prefer-peer': _preferPeer, 'x-antstation-session-id': _antstationSession, ...headersForPeer } = serializedReq.headers;
|
|
2005
2352
|
let requestForPeer = {
|
|
2006
2353
|
...serializedReq,
|
|
2007
2354
|
headers: {
|
|
@@ -2009,6 +2356,9 @@ export class BuyerProxy {
|
|
|
2009
2356
|
'x-antseed-provider': selectedRoutePlan.provider,
|
|
2010
2357
|
},
|
|
2011
2358
|
};
|
|
2359
|
+
if (selectedRoutePlan.serviceId) {
|
|
2360
|
+
requestForPeer = withRoutedModel(requestForPeer, selectedRoutePlan.serviceId);
|
|
2361
|
+
}
|
|
2012
2362
|
const clientWantsStreaming = requestWantsStreaming(serializedReq.headers, serializedReq.body);
|
|
2013
2363
|
let adaptResponse = null;
|
|
2014
2364
|
let streamResponseAdapter = null;
|