@antseed/cli 0.1.148 → 0.1.150
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 +51 -17
- package/dist/cli/commands/buyer/activity.d.ts +3 -0
- package/dist/cli/commands/buyer/activity.d.ts.map +1 -0
- package/dist/cli/commands/buyer/activity.js +555 -0
- package/dist/cli/commands/buyer/activity.js.map +1 -0
- package/dist/cli/commands/buyer/daemon.d.ts +13 -0
- package/dist/cli/commands/buyer/daemon.d.ts.map +1 -0
- package/dist/cli/commands/buyer/daemon.js +39 -0
- package/dist/cli/commands/buyer/daemon.js.map +1 -0
- package/dist/cli/commands/buyer/deposit.d.ts +8 -0
- package/dist/cli/commands/buyer/deposit.d.ts.map +1 -1
- package/dist/cli/commands/buyer/deposit.js +338 -25
- package/dist/cli/commands/buyer/deposit.js.map +1 -1
- package/dist/cli/commands/buyer/index.d.ts.map +1 -1
- package/dist/cli/commands/buyer/index.js +2 -0
- package/dist/cli/commands/buyer/index.js.map +1 -1
- 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 +53 -10
- 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/buyer/sweep.d.ts.map +1 -1
- package/dist/cli/commands/buyer/sweep.js +17 -17
- package/dist/cli/commands/buyer/sweep.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/cli/commands/payments.d.ts.map +1 -1
- package/dist/cli/commands/payments.js +29 -34
- package/dist/cli/commands/payments.js.map +1 -1
- package/dist/cli/commands/seller/start.d.ts.map +1 -1
- package/dist/cli/commands/seller/start.js +4 -1
- package/dist/cli/commands/seller/start.js.map +1 -1
- package/dist/cli/commands/seller/start.test.js +15 -0
- package/dist/cli/commands/seller/start.test.js.map +1 -1
- package/dist/cli/index.js +2 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/config/defaults.d.ts.map +1 -1
- package/dist/config/defaults.js +7 -0
- package/dist/config/defaults.js.map +1 -1
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +41 -0
- package/dist/config/loader.js.map +1 -1
- package/dist/config/loader.test.js +52 -0
- package/dist/config/loader.test.js.map +1 -1
- package/dist/config/types.d.ts +10 -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/generated/baked-defaults.d.ts +15 -0
- package/dist/generated/baked-defaults.d.ts.map +1 -0
- package/dist/generated/baked-defaults.js +15 -0
- package/dist/generated/baked-defaults.js.map +1 -0
- package/dist/plugins/manager.d.ts +1 -0
- package/dist/plugins/manager.d.ts.map +1 -1
- package/dist/plugins/manager.js +15 -4
- package/dist/plugins/manager.js.map +1 -1
- package/dist/plugins/manager.test.d.ts +2 -0
- package/dist/plugins/manager.test.d.ts.map +1 -0
- package/dist/plugins/manager.test.js +15 -0
- package/dist/plugins/manager.test.js.map +1 -0
- package/dist/proxy/buyer-proxy.d.ts +31 -2
- package/dist/proxy/buyer-proxy.d.ts.map +1 -1
- package/dist/proxy/buyer-proxy.js +481 -59
- package/dist/proxy/buyer-proxy.js.map +1 -1
- package/dist/proxy/buyer-proxy.test.js +894 -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/deposit-watcher.d.ts +125 -0
- package/dist/proxy/deposit-watcher.d.ts.map +1 -0
- package/dist/proxy/deposit-watcher.js +306 -0
- package/dist/proxy/deposit-watcher.js.map +1 -0
- package/dist/proxy/deposit-watcher.test.d.ts +2 -0
- package/dist/proxy/deposit-watcher.test.d.ts.map +1 -0
- package/dist/proxy/deposit-watcher.test.js +173 -0
- package/dist/proxy/deposit-watcher.test.js.map +1 -0
- 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 +6 -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;
|
|
@@ -544,6 +609,12 @@ export class BuyerProxy {
|
|
|
544
609
|
_now;
|
|
545
610
|
/** Latest relayer receipt per sweep authNonce, for CLI progress polling. */
|
|
546
611
|
_sweepReceipts = new Map();
|
|
612
|
+
/**
|
|
613
|
+
* Hot-wallet deposit watcher (auto-sweep). Owned by `buyer start`, attached
|
|
614
|
+
* here so the desktop and `antseed deposit` can drive it over the control
|
|
615
|
+
* plane instead of running a second signer against the same wallet.
|
|
616
|
+
*/
|
|
617
|
+
_depositWatcher = null;
|
|
547
618
|
/**
|
|
548
619
|
* requestId -> the conversation that issued it, so the node's per-request
|
|
549
620
|
* spend events can be attributed to a chat. Only the proxy knows this
|
|
@@ -561,8 +632,16 @@ export class BuyerProxy {
|
|
|
561
632
|
this._peerCacheTtlMs = Math.max(0, config.peerCacheTtlMs ?? Math.max(6 * 60_000, this._bgRefreshIntervalMs + 60_000));
|
|
562
633
|
this._stateDir = config.dataDir;
|
|
563
634
|
this._stateFile = join(config.dataDir, 'buyer.state.json');
|
|
635
|
+
this._configPath = config.configPath ?? null;
|
|
564
636
|
this._conversations = new ConversationStore(config.dataDir);
|
|
565
637
|
this._pinnedPeer = config.pinnedPeerId?.toLowerCase() ?? null;
|
|
638
|
+
this._routingPreferences = config.routingPreferences
|
|
639
|
+
? {
|
|
640
|
+
...config.routingPreferences,
|
|
641
|
+
allowedPeerIds: [...config.routingPreferences.allowedPeerIds],
|
|
642
|
+
blockedPeerIds: [...config.routingPreferences.blockedPeerIds],
|
|
643
|
+
}
|
|
644
|
+
: null;
|
|
566
645
|
this._now = config.now ?? (() => Date.now());
|
|
567
646
|
this._server = createServer((req, res) => {
|
|
568
647
|
this._handleRequest(req, res).catch((err) => {
|
|
@@ -629,6 +708,14 @@ export class BuyerProxy {
|
|
|
629
708
|
this._requestConversations.delete(oldest);
|
|
630
709
|
}
|
|
631
710
|
}
|
|
711
|
+
/** Attach the hot-wallet deposit watcher (owned by `buyer start`). */
|
|
712
|
+
setDepositWatcher(watcher) {
|
|
713
|
+
this._depositWatcher = watcher;
|
|
714
|
+
}
|
|
715
|
+
/** Latest relayer receipt for a sweep authNonce, if one has arrived. */
|
|
716
|
+
getSweepReceipt(authNonce) {
|
|
717
|
+
return this._sweepReceipts.get(authNonce.toLowerCase()) ?? null;
|
|
718
|
+
}
|
|
632
719
|
async start() {
|
|
633
720
|
this._startedAtMs = Date.now();
|
|
634
721
|
// Clean up temp files orphaned by state writes whose rename failed in a
|
|
@@ -661,6 +748,7 @@ export class BuyerProxy {
|
|
|
661
748
|
this._startIncrementalDiscoverySweep();
|
|
662
749
|
await this._writeStateFile('connected');
|
|
663
750
|
this._watchStateFile();
|
|
751
|
+
this._watchConfigFile();
|
|
664
752
|
}
|
|
665
753
|
async _hydratePeersFromStateFile() {
|
|
666
754
|
try {
|
|
@@ -699,6 +787,14 @@ export class BuyerProxy {
|
|
|
699
787
|
unwatchFile(this._stateFile);
|
|
700
788
|
this._stateFileWatching = false;
|
|
701
789
|
}
|
|
790
|
+
if (this._configWatchDebounce) {
|
|
791
|
+
clearTimeout(this._configWatchDebounce);
|
|
792
|
+
this._configWatchDebounce = null;
|
|
793
|
+
}
|
|
794
|
+
if (this._configFileWatching && this._configPath) {
|
|
795
|
+
unwatchFile(this._configPath);
|
|
796
|
+
this._configFileWatching = false;
|
|
797
|
+
}
|
|
702
798
|
if (this._bgRefreshHandle) {
|
|
703
799
|
clearInterval(this._bgRefreshHandle);
|
|
704
800
|
this._bgRefreshHandle = null;
|
|
@@ -747,13 +843,51 @@ export class BuyerProxy {
|
|
|
747
843
|
this._pinnedPeer = pinnedPeer;
|
|
748
844
|
}
|
|
749
845
|
const routedModel = typeof parsed.defaultRoutedModel === 'string' ? parsed.defaultRoutedModel.trim() : '';
|
|
750
|
-
this._defaultRoutedModel =
|
|
846
|
+
this._defaultRoutedModel = routedModel.length > 0 && isValidRoutedModelTarget(routedModel) ? routedModel : null;
|
|
751
847
|
log(`Session overrides reloaded: peer=${this._pinnedPeer ?? 'none'} route=${this._defaultRoutedModel ?? 'none'}`);
|
|
752
848
|
}
|
|
753
849
|
catch {
|
|
754
850
|
// state file unreadable; keep current values
|
|
755
851
|
}
|
|
756
852
|
}
|
|
853
|
+
_watchConfigFile() {
|
|
854
|
+
if (!this._configPath)
|
|
855
|
+
return;
|
|
856
|
+
try {
|
|
857
|
+
watchFile(this._configPath, { persistent: false, interval: 500 }, (curr, prev) => {
|
|
858
|
+
if (curr.mtimeMs === prev.mtimeMs && curr.ino === prev.ino)
|
|
859
|
+
return;
|
|
860
|
+
if (this._configWatchDebounce)
|
|
861
|
+
clearTimeout(this._configWatchDebounce);
|
|
862
|
+
this._configWatchDebounce = setTimeout(() => {
|
|
863
|
+
this._configWatchDebounce = null;
|
|
864
|
+
void this._reloadRoutingPreferences().catch(() => { });
|
|
865
|
+
}, 50);
|
|
866
|
+
});
|
|
867
|
+
this._configFileWatching = true;
|
|
868
|
+
}
|
|
869
|
+
catch {
|
|
870
|
+
// Config watcher failure is non-fatal; startup preferences remain active.
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
async _reloadRoutingPreferences() {
|
|
874
|
+
if (!this._configPath)
|
|
875
|
+
return;
|
|
876
|
+
try {
|
|
877
|
+
const config = await loadConfig(this._configPath);
|
|
878
|
+
const next = config.buyer.routingPreferences;
|
|
879
|
+
this._routingPreferences = {
|
|
880
|
+
...next,
|
|
881
|
+
allowedPeerIds: [...next.allowedPeerIds],
|
|
882
|
+
blockedPeerIds: [...next.blockedPeerIds],
|
|
883
|
+
};
|
|
884
|
+
log(`Routing preferences reloaded: minTrust=${next.minTrustScore} maxInput=${next.maxInputUsdPerMillion} `
|
|
885
|
+
+ `preferFree=${next.preferFreePeers} allow=${next.allowedPeerIds.length} block=${next.blockedPeerIds.length}`);
|
|
886
|
+
}
|
|
887
|
+
catch (err) {
|
|
888
|
+
log(`Routing preferences reload ignored: ${err instanceof Error ? err.message : String(err)}`);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
757
891
|
/** Stamp the last model-request activity time (dispatch or streamed frame). */
|
|
758
892
|
_markModelActivity() {
|
|
759
893
|
this._lastModelActivityAt = Date.now();
|
|
@@ -1309,9 +1443,9 @@ export class BuyerProxy {
|
|
|
1309
1443
|
res.end(JSON.stringify({ ok: false, error: 'Invalid JSON body' }));
|
|
1310
1444
|
return;
|
|
1311
1445
|
}
|
|
1312
|
-
if (model.length > 0 && !
|
|
1446
|
+
if (model.length > 0 && !isValidRoutedModelTarget(model)) {
|
|
1313
1447
|
res.writeHead(400, { 'content-type': 'application/json' });
|
|
1314
|
-
res.end(JSON.stringify({ ok: false, error: 'model must be "<peerId>@<service>"
|
|
1448
|
+
res.end(JSON.stringify({ ok: false, error: 'model must be "<service>", "<peerId>@<service>", or empty to clear' }));
|
|
1315
1449
|
return;
|
|
1316
1450
|
}
|
|
1317
1451
|
this._defaultRoutedModel = model.length > 0 ? model : null;
|
|
@@ -1326,6 +1460,24 @@ export class BuyerProxy {
|
|
|
1326
1460
|
res.end(JSON.stringify({ ok: true, conversations: this._conversations.list() }));
|
|
1327
1461
|
return;
|
|
1328
1462
|
}
|
|
1463
|
+
const conversationMatch = path.match(/^\/_antseed\/conversations\/(.+)$/);
|
|
1464
|
+
if (conversationMatch && method === 'GET') {
|
|
1465
|
+
let id = '';
|
|
1466
|
+
try {
|
|
1467
|
+
id = decodeURIComponent(conversationMatch[1] ?? '');
|
|
1468
|
+
}
|
|
1469
|
+
catch {
|
|
1470
|
+
res.writeHead(400, { 'content-type': 'application/json' });
|
|
1471
|
+
res.end(JSON.stringify({ ok: false, error: 'Invalid conversation id' }));
|
|
1472
|
+
return;
|
|
1473
|
+
}
|
|
1474
|
+
const conversation = this._conversations.get(id);
|
|
1475
|
+
res.writeHead(conversation ? 200 : 404, { 'content-type': 'application/json' });
|
|
1476
|
+
res.end(JSON.stringify(conversation
|
|
1477
|
+
? { ok: true, conversation }
|
|
1478
|
+
: { ok: false, error: 'Unknown conversation' }));
|
|
1479
|
+
return;
|
|
1480
|
+
}
|
|
1329
1481
|
if (path === '/_antseed/conversations/update' && method === 'POST') {
|
|
1330
1482
|
const chunks = [];
|
|
1331
1483
|
let totalSize = 0;
|
|
@@ -1367,9 +1519,9 @@ export class BuyerProxy {
|
|
|
1367
1519
|
}
|
|
1368
1520
|
if ('pinnedModel' in parsed) {
|
|
1369
1521
|
const pin = typeof parsed.pinnedModel === 'string' ? parsed.pinnedModel.trim() : '';
|
|
1370
|
-
if (pin.length > 0 && !
|
|
1522
|
+
if (pin.length > 0 && !isValidRoutedModelTarget(pin)) {
|
|
1371
1523
|
res.writeHead(400, { 'content-type': 'application/json' });
|
|
1372
|
-
res.end(JSON.stringify({ ok: false, error: 'pinnedModel must be "<peerId>@<service>"
|
|
1524
|
+
res.end(JSON.stringify({ ok: false, error: 'pinnedModel must be "<service>", "<peerId>@<service>", or empty to clear' }));
|
|
1373
1525
|
return;
|
|
1374
1526
|
}
|
|
1375
1527
|
// 'user' marks a seller the user chose for this specific chat — the
|
|
@@ -1546,6 +1698,62 @@ export class BuyerProxy {
|
|
|
1546
1698
|
}
|
|
1547
1699
|
return;
|
|
1548
1700
|
}
|
|
1701
|
+
// Hot-wallet deposit watcher (auto-sweep). The desktop and `antseed
|
|
1702
|
+
// deposit` drive the daemon's single watcher through these instead of
|
|
1703
|
+
// running a second signer against the same wallet.
|
|
1704
|
+
if (path === '/_antseed/deposits/status' && method === 'GET') {
|
|
1705
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
1706
|
+
res.end(JSON.stringify({
|
|
1707
|
+
ok: true,
|
|
1708
|
+
watcher: this._depositWatcher !== null,
|
|
1709
|
+
status: this._depositWatcher?.status() ?? null,
|
|
1710
|
+
}));
|
|
1711
|
+
return;
|
|
1712
|
+
}
|
|
1713
|
+
if (path === '/_antseed/deposits/watch' && method === 'POST') {
|
|
1714
|
+
const chunks = [];
|
|
1715
|
+
let totalSize = 0;
|
|
1716
|
+
for await (const chunk of req) {
|
|
1717
|
+
totalSize += chunk.length;
|
|
1718
|
+
if (totalSize > 1024) {
|
|
1719
|
+
res.writeHead(413, { 'content-type': 'application/json' });
|
|
1720
|
+
res.end(JSON.stringify({ ok: false, error: 'Request body too large' }));
|
|
1721
|
+
return;
|
|
1722
|
+
}
|
|
1723
|
+
chunks.push(chunk);
|
|
1724
|
+
}
|
|
1725
|
+
let mode;
|
|
1726
|
+
try {
|
|
1727
|
+
const body = JSON.parse(Buffer.concat(chunks).toString() || '{}');
|
|
1728
|
+
mode = String(body.mode ?? 'active');
|
|
1729
|
+
}
|
|
1730
|
+
catch {
|
|
1731
|
+
res.writeHead(400, { 'content-type': 'application/json' });
|
|
1732
|
+
res.end(JSON.stringify({ ok: false, error: 'Invalid JSON body' }));
|
|
1733
|
+
return;
|
|
1734
|
+
}
|
|
1735
|
+
if (mode !== 'active' && mode !== 'background') {
|
|
1736
|
+
res.writeHead(400, { 'content-type': 'application/json' });
|
|
1737
|
+
res.end(JSON.stringify({ ok: false, error: 'mode must be "active" or "background"' }));
|
|
1738
|
+
return;
|
|
1739
|
+
}
|
|
1740
|
+
const watcher = this._depositWatcher;
|
|
1741
|
+
if (!watcher) {
|
|
1742
|
+
res.writeHead(503, { 'content-type': 'application/json' });
|
|
1743
|
+
res.end(JSON.stringify({
|
|
1744
|
+
ok: false,
|
|
1745
|
+
error: 'Deposit watcher unavailable — payments are disabled or this chain has no deposit relay.',
|
|
1746
|
+
}));
|
|
1747
|
+
return;
|
|
1748
|
+
}
|
|
1749
|
+
if (mode === 'active')
|
|
1750
|
+
watcher.promote();
|
|
1751
|
+
else
|
|
1752
|
+
watcher.demote();
|
|
1753
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
1754
|
+
res.end(JSON.stringify({ ok: true, status: watcher.status() }));
|
|
1755
|
+
return;
|
|
1756
|
+
}
|
|
1549
1757
|
const sweepReceiptMatch = path.match(/^\/_antseed\/sweep\/(0x[0-9a-fA-F]{64})$/);
|
|
1550
1758
|
if (sweepReceiptMatch && method === 'GET') {
|
|
1551
1759
|
const receipt = this._sweepReceipts.get(sweepReceiptMatch[1].toLowerCase()) ?? null;
|
|
@@ -1556,6 +1764,63 @@ export class BuyerProxy {
|
|
|
1556
1764
|
res.writeHead(404, { 'content-type': 'application/json' });
|
|
1557
1765
|
res.end(JSON.stringify({ ok: false, error: 'Unknown control-plane endpoint' }));
|
|
1558
1766
|
}
|
|
1767
|
+
/**
|
|
1768
|
+
* GET /v1/models[?type=text|images] and GET /v1/models/:id — answered
|
|
1769
|
+
* locally from the discovered-peer cache, aggregated across the network. The
|
|
1770
|
+
* `x-antseed-request-id` response header keeps the port-reuse probe in
|
|
1771
|
+
* `buyer start` recognizing this as an AntSeed proxy.
|
|
1772
|
+
*/
|
|
1773
|
+
async _handleNetworkModels(res, rawPath) {
|
|
1774
|
+
const url = new URL(rawPath, 'http://localhost');
|
|
1775
|
+
const responseHeaders = { 'content-type': 'application/json', 'x-antseed-request-id': randomUUID() };
|
|
1776
|
+
const peers = await this._getPeers();
|
|
1777
|
+
const models = buildNetworkModels(peers, this._now(), {
|
|
1778
|
+
routingPreferences: this._routingPreferences,
|
|
1779
|
+
peerHealth: this._peerHealth,
|
|
1780
|
+
});
|
|
1781
|
+
const modelIdRaw = url.pathname.replace(/^\/v1\/models\/?/i, '');
|
|
1782
|
+
if (modelIdRaw.length > 0) {
|
|
1783
|
+
let modelId;
|
|
1784
|
+
try {
|
|
1785
|
+
modelId = decodeURIComponent(modelIdRaw).trim();
|
|
1786
|
+
}
|
|
1787
|
+
catch {
|
|
1788
|
+
modelId = modelIdRaw.trim();
|
|
1789
|
+
}
|
|
1790
|
+
const modelKey = canonicalModelKey(modelId);
|
|
1791
|
+
const model = models.find((entry) => canonicalModelKey(entry.id) === modelKey);
|
|
1792
|
+
if (!model) {
|
|
1793
|
+
res.writeHead(404, responseHeaders);
|
|
1794
|
+
res.end(JSON.stringify({
|
|
1795
|
+
error: {
|
|
1796
|
+
message: `Model "${modelId}" was not found on the network.`,
|
|
1797
|
+
type: 'invalid_request_error',
|
|
1798
|
+
code: 'model_not_found',
|
|
1799
|
+
},
|
|
1800
|
+
}));
|
|
1801
|
+
return;
|
|
1802
|
+
}
|
|
1803
|
+
res.writeHead(200, responseHeaders);
|
|
1804
|
+
res.end(JSON.stringify(model));
|
|
1805
|
+
return;
|
|
1806
|
+
}
|
|
1807
|
+
const typeFilter = parseModelTypeFilter(url.searchParams.get('type'));
|
|
1808
|
+
if (typeFilter === 'invalid') {
|
|
1809
|
+
res.writeHead(400, responseHeaders);
|
|
1810
|
+
res.end(JSON.stringify({
|
|
1811
|
+
error: {
|
|
1812
|
+
message: `Unknown model type "${url.searchParams.get('type') ?? ''}" — expected "text" or "images".`,
|
|
1813
|
+
type: 'invalid_request_error',
|
|
1814
|
+
param: 'type',
|
|
1815
|
+
},
|
|
1816
|
+
}));
|
|
1817
|
+
return;
|
|
1818
|
+
}
|
|
1819
|
+
const data = typeFilter === 'all' ? models : models.filter((entry) => entry.type === typeFilter);
|
|
1820
|
+
log(`GET /v1/models answered locally: ${data.length} models across ${peers.length} peers${typeFilter ? ` (type=${typeFilter})` : ''}`);
|
|
1821
|
+
res.writeHead(200, responseHeaders);
|
|
1822
|
+
res.end(JSON.stringify({ object: 'list', data }));
|
|
1823
|
+
}
|
|
1559
1824
|
async _handleRequest(req, res) {
|
|
1560
1825
|
const method = req.method ?? 'GET';
|
|
1561
1826
|
const path = req.url ?? '/';
|
|
@@ -1577,6 +1842,13 @@ export class BuyerProxy {
|
|
|
1577
1842
|
res.end(JSON.stringify({ error: { message: 'Not found', type: 'invalid_request_error' } }));
|
|
1578
1843
|
return;
|
|
1579
1844
|
}
|
|
1845
|
+
// `/v1/models` is answered locally from the discovered-peer cache: one
|
|
1846
|
+
// entry per model across the whole network, with the peers serving it.
|
|
1847
|
+
// Routed to a single pinned seller it would only cover that seller's
|
|
1848
|
+
// services — and would require a pin just to browse the network.
|
|
1849
|
+
if (method === 'GET' && normalizedPath.startsWith('/v1/models')) {
|
|
1850
|
+
return this._handleNetworkModels(res, path);
|
|
1851
|
+
}
|
|
1580
1852
|
// Collect request body
|
|
1581
1853
|
const chunks = [];
|
|
1582
1854
|
for await (const chunk of req) {
|
|
@@ -1622,12 +1894,10 @@ export class BuyerProxy {
|
|
|
1622
1894
|
// _reloadSessionOverrides() cannot change routing mid-request.
|
|
1623
1895
|
const effectivePinnedPeer = this._pinnedPeer;
|
|
1624
1896
|
// 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.
|
|
1897
|
+
// identity (see conversation-identity.ts). Explicit chat pins remain hard;
|
|
1898
|
+
// automatically selected routes become soft affinity, so later turns stay
|
|
1899
|
+
// on the same seller unless it is cooling, unavailable, or fails retryably.
|
|
1900
|
+
// Subagent sessions inherit their parent chat's route.
|
|
1631
1901
|
const isConversationRequest = method === 'POST' && isCompletionRequestPath(path);
|
|
1632
1902
|
const conversationBody = isConversationRequest
|
|
1633
1903
|
? parseRequestBodyObject(serializedReq.body, serializedReq.headers)
|
|
@@ -1638,13 +1908,22 @@ export class BuyerProxy {
|
|
|
1638
1908
|
// Internal marker from the system proxy: source profile used only for
|
|
1639
1909
|
// local conversation attribution. Stripped here so it never reaches a seller.
|
|
1640
1910
|
delete serializedReq.headers[SYSTEM_PROXY_SOURCE_HEADER];
|
|
1641
|
-
const
|
|
1642
|
-
?
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1911
|
+
const trackedConversationKey = conversationIdentity
|
|
1912
|
+
? conversationIdentity.parentSessionKey ?? conversationIdentity.sessionKey
|
|
1913
|
+
: null;
|
|
1914
|
+
const storedConversation = conversationIdentity && trackedConversationKey
|
|
1915
|
+
? this._conversations.get(`${conversationIdentity.tool}:${trackedConversationKey}`)
|
|
1646
1916
|
: null;
|
|
1917
|
+
const storedAutoRoute = storedConversation?.peerSource === 'auto' && storedConversation.pinnedModel
|
|
1918
|
+
? parsePeerPinnedService(storedConversation.pinnedModel)
|
|
1919
|
+
: null;
|
|
1920
|
+
const chatPinnedModel = storedConversation?.peerSource === 'user'
|
|
1921
|
+
? storedConversation.pinnedModel
|
|
1922
|
+
: storedAutoRoute?.service ?? storedConversation?.pinnedModel ?? null;
|
|
1923
|
+
const preferredPeerHeader = normalizePeerId(serializedReq.headers['x-antseed-prefer-peer'] ?? '');
|
|
1924
|
+
const preferredConversationPeerId = preferredPeerHeader ?? storedAutoRoute?.peerId ?? null;
|
|
1647
1925
|
const effectiveRoutedModel = chatPinnedModel ?? this._defaultRoutedModel;
|
|
1926
|
+
let trackedConversationId = storedConversation?.id ?? null;
|
|
1648
1927
|
// Resolve the `antseed` model alias to the session's default route first,
|
|
1649
1928
|
// so the regular `<peerId>@<service>` pin rewrite below picks up the
|
|
1650
1929
|
// substituted value. Tool configs written by the desktop carry the alias
|
|
@@ -1657,7 +1936,7 @@ export class BuyerProxy {
|
|
|
1657
1936
|
error: {
|
|
1658
1937
|
type: 'no_default_route',
|
|
1659
1938
|
code: 'no_default_route',
|
|
1660
|
-
message: `Model "${ROUTED_MODEL_ALIAS}" routes to the model selected in
|
|
1939
|
+
message: `Model "${ROUTED_MODEL_ALIAS}" routes to the model selected in VPR, but no route is set. `
|
|
1661
1940
|
+ 'Pick a model in the desktop app, or request "<peerId>@<model>" explicitly.',
|
|
1662
1941
|
param: 'model',
|
|
1663
1942
|
},
|
|
@@ -1713,6 +1992,13 @@ export class BuyerProxy {
|
|
|
1713
1992
|
snippet,
|
|
1714
1993
|
lastModel: titleTurn ? null : resolvedModel,
|
|
1715
1994
|
});
|
|
1995
|
+
const explicitConversationPin = resolvedModel && (parsePeerPinnedService(rawModel)
|
|
1996
|
+
|| (aliasResult.substituted && parsePeerPinnedService(effectiveRoutedModel ?? ''))
|
|
1997
|
+
|| (chatPinOverrideApplied && parsePeerPinnedService(chatPinnedModel ?? '')));
|
|
1998
|
+
if (explicitConversationPin) {
|
|
1999
|
+
this._conversations.setPinnedModel(tracked.id, resolvedModel, 'user');
|
|
2000
|
+
}
|
|
2001
|
+
trackedConversationId = tracked.id;
|
|
1716
2002
|
// Bind the request to the chat so its cost can be attributed when the
|
|
1717
2003
|
// payment layer signs for it (see _attributeSpend).
|
|
1718
2004
|
this._trackRequestConversation(serializedReq.requestId, tracked.id);
|
|
@@ -1749,21 +2035,10 @@ export class BuyerProxy {
|
|
|
1749
2035
|
const explicitProvider = getExplicitProviderOverride(serializedReq);
|
|
1750
2036
|
const explicitPeerId = getExplicitPeerIdOverride(serializedReq, effectivePinnedPeer ?? undefined, bodyPinnedPeer);
|
|
1751
2037
|
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'
|
|
2038
|
+
if (!explicitPeerId && !requestedService) {
|
|
2039
|
+
log('Request rejected: no peer pinned and no model requested');
|
|
2040
|
+
const errorMessage = 'No model or peer was specified.\n'
|
|
2041
|
+
+ 'Set the request model, or pin a peer one of three ways:\n'
|
|
1767
2042
|
+ ' • Per-request header: x-antseed-pin-peer: <peerId> (40-char hex EVM address)\n'
|
|
1768
2043
|
+ ' • Model name prefix: <peerId>@<model>\n'
|
|
1769
2044
|
+ ' • Session pin: antseed buyer connection set --peer <peerId>\n'
|
|
@@ -1771,10 +2046,10 @@ export class BuyerProxy {
|
|
|
1771
2046
|
res.writeHead(400, { 'content-type': 'application/json' });
|
|
1772
2047
|
res.end(JSON.stringify({
|
|
1773
2048
|
error: {
|
|
1774
|
-
type: '
|
|
1775
|
-
code: '
|
|
2049
|
+
type: 'missing_routing_target',
|
|
2050
|
+
code: 'missing_routing_target',
|
|
1776
2051
|
message: errorMessage,
|
|
1777
|
-
param: '
|
|
2052
|
+
param: 'model',
|
|
1778
2053
|
help: {
|
|
1779
2054
|
perRequestHeader: 'x-antseed-pin-peer: <peerId>',
|
|
1780
2055
|
modelPrefix: '<peerId>@<model>',
|
|
@@ -1793,6 +2068,146 @@ export class BuyerProxy {
|
|
|
1793
2068
|
res.end('No sellers available on the network. Is a seeder running?');
|
|
1794
2069
|
return;
|
|
1795
2070
|
}
|
|
2071
|
+
if (!explicitPeerId && requestedService) {
|
|
2072
|
+
const selectModelPeers = (candidateSources) => selectCandidatePeersForRouting(candidateSources, requestProtocol, requestedService, explicitProvider, 'strict');
|
|
2073
|
+
let discoveredPeers = peers;
|
|
2074
|
+
let { candidatePeers: modelPeers, routePlanByPeerId: modelPlans } = selectModelPeers(discoveredPeers);
|
|
2075
|
+
const cacheAgeMs = Date.now() - this._cacheLastUpdatedAtMs;
|
|
2076
|
+
if (modelPeers.length === 0 || cacheAgeMs > this._peerCacheTtlMs) {
|
|
2077
|
+
discoveredPeers = await this._getPeers({ forceRefresh: true });
|
|
2078
|
+
({ candidatePeers: modelPeers, routePlanByPeerId: modelPlans } = selectModelPeers(discoveredPeers));
|
|
2079
|
+
}
|
|
2080
|
+
const router = this._node.router;
|
|
2081
|
+
const policyRouter = router;
|
|
2082
|
+
const routeCandidates = modelPeers
|
|
2083
|
+
.map((peer) => {
|
|
2084
|
+
const plan = modelPlans.get(peer.peerId)
|
|
2085
|
+
?? resolvePeerRoutePlan(peer, requestProtocol, requestedService, explicitProvider, 'strict');
|
|
2086
|
+
if (!plan?.serviceId)
|
|
2087
|
+
return null;
|
|
2088
|
+
const offer = findAdvertisedServiceOffer(peer, plan.provider, plan.serviceId);
|
|
2089
|
+
if (!offer)
|
|
2090
|
+
return null;
|
|
2091
|
+
const requestForPolicy = withRoutedModel(serializedReq, plan.serviceId);
|
|
2092
|
+
if (!peerAllowedByPolicy(policyRouter, requestForPolicy, peer))
|
|
2093
|
+
return null;
|
|
2094
|
+
return {
|
|
2095
|
+
peer,
|
|
2096
|
+
serviceId: plan.serviceId,
|
|
2097
|
+
request: requestForPolicy,
|
|
2098
|
+
reputation: normalizedModelReputationScore(peer, this._now()) ?? -1,
|
|
2099
|
+
hasCachedInputPricing: offer.cachedInputUsdPerMillion !== undefined,
|
|
2100
|
+
inputUsdPerMillion: offer.inputUsdPerMillion ?? null,
|
|
2101
|
+
outputUsdPerMillion: offer.outputUsdPerMillion ?? null,
|
|
2102
|
+
minImageUsdPerImage: offer.minImageUsdPerImage ?? null,
|
|
2103
|
+
};
|
|
2104
|
+
})
|
|
2105
|
+
.filter((candidate) => candidate !== null);
|
|
2106
|
+
const now = this._now();
|
|
2107
|
+
const preferCachedPricing = routeCandidates.some((candidate) => candidate.hasCachedInputPricing);
|
|
2108
|
+
const ranked = routeCandidates.map((candidate) => {
|
|
2109
|
+
const health = this._peerHealth.get(candidate.peer.peerId);
|
|
2110
|
+
return {
|
|
2111
|
+
...candidate,
|
|
2112
|
+
peerId: candidate.peer.peerId,
|
|
2113
|
+
effectiveReputationScore: effectiveModelReputationScore(candidate.reputation >= 0 ? candidate.reputation : null, candidate.hasCachedInputPricing, preferCachedPricing),
|
|
2114
|
+
peerCooldownUntil: health?.cooldownUntil ?? null,
|
|
2115
|
+
peerFailureStreak: health?.failureStreak ?? 0,
|
|
2116
|
+
};
|
|
2117
|
+
});
|
|
2118
|
+
let candidates;
|
|
2119
|
+
const routingPreferences = this._routingPreferences;
|
|
2120
|
+
if (routingPreferences) {
|
|
2121
|
+
candidates = rankModelRoutes(ranked, routingPreferences, now)
|
|
2122
|
+
.filter((candidate) => isModelRouteEligible(candidate, routingPreferences));
|
|
2123
|
+
}
|
|
2124
|
+
else {
|
|
2125
|
+
ranked.sort((a, b) => (b.effectiveReputationScore ?? -1) - (a.effectiveReputationScore ?? -1)
|
|
2126
|
+
|| a.peer.peerId.localeCompare(b.peer.peerId));
|
|
2127
|
+
const ready = ranked.filter((candidate) => !isCoolingDown(this._peerHealth.get(candidate.peer.peerId), now));
|
|
2128
|
+
candidates = ready.length > 0 ? ready : ranked;
|
|
2129
|
+
}
|
|
2130
|
+
if (preferredConversationPeerId) {
|
|
2131
|
+
const preferredIndex = candidates.findIndex((candidate) => (candidate.peer.peerId.toLowerCase() === preferredConversationPeerId
|
|
2132
|
+
&& !isCoolingDown(this._peerHealth.get(candidate.peer.peerId), now)));
|
|
2133
|
+
if (preferredIndex > 0) {
|
|
2134
|
+
const [preferred] = candidates.splice(preferredIndex, 1);
|
|
2135
|
+
if (preferred)
|
|
2136
|
+
candidates.unshift(preferred);
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
2139
|
+
if (candidates.length === 0) {
|
|
2140
|
+
res.writeHead(502, { 'content-type': 'application/json' });
|
|
2141
|
+
res.end(JSON.stringify({
|
|
2142
|
+
error: {
|
|
2143
|
+
type: 'model_not_found',
|
|
2144
|
+
code: 'model_not_found',
|
|
2145
|
+
message: `No policy-allowed peer currently serves model "${requestedService}".`,
|
|
2146
|
+
param: 'model',
|
|
2147
|
+
},
|
|
2148
|
+
}));
|
|
2149
|
+
return;
|
|
2150
|
+
}
|
|
2151
|
+
let lastRetry = null;
|
|
2152
|
+
let lastVerificationError = null;
|
|
2153
|
+
for (const [index, selected] of candidates.entries()) {
|
|
2154
|
+
if (this._verifier) {
|
|
2155
|
+
const makeReach = (chosenId) => makeVerifierReach(this._node, selected.peer, chosenId, clientAbortController.signal);
|
|
2156
|
+
const outcome = await this._verifyPeer(selected.peer, makeReach, clientAbortController.signal);
|
|
2157
|
+
if (!outcome.ok) {
|
|
2158
|
+
lastVerificationError = `Peer ${selected.peer.peerId.slice(0, 12)}... failed required verification (${outcome.reason ?? 'failed'}).`;
|
|
2159
|
+
log(`${lastVerificationError} Trying the next model peer.`);
|
|
2160
|
+
continue;
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
for (let peerAttempt = 0; peerAttempt < MODEL_RATE_LIMIT_MAX_ATTEMPTS_PER_PEER; peerAttempt += 1) {
|
|
2164
|
+
log(`Auto-selected peer ${selected.peer.peerId.slice(0, 12)}... for model="${requestedService}" `
|
|
2165
|
+
+ `service="${selected.serviceId}" reputation=${selected.reputation} `
|
|
2166
|
+
+ `effective=${selected.effectiveReputationScore ?? 'unknown'} peer=${index + 1}/${candidates.length} `
|
|
2167
|
+
+ `attempt=${peerAttempt + 1}/${MODEL_RATE_LIMIT_MAX_ATTEMPTS_PER_PEER}`);
|
|
2168
|
+
const result = await this._dispatchToPeer(res, selected.request, selected.peer, modelPlans, requestProtocol, selected.serviceId, explicitProvider, router, RETRYABLE_STATUS_CODES, clientAbortController.signal);
|
|
2169
|
+
if (result.done) {
|
|
2170
|
+
if (trackedConversationId) {
|
|
2171
|
+
this._conversations.recordRoutedModel(trackedConversationId, `${selected.peer.peerId}@${selected.serviceId}`);
|
|
2172
|
+
}
|
|
2173
|
+
return;
|
|
2174
|
+
}
|
|
2175
|
+
lastRetry = result;
|
|
2176
|
+
if (result.responseHeaders[ANTSEED_FAULT_ATTRIBUTION_HEADER]?.toLowerCase() === 'buyer') {
|
|
2177
|
+
res.writeHead(result.statusCode, result.responseHeaders);
|
|
2178
|
+
res.end(result.responseBody);
|
|
2179
|
+
return;
|
|
2180
|
+
}
|
|
2181
|
+
const retrySamePeer = result.statusCode === 429
|
|
2182
|
+
&& peerAttempt + 1 < MODEL_RATE_LIMIT_MAX_ATTEMPTS_PER_PEER;
|
|
2183
|
+
if (!retrySamePeer)
|
|
2184
|
+
break;
|
|
2185
|
+
const delayMs = rateLimitRetryDelayMs(result.responseHeaders, peerAttempt);
|
|
2186
|
+
log(`Peer ${selected.peer.peerId.slice(0, 12)}... is rate-limited; retrying in ${delayMs}ms.`);
|
|
2187
|
+
if (!await waitForRetry(delayMs, clientAbortController.signal))
|
|
2188
|
+
return;
|
|
2189
|
+
}
|
|
2190
|
+
if (index + 1 < candidates.length) {
|
|
2191
|
+
log(`Peer ${selected.peer.peerId.slice(0, 12)}... failed retryably; trying next model peer.`);
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
if (lastRetry) {
|
|
2195
|
+
res.writeHead(lastRetry.statusCode, lastRetry.responseHeaders);
|
|
2196
|
+
res.end(lastRetry.responseBody);
|
|
2197
|
+
}
|
|
2198
|
+
else {
|
|
2199
|
+
res.writeHead(502, { 'content-type': 'application/json' });
|
|
2200
|
+
res.end(JSON.stringify({
|
|
2201
|
+
error: {
|
|
2202
|
+
type: 'peer_verification_failed',
|
|
2203
|
+
code: 'peer_verification_failed',
|
|
2204
|
+
message: lastVerificationError ?? `No verified peer currently serves model "${requestedService}".`,
|
|
2205
|
+
},
|
|
2206
|
+
}));
|
|
2207
|
+
}
|
|
2208
|
+
return;
|
|
2209
|
+
}
|
|
2210
|
+
const pinnedPeerId = explicitPeerId;
|
|
1796
2211
|
// Narrow the candidate set to just the pinned peer (if we already know
|
|
1797
2212
|
// about it) before running the per-peer protocol/service match. This
|
|
1798
2213
|
// avoids wasting work — and spamming "Service strict-miss" log lines —
|
|
@@ -1800,7 +2215,7 @@ export class BuyerProxy {
|
|
|
1800
2215
|
// fall through with the full list so the "not in candidate set → force
|
|
1801
2216
|
// refresh" path still works.
|
|
1802
2217
|
const narrowToPinned = (sources) => {
|
|
1803
|
-
const match = sources.find((p) => p.peerId.toLowerCase() ===
|
|
2218
|
+
const match = sources.find((p) => p.peerId.toLowerCase() === pinnedPeerId);
|
|
1804
2219
|
return match ? [match] : sources;
|
|
1805
2220
|
};
|
|
1806
2221
|
const selectPeers = (candidateSources) => selectCandidatePeersForRouting(narrowToPinned(candidateSources), requestProtocol, requestedService, explicitProvider, 'lenient');
|
|
@@ -1821,7 +2236,7 @@ export class BuyerProxy {
|
|
|
1821
2236
|
let routingPeers = candidatePeers;
|
|
1822
2237
|
let routingPlans = routePlanByPeerId;
|
|
1823
2238
|
let discoveredPeers = peers;
|
|
1824
|
-
const isPinnedDiscovered = () => discoveredPeers.some((peer) => peer.peerId.toLowerCase() ===
|
|
2239
|
+
const isPinnedDiscovered = () => discoveredPeers.some((peer) => peer.peerId.toLowerCase() === pinnedPeerId);
|
|
1825
2240
|
// Single refresh guard covers all three doubts about the cache: pin
|
|
1826
2241
|
// missing, candidate filter empty, or cache past TTL.
|
|
1827
2242
|
const cacheAgeMs = Date.now() - this._cacheLastUpdatedAtMs;
|
|
@@ -1837,16 +2252,16 @@ export class BuyerProxy {
|
|
|
1837
2252
|
? 'model peer prefix'
|
|
1838
2253
|
: '--peer flag or session pin';
|
|
1839
2254
|
const diagnostics = this._formatPeerSelectionDiagnostics(discoveredPeers);
|
|
1840
|
-
log(`Pinned peer ${
|
|
2255
|
+
log(`Pinned peer ${pinnedPeerId.slice(0, 12)}... not discoverable in DHT (${logSource})`);
|
|
1841
2256
|
res.writeHead(502, { 'content-type': 'text/plain' });
|
|
1842
|
-
res.end(`Pinned peer ${
|
|
2257
|
+
res.end(`Pinned peer ${pinnedPeerId.slice(0, 12)}... is not reachable right now. `
|
|
1843
2258
|
+ 'It may be offline, not announcing, or temporarily unreachable. '
|
|
1844
2259
|
+ 'Pick a different service in Discover or try again later. '
|
|
1845
2260
|
+ diagnostics);
|
|
1846
2261
|
return;
|
|
1847
2262
|
}
|
|
1848
2263
|
if (routingPeers.length === 0) {
|
|
1849
|
-
const pinnedPeer = discoveredPeers.find((peer) => peer.peerId.toLowerCase() ===
|
|
2264
|
+
const pinnedPeer = discoveredPeers.find((peer) => peer.peerId.toLowerCase() === pinnedPeerId) ?? null;
|
|
1850
2265
|
const protocolLabel = requestProtocol ? `protocol=${requestProtocol}` : 'protocol=unknown';
|
|
1851
2266
|
const providerLabel = explicitProvider ? `provider=${explicitProvider}` : 'provider=auto';
|
|
1852
2267
|
const serviceLabel = requestedService ? `service=${requestedService}` : 'service=none';
|
|
@@ -1857,41 +2272,40 @@ export class BuyerProxy {
|
|
|
1857
2272
|
.filter((provider) => provider.length > 0);
|
|
1858
2273
|
if (!providers.includes(explicitProvider)) {
|
|
1859
2274
|
const providerList = providers.length > 0 ? providers.join(', ') : 'none';
|
|
1860
|
-
log(`Pinned peer ${
|
|
2275
|
+
log(`Pinned peer ${pinnedPeerId.slice(0, 12)}... does not offer explicit provider=${explicitProvider}`);
|
|
1861
2276
|
res.writeHead(502, { 'content-type': 'text/plain' });
|
|
1862
|
-
res.end(`Pinned peer ${
|
|
2277
|
+
res.end(`Pinned peer ${pinnedPeerId.slice(0, 12)}... does not offer provider=${explicitProvider}. `
|
|
1863
2278
|
+ `Available providers: ${providerList}. `
|
|
1864
2279
|
+ 'Remove or change the x-antseed-provider header, or pick a different peer. '
|
|
1865
2280
|
+ diagnostics);
|
|
1866
2281
|
return;
|
|
1867
2282
|
}
|
|
1868
2283
|
}
|
|
1869
|
-
log(`Pinned peer ${
|
|
2284
|
+
log(`Pinned peer ${pinnedPeerId.slice(0, 12)}... filtered out by protocol/service match`);
|
|
1870
2285
|
res.writeHead(502, { 'content-type': 'text/plain' });
|
|
1871
|
-
res.end(`Pinned peer ${
|
|
2286
|
+
res.end(`Pinned peer ${pinnedPeerId.slice(0, 12)}... does not support this request `
|
|
1872
2287
|
+ `(${protocolLabel}, ${providerLabel}, ${serviceLabel}). `
|
|
1873
2288
|
+ `Pick a different service in Discover. ${diagnostics}`);
|
|
1874
2289
|
return;
|
|
1875
2290
|
}
|
|
1876
2291
|
log(`Routing candidates: ${routingPeers.length} peer(s)`);
|
|
1877
2292
|
const router = this._node.router;
|
|
1878
|
-
const selectedPeer = routingPeers.find((p) => p.peerId.toLowerCase() ===
|
|
2293
|
+
const selectedPeer = routingPeers.find((p) => p.peerId.toLowerCase() === pinnedPeerId) ?? null;
|
|
1879
2294
|
// Defence in depth: the discovered+narrowed checks above should make this
|
|
1880
2295
|
// branch unreachable. Keep a structured fallback so we don't silently hang
|
|
1881
2296
|
// if an invariant breaks.
|
|
1882
2297
|
if (!selectedPeer) {
|
|
1883
|
-
log(`Invariant: pinned peer ${
|
|
2298
|
+
log(`Invariant: pinned peer ${pinnedPeerId.slice(0, 12)}... present in DHT but missing from narrowed candidate list`);
|
|
1884
2299
|
res.writeHead(502, { 'content-type': 'text/plain' });
|
|
1885
|
-
res.end(`Pinned peer ${
|
|
2300
|
+
res.end(`Pinned peer ${pinnedPeerId.slice(0, 12)}... is currently unreachable. Try again in a moment.`);
|
|
1886
2301
|
return;
|
|
1887
2302
|
}
|
|
1888
2303
|
const policyRouter = router;
|
|
1889
|
-
const
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
if (!policyAllowed) {
|
|
2304
|
+
const selectedPlan = routingPlans.get(selectedPeer.peerId)
|
|
2305
|
+
?? resolvePeerRoutePlan(selectedPeer, requestProtocol, requestedService, explicitProvider, 'lenient');
|
|
2306
|
+
const pinnedServiceId = selectedPlan?.serviceId ?? requestedService;
|
|
2307
|
+
const pinnedRequest = pinnedServiceId ? withRoutedModel(serializedReq, pinnedServiceId) : serializedReq;
|
|
2308
|
+
if (!peerAllowedByPolicy(policyRouter, pinnedRequest, selectedPeer)) {
|
|
1895
2309
|
log(`Pinned peer ${selectedPeer.peerId.slice(0, 12)}... filtered out by buyer routing policy`);
|
|
1896
2310
|
res.writeHead(502, { 'content-type': 'text/plain' });
|
|
1897
2311
|
res.end(`Pinned peer ${selectedPeer.peerId.slice(0, 12)}... is outside your buyer routing policy. `
|
|
@@ -1916,10 +2330,13 @@ export class BuyerProxy {
|
|
|
1916
2330
|
}
|
|
1917
2331
|
}
|
|
1918
2332
|
log(`Using pinned peer ${selectedPeer.peerId.slice(0, 12)}...`);
|
|
1919
|
-
const result = await this._dispatchToPeer(res,
|
|
2333
|
+
const result = await this._dispatchToPeer(res, pinnedRequest, selectedPeer, routingPlans, requestProtocol, requestedService, explicitProvider, router, RETRYABLE_STATUS_CODES, clientAbortController.signal);
|
|
2334
|
+
if (result.done && trackedConversationId && pinnedServiceId) {
|
|
2335
|
+
this._conversations.recordRoutedModel(trackedConversationId, `${selectedPeer.peerId}@${pinnedServiceId}`);
|
|
2336
|
+
}
|
|
1920
2337
|
if (!result.done) {
|
|
1921
2338
|
// Pinned peer returned a retryable error. We never retry against another
|
|
1922
|
-
// peer
|
|
2339
|
+
// peer for an explicit pin, so surface the error to the client.
|
|
1923
2340
|
res.writeHead(result.statusCode, result.responseHeaders);
|
|
1924
2341
|
res.end(result.responseBody);
|
|
1925
2342
|
}
|
|
@@ -2001,7 +2418,9 @@ export class BuyerProxy {
|
|
|
2001
2418
|
+ `did not announce for this service: ${unannounced.join(', ')} — the upstream may ignore or reject them`);
|
|
2002
2419
|
}
|
|
2003
2420
|
}
|
|
2004
|
-
const { 'x-antseed-pin-peer': _pinPeer, 'x-antseed-prefer-peer': _preferPeer,
|
|
2421
|
+
const { 'x-antseed-pin-peer': _pinPeer, 'x-antseed-prefer-peer': _preferPeer, 'x-vpr-session-id': _vprSession,
|
|
2422
|
+
// Legacy desktop builds (pre AntStation → VPR rename) still send this.
|
|
2423
|
+
'x-antstation-session-id': _antstationSession, ...headersForPeer } = serializedReq.headers;
|
|
2005
2424
|
let requestForPeer = {
|
|
2006
2425
|
...serializedReq,
|
|
2007
2426
|
headers: {
|
|
@@ -2009,6 +2428,9 @@ export class BuyerProxy {
|
|
|
2009
2428
|
'x-antseed-provider': selectedRoutePlan.provider,
|
|
2010
2429
|
},
|
|
2011
2430
|
};
|
|
2431
|
+
if (selectedRoutePlan.serviceId) {
|
|
2432
|
+
requestForPeer = withRoutedModel(requestForPeer, selectedRoutePlan.serviceId);
|
|
2433
|
+
}
|
|
2012
2434
|
const clientWantsStreaming = requestWantsStreaming(serializedReq.headers, serializedReq.body);
|
|
2013
2435
|
let adaptResponse = null;
|
|
2014
2436
|
let streamResponseAdapter = null;
|