@antseed/cli 0.1.23 → 0.1.25
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 +32 -0
- package/dist/cli/commands/connect.d.ts.map +1 -1
- package/dist/cli/commands/connect.js +45 -1
- package/dist/cli/commands/connect.js.map +1 -1
- package/dist/cli/commands/connection.d.ts +3 -0
- package/dist/cli/commands/connection.d.ts.map +1 -0
- package/dist/cli/commands/connection.js +128 -0
- package/dist/cli/commands/connection.js.map +1 -0
- package/dist/cli/commands/seed.d.ts.map +1 -1
- package/dist/cli/commands/seed.js +5 -2
- package/dist/cli/commands/seed.js.map +1 -1
- package/dist/cli/index.js +4 -3
- package/dist/cli/index.js.map +1 -1
- package/dist/config/types.d.ts +9 -0
- package/dist/config/types.d.ts.map +1 -1
- package/dist/proxy/buyer-proxy.d.ts +30 -3
- package/dist/proxy/buyer-proxy.d.ts.map +1 -1
- package/dist/proxy/buyer-proxy.js +306 -79
- package/dist/proxy/buyer-proxy.js.map +1 -1
- package/dist/proxy/buyer-proxy.test.js +52 -2
- package/dist/proxy/buyer-proxy.test.js.map +1 -1
- package/dist/proxy/model-api-adapter.d.ts +1 -19
- package/dist/proxy/model-api-adapter.d.ts.map +1 -1
- package/dist/proxy/model-api-adapter.js +4 -534
- package/dist/proxy/model-api-adapter.js.map +1 -1
- package/package.json +5 -4
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
|
-
import {
|
|
3
|
+
import { watch } from 'node:fs';
|
|
4
|
+
import { readFile, writeFile, rename, mkdir } from 'node:fs/promises';
|
|
4
5
|
import { join } from 'node:path';
|
|
5
6
|
import { homedir } from 'node:os';
|
|
6
|
-
import { detectRequestModelApiProtocol, inferProviderDefaultModelApiProtocols, selectTargetProtocolForRequest, transformAnthropicMessagesRequestToOpenAIChat, transformOpenAIChatResponseToAnthropicMessage, } from './model-api-adapter.js';
|
|
7
|
+
import { detectRequestModelApiProtocol, inferProviderDefaultModelApiProtocols, selectTargetProtocolForRequest, transformAnthropicMessagesRequestToOpenAIChat, transformOpenAIChatResponseToAnthropicMessage, transformOpenAIResponsesRequestToOpenAIChat, transformOpenAIChatResponseToOpenAIResponses, } from './model-api-adapter.js';
|
|
7
8
|
const DAEMON_STATE_FILE = join(homedir(), '.antseed', 'daemon.state.json');
|
|
9
|
+
const BUYER_STATE_FILE = join(homedir(), '.antseed', 'buyer.state.json');
|
|
8
10
|
const DEBUG = () => ['1', 'true', 'yes', 'on'].includes((process.env['ANTSEED_DEBUG'] ?? '').trim().toLowerCase());
|
|
9
11
|
function log(...args) {
|
|
10
12
|
if (DEBUG())
|
|
@@ -54,11 +56,21 @@ function getPreferredPeerIdHint(request) {
|
|
|
54
56
|
return header;
|
|
55
57
|
}
|
|
56
58
|
function getPeerProviderProtocols(peer, provider, requestedModel) {
|
|
59
|
+
const normalizedRequestedModel = requestedModel?.trim();
|
|
57
60
|
const fromMetadata = peer.providerModelApiProtocols?.[provider]?.models;
|
|
58
61
|
if (fromMetadata) {
|
|
59
|
-
if (
|
|
60
|
-
|
|
61
|
-
|
|
62
|
+
if (normalizedRequestedModel) {
|
|
63
|
+
const directMatchKey = Object.keys(fromMetadata).find((model) => model.toLowerCase() === normalizedRequestedModel.toLowerCase());
|
|
64
|
+
if (directMatchKey && fromMetadata[directMatchKey]?.length) {
|
|
65
|
+
log(`Model match: peer ${peer.peerId.slice(0, 8)} provider=${provider} model="${normalizedRequestedModel}" `
|
|
66
|
+
+ `→ [${fromMetadata[directMatchKey].join(',')}]`);
|
|
67
|
+
return Array.from(new Set(fromMetadata[directMatchKey]));
|
|
68
|
+
}
|
|
69
|
+
if (Object.keys(fromMetadata).length > 0) {
|
|
70
|
+
log(`Model strict-miss: peer ${peer.peerId.slice(0, 8)} provider=${provider} model="${normalizedRequestedModel}" `
|
|
71
|
+
+ 'not in metadata; excluding from route candidates.');
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
62
74
|
}
|
|
63
75
|
const merged = Object.values(fromMetadata).flat();
|
|
64
76
|
if (merged.length > 0) {
|
|
@@ -72,24 +84,6 @@ function getPeerProviderProtocols(peer, provider, requestedModel) {
|
|
|
72
84
|
log(`No metadata: peer ${peer.peerId.slice(0, 8)} provider=${provider} → inferred [${inferred.join(',')}]`);
|
|
73
85
|
return inferred;
|
|
74
86
|
}
|
|
75
|
-
function isProviderModelExplicitlyUnsupported(peer, provider, requestedModel) {
|
|
76
|
-
if (!requestedModel) {
|
|
77
|
-
return false;
|
|
78
|
-
}
|
|
79
|
-
const modelMatrix = peer.providerModelApiProtocols?.[provider]?.models;
|
|
80
|
-
if (!modelMatrix) {
|
|
81
|
-
return false;
|
|
82
|
-
}
|
|
83
|
-
const advertisedModels = Object.keys(modelMatrix);
|
|
84
|
-
if (advertisedModels.length === 0) {
|
|
85
|
-
return false;
|
|
86
|
-
}
|
|
87
|
-
if (Object.prototype.hasOwnProperty.call(modelMatrix, requestedModel)) {
|
|
88
|
-
return false;
|
|
89
|
-
}
|
|
90
|
-
log(`Model strict-miss: peer ${peer.peerId.slice(0, 8)} provider=${provider} does not advertise model="${requestedModel}"`);
|
|
91
|
-
return true;
|
|
92
|
-
}
|
|
93
87
|
function resolvePeerRoutePlan(peer, requestProtocol, requestedModel, explicitProvider) {
|
|
94
88
|
const providers = peer.providers
|
|
95
89
|
.map((provider) => provider.trim().toLowerCase())
|
|
@@ -107,9 +101,6 @@ function resolvePeerRoutePlan(peer, requestProtocol, requestedModel, explicitPro
|
|
|
107
101
|
}
|
|
108
102
|
let transformedFallback = null;
|
|
109
103
|
for (const provider of candidates) {
|
|
110
|
-
if (!explicitProvider && isProviderModelExplicitlyUnsupported(peer, provider, requestedModel)) {
|
|
111
|
-
continue;
|
|
112
|
-
}
|
|
113
104
|
const supportedProtocols = getPeerProviderProtocols(peer, provider, requestedModel);
|
|
114
105
|
const selection = selectTargetProtocolForRequest(requestProtocol, supportedProtocols);
|
|
115
106
|
if (!selection) {
|
|
@@ -575,6 +566,16 @@ function requestWantsStreaming(headers, body) {
|
|
|
575
566
|
return false;
|
|
576
567
|
}
|
|
577
568
|
}
|
|
569
|
+
function isConnectionChurnError(message) {
|
|
570
|
+
return /connection .*?\b(closed|failed)\s+during request\b/i.test(message);
|
|
571
|
+
}
|
|
572
|
+
function isConnectionHealthy(state) {
|
|
573
|
+
if (!state) {
|
|
574
|
+
return false;
|
|
575
|
+
}
|
|
576
|
+
const normalized = String(state).toLowerCase();
|
|
577
|
+
return normalized === 'open' || normalized === 'authenticated' || normalized === 'connecting';
|
|
578
|
+
}
|
|
578
579
|
function extractHostFromAddress(address) {
|
|
579
580
|
const trimmed = address.trim();
|
|
580
581
|
if (trimmed.length === 0)
|
|
@@ -599,6 +600,38 @@ function isLoopbackPeer(peer) {
|
|
|
599
600
|
const host = extractHostFromAddress(peer.publicAddress);
|
|
600
601
|
return isLoopbackHost(host);
|
|
601
602
|
}
|
|
603
|
+
/**
|
|
604
|
+
* Rewrite the `model` field in a JSON request body.
|
|
605
|
+
* Also updates `content-length` if present in headers.
|
|
606
|
+
* Returns the original body/headers unchanged if the body is not JSON,
|
|
607
|
+
* is empty, or cannot be parsed.
|
|
608
|
+
*/
|
|
609
|
+
export function rewriteModelInBody(body, headers, model) {
|
|
610
|
+
const contentType = (headers['content-type'] ?? headers['Content-Type'] ?? '').toLowerCase();
|
|
611
|
+
if (!contentType.includes('application/json') || body.length === 0) {
|
|
612
|
+
return { body, headers };
|
|
613
|
+
}
|
|
614
|
+
try {
|
|
615
|
+
const parsed = JSON.parse(new TextDecoder().decode(body));
|
|
616
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
617
|
+
return { body, headers };
|
|
618
|
+
}
|
|
619
|
+
const obj = parsed;
|
|
620
|
+
obj['model'] = model;
|
|
621
|
+
const rewritten = new TextEncoder().encode(JSON.stringify(obj));
|
|
622
|
+
const updatedHeaders = { ...headers };
|
|
623
|
+
if ('content-length' in updatedHeaders) {
|
|
624
|
+
updatedHeaders['content-length'] = String(rewritten.length);
|
|
625
|
+
}
|
|
626
|
+
else if ('Content-Length' in updatedHeaders) {
|
|
627
|
+
updatedHeaders['Content-Length'] = String(rewritten.length);
|
|
628
|
+
}
|
|
629
|
+
return { body: rewritten, headers: updatedHeaders };
|
|
630
|
+
}
|
|
631
|
+
catch {
|
|
632
|
+
return { body, headers };
|
|
633
|
+
}
|
|
634
|
+
}
|
|
602
635
|
/**
|
|
603
636
|
* Local HTTP proxy that forwards requests to P2P sellers.
|
|
604
637
|
*
|
|
@@ -612,9 +645,13 @@ export class BuyerProxy {
|
|
|
612
645
|
_port;
|
|
613
646
|
_bgRefreshIntervalMs;
|
|
614
647
|
_peerCacheTtlMs;
|
|
615
|
-
|
|
648
|
+
_pinnedPeer;
|
|
649
|
+
_pinnedModel;
|
|
650
|
+
_stateFileWatcher = null;
|
|
651
|
+
_stateWatchDebounce = null;
|
|
616
652
|
_cachedPeers = [];
|
|
617
653
|
_cacheLastUpdatedAtMs = 0;
|
|
654
|
+
_cacheMutationEpoch = 0;
|
|
618
655
|
_peerRefreshPromise = null;
|
|
619
656
|
_lastStaleCacheLogAtMs = 0;
|
|
620
657
|
_bgRefreshHandle = null;
|
|
@@ -625,7 +662,8 @@ export class BuyerProxy {
|
|
|
625
662
|
this._port = config.port;
|
|
626
663
|
this._bgRefreshIntervalMs = config.backgroundRefreshIntervalMs ?? 5 * 60_000;
|
|
627
664
|
this._peerCacheTtlMs = Math.max(0, config.peerCacheTtlMs ?? 30_000);
|
|
628
|
-
this.
|
|
665
|
+
this._pinnedPeer = config.pinnedPeerId?.toLowerCase() ?? null;
|
|
666
|
+
this._pinnedModel = config.pinnedModel?.trim() ?? null;
|
|
629
667
|
this._server = createServer((req, res) => {
|
|
630
668
|
this._handleRequest(req, res).catch((err) => {
|
|
631
669
|
log('Unhandled error:', err);
|
|
@@ -645,16 +683,96 @@ export class BuyerProxy {
|
|
|
645
683
|
});
|
|
646
684
|
});
|
|
647
685
|
this._startBackgroundRefresh();
|
|
686
|
+
await this._writeStateFile('connected');
|
|
687
|
+
this._watchStateFile();
|
|
648
688
|
}
|
|
649
689
|
async stop() {
|
|
690
|
+
if (this._stateWatchDebounce) {
|
|
691
|
+
clearTimeout(this._stateWatchDebounce);
|
|
692
|
+
this._stateWatchDebounce = null;
|
|
693
|
+
}
|
|
694
|
+
if (this._stateFileWatcher) {
|
|
695
|
+
this._stateFileWatcher.close();
|
|
696
|
+
this._stateFileWatcher = null;
|
|
697
|
+
}
|
|
650
698
|
if (this._bgRefreshHandle) {
|
|
651
699
|
clearInterval(this._bgRefreshHandle);
|
|
652
700
|
this._bgRefreshHandle = null;
|
|
653
701
|
}
|
|
702
|
+
await this._writeStateFile('stopped');
|
|
654
703
|
return new Promise((resolve) => {
|
|
655
704
|
this._server.close(() => resolve());
|
|
656
705
|
});
|
|
657
706
|
}
|
|
707
|
+
_watchStateFile() {
|
|
708
|
+
try {
|
|
709
|
+
this._stateFileWatcher = watch(BUYER_STATE_FILE, { persistent: false }, () => {
|
|
710
|
+
if (this._stateWatchDebounce)
|
|
711
|
+
clearTimeout(this._stateWatchDebounce);
|
|
712
|
+
this._stateWatchDebounce = setTimeout(() => {
|
|
713
|
+
this._stateWatchDebounce = null;
|
|
714
|
+
void this._reloadSessionOverrides().catch(() => { });
|
|
715
|
+
}, 50);
|
|
716
|
+
});
|
|
717
|
+
this._stateFileWatcher.on('error', () => {
|
|
718
|
+
// watcher error is non-fatal
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
catch {
|
|
722
|
+
// watcher setup failed; non-fatal
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
async _reloadSessionOverrides() {
|
|
726
|
+
try {
|
|
727
|
+
const raw = await readFile(BUYER_STATE_FILE, 'utf-8');
|
|
728
|
+
const parsed = JSON.parse(raw);
|
|
729
|
+
const pinnedModel = typeof parsed.pinnedModel === 'string' && parsed.pinnedModel.trim().length > 0
|
|
730
|
+
? parsed.pinnedModel.trim()
|
|
731
|
+
: null;
|
|
732
|
+
const pinnedPeer = typeof parsed.pinnedPeerId === 'string' && parsed.pinnedPeerId.trim().length > 0
|
|
733
|
+
? parsed.pinnedPeerId.trim().toLowerCase()
|
|
734
|
+
: null;
|
|
735
|
+
this._pinnedModel = pinnedModel;
|
|
736
|
+
this._pinnedPeer = pinnedPeer;
|
|
737
|
+
log(`Session overrides reloaded: model=${pinnedModel ?? 'none'} peer=${pinnedPeer ?? 'none'}`);
|
|
738
|
+
}
|
|
739
|
+
catch {
|
|
740
|
+
// state file unreadable; keep current values
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
async _writeStateFile(state) {
|
|
744
|
+
try {
|
|
745
|
+
const dir = join(homedir(), '.antseed');
|
|
746
|
+
await mkdir(dir, { recursive: true });
|
|
747
|
+
let existing = {};
|
|
748
|
+
try {
|
|
749
|
+
const raw = await readFile(BUYER_STATE_FILE, 'utf-8');
|
|
750
|
+
existing = JSON.parse(raw);
|
|
751
|
+
}
|
|
752
|
+
catch {
|
|
753
|
+
// file doesn't exist yet
|
|
754
|
+
}
|
|
755
|
+
// When stopping, preserve whatever pinnedModel/pinnedPeerId is already
|
|
756
|
+
// in the file — the debounce may have been cancelled before
|
|
757
|
+
// _reloadSessionOverrides could commit the latest CLI-written values.
|
|
758
|
+
const sessionOverrides = state === 'connected'
|
|
759
|
+
? { pinnedModel: this._pinnedModel, pinnedPeerId: this._pinnedPeer }
|
|
760
|
+
: {};
|
|
761
|
+
const data = {
|
|
762
|
+
...existing,
|
|
763
|
+
state,
|
|
764
|
+
pid: process.pid,
|
|
765
|
+
port: this._port,
|
|
766
|
+
...sessionOverrides,
|
|
767
|
+
};
|
|
768
|
+
const tmp = join(homedir(), '.antseed', `.buyer.state.${randomUUID()}.json.tmp`);
|
|
769
|
+
await writeFile(tmp, JSON.stringify(data, null, 2));
|
|
770
|
+
await rename(tmp, BUYER_STATE_FILE);
|
|
771
|
+
}
|
|
772
|
+
catch {
|
|
773
|
+
// non-fatal
|
|
774
|
+
}
|
|
775
|
+
}
|
|
658
776
|
_startBackgroundRefresh() {
|
|
659
777
|
this._bgRefreshHandle = setInterval(() => {
|
|
660
778
|
void this._refreshPeersNow().catch(() => {
|
|
@@ -665,12 +783,14 @@ export class BuyerProxy {
|
|
|
665
783
|
_replacePeers(incoming) {
|
|
666
784
|
this._cachedPeers = incoming;
|
|
667
785
|
this._cacheLastUpdatedAtMs = Date.now();
|
|
786
|
+
this._cacheMutationEpoch += 1;
|
|
668
787
|
}
|
|
669
788
|
_evictPeer(peerId) {
|
|
670
789
|
const before = this._cachedPeers.length;
|
|
671
790
|
this._cachedPeers = this._cachedPeers.filter((p) => p.peerId !== peerId);
|
|
672
791
|
if (this._cachedPeers.length < before) {
|
|
673
792
|
this._cacheLastUpdatedAtMs = Date.now();
|
|
793
|
+
this._cacheMutationEpoch += 1;
|
|
674
794
|
log(`Evicted failing peer ${peerId.slice(0, 12)}... from cache (${this._cachedPeers.length} remaining)`);
|
|
675
795
|
}
|
|
676
796
|
}
|
|
@@ -692,7 +812,11 @@ export class BuyerProxy {
|
|
|
692
812
|
this._lastSuccessfulPeerByRouteKey.delete(routeKey);
|
|
693
813
|
}
|
|
694
814
|
if (this._lastSuccessfulPeerId === peerId) {
|
|
695
|
-
|
|
815
|
+
const stillUsedByOtherRoute = Array.from(this._lastSuccessfulPeerByRouteKey.values())
|
|
816
|
+
.some((rememberedPeerId) => rememberedPeerId === peerId);
|
|
817
|
+
if (!stillUsedByOtherRoute) {
|
|
818
|
+
this._lastSuccessfulPeerId = null;
|
|
819
|
+
}
|
|
696
820
|
}
|
|
697
821
|
}
|
|
698
822
|
_buildRouteKey(path, requestProtocol, requestedModel, explicitProvider) {
|
|
@@ -701,9 +825,11 @@ export class BuyerProxy {
|
|
|
701
825
|
? '/v1/messages'
|
|
702
826
|
: normalizedPath.startsWith('/v1/chat/completions')
|
|
703
827
|
? '/v1/chat/completions'
|
|
704
|
-
: normalizedPath.startsWith('/v1/
|
|
705
|
-
? '/v1/
|
|
706
|
-
: normalizedPath)
|
|
828
|
+
: normalizedPath.startsWith('/v1/responses')
|
|
829
|
+
? '/v1/responses'
|
|
830
|
+
: normalizedPath.startsWith('/v1/models')
|
|
831
|
+
? '/v1/models'
|
|
832
|
+
: normalizedPath);
|
|
707
833
|
return [
|
|
708
834
|
pathGroup,
|
|
709
835
|
requestProtocol ?? 'unknown-protocol',
|
|
@@ -754,16 +880,14 @@ export class BuyerProxy {
|
|
|
754
880
|
return null;
|
|
755
881
|
}
|
|
756
882
|
}
|
|
757
|
-
async
|
|
883
|
+
async _discoverPeersFromNetwork() {
|
|
758
884
|
const localSeeder = await this._readLocalSeederFallback();
|
|
759
885
|
if (localSeeder) {
|
|
760
|
-
this._replacePeers([localSeeder]);
|
|
761
886
|
log(`Using local seeder ${localSeeder.peerId.slice(0, 12)}... @ ${localSeeder.publicAddress} (skipping DHT lookup)`);
|
|
762
|
-
return
|
|
887
|
+
return [localSeeder];
|
|
763
888
|
}
|
|
764
889
|
log('Discovering peers via DHT...');
|
|
765
890
|
const peers = await this._node.discoverPeers();
|
|
766
|
-
this._replacePeers(peers);
|
|
767
891
|
if (peers.length > 0) {
|
|
768
892
|
log(`Found ${peers.length} peer(s)`);
|
|
769
893
|
}
|
|
@@ -773,14 +897,22 @@ export class BuyerProxy {
|
|
|
773
897
|
if (this._peerRefreshPromise) {
|
|
774
898
|
return this._peerRefreshPromise;
|
|
775
899
|
}
|
|
776
|
-
const previousCachedPeers = this._cachedPeers;
|
|
900
|
+
const previousCachedPeers = [...this._cachedPeers];
|
|
901
|
+
const mutationEpochAtStart = this._cacheMutationEpoch;
|
|
777
902
|
this._peerRefreshPromise = (async () => {
|
|
778
|
-
const peers = await this.
|
|
779
|
-
if (peers.length
|
|
903
|
+
const peers = await this._discoverPeersFromNetwork();
|
|
904
|
+
if (peers.length > 0) {
|
|
905
|
+
this._replacePeers(peers);
|
|
906
|
+
return peers;
|
|
907
|
+
}
|
|
908
|
+
const fallbackPeers = previousCachedPeers.length > 0 && this._cacheMutationEpoch === mutationEpochAtStart
|
|
909
|
+
? [...previousCachedPeers]
|
|
910
|
+
: [];
|
|
911
|
+
if (fallbackPeers.length > 0) {
|
|
780
912
|
// Preserve stale cache as fallback when discovery transiently fails.
|
|
781
|
-
log('Discovery returned 0 peers;
|
|
782
|
-
this._replacePeers(
|
|
783
|
-
return
|
|
913
|
+
log('Discovery returned 0 peers; preserving most-recent cached peers as fallback.');
|
|
914
|
+
this._replacePeers(fallbackPeers);
|
|
915
|
+
return fallbackPeers;
|
|
784
916
|
}
|
|
785
917
|
return peers;
|
|
786
918
|
})().finally(() => {
|
|
@@ -851,13 +983,24 @@ export class BuyerProxy {
|
|
|
851
983
|
}
|
|
852
984
|
// Remove host header (points to localhost, not the seller)
|
|
853
985
|
delete headers['host'];
|
|
854
|
-
|
|
986
|
+
let serializedReq = {
|
|
855
987
|
requestId: randomUUID(),
|
|
856
988
|
method,
|
|
857
989
|
path,
|
|
858
990
|
headers,
|
|
859
991
|
body: new Uint8Array(body),
|
|
860
992
|
};
|
|
993
|
+
// Snapshot both session overrides together before any await so a concurrent
|
|
994
|
+
// _reloadSessionOverrides() cannot produce a model/peer mismatch mid-request.
|
|
995
|
+
const effectivePinnedModel = this._pinnedModel;
|
|
996
|
+
const effectivePinnedPeer = this._pinnedPeer;
|
|
997
|
+
if (effectivePinnedModel) {
|
|
998
|
+
const { body: rewrittenBody, headers: rewrittenHeaders } = rewriteModelInBody(serializedReq.body, serializedReq.headers, effectivePinnedModel);
|
|
999
|
+
if (rewrittenBody !== serializedReq.body) {
|
|
1000
|
+
serializedReq = { ...serializedReq, body: rewrittenBody, headers: rewrittenHeaders };
|
|
1001
|
+
log(`Model override applied: ${effectivePinnedModel}`);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
861
1004
|
const clientAbortController = new AbortController();
|
|
862
1005
|
const onClientAbort = () => {
|
|
863
1006
|
if (clientAbortController.signal.aborted) {
|
|
@@ -866,7 +1009,11 @@ export class BuyerProxy {
|
|
|
866
1009
|
clientAbortController.abort();
|
|
867
1010
|
log(`Client disconnected; aborting upstream request reqId=${serializedReq.requestId.slice(0, 8)}`);
|
|
868
1011
|
};
|
|
869
|
-
req.once('
|
|
1012
|
+
req.once('close', () => {
|
|
1013
|
+
if (!req.complete && !res.writableEnded) {
|
|
1014
|
+
onClientAbort();
|
|
1015
|
+
}
|
|
1016
|
+
});
|
|
870
1017
|
res.once('close', () => {
|
|
871
1018
|
if (!res.writableEnded) {
|
|
872
1019
|
onClientAbort();
|
|
@@ -884,20 +1031,30 @@ export class BuyerProxy {
|
|
|
884
1031
|
const requestedModel = extractRequestedModel(serializedReq);
|
|
885
1032
|
log(`Routing: protocol=${requestProtocol ?? 'null'} model=${requestedModel ?? 'null'}`);
|
|
886
1033
|
const explicitProvider = getExplicitProviderOverride(serializedReq);
|
|
887
|
-
const explicitPeerId = getExplicitPeerIdOverride(serializedReq,
|
|
1034
|
+
const explicitPeerId = getExplicitPeerIdOverride(serializedReq, effectivePinnedPeer ?? undefined);
|
|
888
1035
|
const preferredPeerId = getPreferredPeerIdHint(serializedReq);
|
|
889
1036
|
log(`Routing hints: provider=${explicitProvider ?? 'auto'} pin-peer=${explicitPeerId ?? 'none'} prefer-peer=${preferredPeerId ?? 'none'}`);
|
|
890
1037
|
const routeKey = this._buildRouteKey(serializedReq.path, requestProtocol, requestedModel, explicitProvider);
|
|
891
|
-
const
|
|
1038
|
+
const selectPeers = (candidateSources) => selectCandidatePeersForRouting(candidateSources, requestProtocol, requestedModel, explicitProvider);
|
|
1039
|
+
let hasForcedRefresh = false;
|
|
1040
|
+
const refreshPeerSelection = async (reason) => {
|
|
1041
|
+
if (hasForcedRefresh) {
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
hasForcedRefresh = true;
|
|
1045
|
+
log(`Forcing peer refresh before routing after ${reason}.`);
|
|
1046
|
+
discoveredPeers = await this._getPeers({ forceRefresh: true });
|
|
1047
|
+
({
|
|
1048
|
+
candidatePeers: routingPeers,
|
|
1049
|
+
routePlanByPeerId: routingPlans,
|
|
1050
|
+
} = selectPeers(discoveredPeers));
|
|
1051
|
+
};
|
|
1052
|
+
let { candidatePeers, routePlanByPeerId, } = selectPeers(peers);
|
|
892
1053
|
let routingPeers = candidatePeers;
|
|
893
1054
|
let routingPlans = routePlanByPeerId;
|
|
894
1055
|
let discoveredPeers = peers;
|
|
895
1056
|
if (routingPeers.length === 0) {
|
|
896
|
-
|
|
897
|
-
discoveredPeers = await this._getPeers({ forceRefresh: true });
|
|
898
|
-
const refreshedSelection = selectCandidatePeersForRouting(discoveredPeers, requestProtocol, requestedModel, explicitProvider);
|
|
899
|
-
routingPeers = refreshedSelection.candidatePeers;
|
|
900
|
-
routingPlans = refreshedSelection.routePlanByPeerId;
|
|
1057
|
+
await refreshPeerSelection('empty initial routing candidate set');
|
|
901
1058
|
}
|
|
902
1059
|
if (routingPeers.length === 0) {
|
|
903
1060
|
const diagnostics = this._formatPeerSelectionDiagnostics(discoveredPeers);
|
|
@@ -912,6 +1069,28 @@ export class BuyerProxy {
|
|
|
912
1069
|
}
|
|
913
1070
|
return;
|
|
914
1071
|
}
|
|
1072
|
+
const preferredProviders = explicitProvider
|
|
1073
|
+
? []
|
|
1074
|
+
: inferPreferredProvidersForRequest(requestProtocol, requestedModel);
|
|
1075
|
+
let hasPreferredProviderCandidate = preferredProviders.length > 0
|
|
1076
|
+
&& routingPeers.some((peer) => {
|
|
1077
|
+
const provider = routingPlans.get(peer.peerId)?.provider?.trim().toLowerCase();
|
|
1078
|
+
return Boolean(provider && preferredProviders.includes(provider));
|
|
1079
|
+
});
|
|
1080
|
+
if (preferredProviders.length > 0 && !hasPreferredProviderCandidate) {
|
|
1081
|
+
await refreshPeerSelection(`missing preferred providers [${preferredProviders.join(',')}]`);
|
|
1082
|
+
hasPreferredProviderCandidate = routingPeers.some((peer) => {
|
|
1083
|
+
const provider = routingPlans.get(peer.peerId)?.provider?.trim().toLowerCase();
|
|
1084
|
+
return Boolean(provider && preferredProviders.includes(provider));
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
1087
|
+
if (routingPeers.length === 0) {
|
|
1088
|
+
const diagnostics = this._formatPeerSelectionDiagnostics(discoveredPeers);
|
|
1089
|
+
res.writeHead(502, { 'content-type': 'text/plain' });
|
|
1090
|
+
const providerLabel = explicitProvider ? ` for provider "${explicitProvider}"` : '';
|
|
1091
|
+
res.end(`No peers support ${requestProtocol ?? 'this request'}${providerLabel}. ${diagnostics}`);
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
915
1094
|
log(`Routing candidates: ${routingPeers.length} peer(s)`);
|
|
916
1095
|
// Select peer: explicit pin bypasses the router (and retry)
|
|
917
1096
|
const router = this._node.router;
|
|
@@ -921,11 +1100,9 @@ export class BuyerProxy {
|
|
|
921
1100
|
let pinnedRoutePlans = routingPlans;
|
|
922
1101
|
let selectedPeer = pinnedRoutingPeers.find((p) => p.peerId.toLowerCase() === explicitPeerId) ?? null;
|
|
923
1102
|
if (!selectedPeer) {
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
pinnedRoutingPeers = refreshedSelection.candidatePeers;
|
|
928
|
-
pinnedRoutePlans = refreshedSelection.routePlanByPeerId;
|
|
1103
|
+
await refreshPeerSelection(`pinned peer ${explicitPeerId.slice(0, 12)}... not in candidate set`);
|
|
1104
|
+
pinnedRoutingPeers = routingPeers;
|
|
1105
|
+
pinnedRoutePlans = routingPlans;
|
|
929
1106
|
selectedPeer = pinnedRoutingPeers.find((p) => p.peerId.toLowerCase() === explicitPeerId) ?? null;
|
|
930
1107
|
}
|
|
931
1108
|
if (!selectedPeer) {
|
|
@@ -955,27 +1132,23 @@ export class BuyerProxy {
|
|
|
955
1132
|
// Non-pinned: retry with failover on provider errors
|
|
956
1133
|
const MAX_ATTEMPTS = 3;
|
|
957
1134
|
const triedPeerIds = new Set();
|
|
958
|
-
const preferredProviders = explicitProvider
|
|
959
|
-
? []
|
|
960
|
-
: inferPreferredProvidersForRequest(requestProtocol, requestedModel);
|
|
961
|
-
const hasPreferredProviderCandidate = preferredProviders.length > 0
|
|
962
|
-
&& routingPeers.some((peer) => {
|
|
963
|
-
const provider = routingPlans.get(peer.peerId)?.provider?.trim().toLowerCase();
|
|
964
|
-
return Boolean(provider && preferredProviders.includes(provider));
|
|
965
|
-
});
|
|
966
1135
|
const restrictFailoverToPreferredProviders = preferredProviders.length > 0 && hasPreferredProviderCandidate;
|
|
967
1136
|
if (restrictFailoverToPreferredProviders) {
|
|
968
|
-
log(`Provider-family
|
|
1137
|
+
log(`Provider-family preference active (attempt 1): [${preferredProviders.join(',')}]`);
|
|
969
1138
|
}
|
|
970
1139
|
let lastStatusCode = 502;
|
|
971
1140
|
let lastResponseBody = null;
|
|
972
1141
|
let lastResponseHeaders = { 'content-type': 'text/plain' };
|
|
973
1142
|
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
|
1143
|
+
const limitToPreferredProviders = restrictFailoverToPreferredProviders && attempt === 0;
|
|
1144
|
+
if (restrictFailoverToPreferredProviders && attempt === 1) {
|
|
1145
|
+
log('Preferred provider attempt failed; expanding failover to all compatible providers.');
|
|
1146
|
+
}
|
|
974
1147
|
const availableCandidates = routingPeers.filter((peer) => {
|
|
975
1148
|
if (triedPeerIds.has(peer.peerId)) {
|
|
976
1149
|
return false;
|
|
977
1150
|
}
|
|
978
|
-
if (!
|
|
1151
|
+
if (!limitToPreferredProviders) {
|
|
979
1152
|
return true;
|
|
980
1153
|
}
|
|
981
1154
|
const provider = routingPlans.get(peer.peerId)?.provider?.trim().toLowerCase();
|
|
@@ -1138,13 +1311,37 @@ export class BuyerProxy {
|
|
|
1138
1311
|
});
|
|
1139
1312
|
forceDisableUpstreamStreaming = true;
|
|
1140
1313
|
}
|
|
1314
|
+
else if (requestProtocol === 'openai-responses'
|
|
1315
|
+
&& selectedRoutePlan.selection.targetProtocol === 'openai-chat-completions') {
|
|
1316
|
+
log(`Applying protocol adapter openai-responses -> openai-chat-completions via provider "${selectedRoutePlan.provider}"`);
|
|
1317
|
+
const transformed = transformOpenAIResponsesRequestToOpenAIChat(requestForPeer);
|
|
1318
|
+
if (!transformed) {
|
|
1319
|
+
res.writeHead(502, { 'content-type': 'text/plain' });
|
|
1320
|
+
res.end('Failed to transform Responses API request for selected provider protocol');
|
|
1321
|
+
return { done: true };
|
|
1322
|
+
}
|
|
1323
|
+
requestForPeer = {
|
|
1324
|
+
...transformed.request,
|
|
1325
|
+
headers: {
|
|
1326
|
+
...transformed.request.headers,
|
|
1327
|
+
'x-antseed-provider': selectedRoutePlan.provider,
|
|
1328
|
+
},
|
|
1329
|
+
};
|
|
1330
|
+
adaptResponse = (response) => transformOpenAIChatResponseToOpenAIResponses(response, {
|
|
1331
|
+
fallbackModel: transformed.requestedModel,
|
|
1332
|
+
streamRequested: transformed.streamRequested,
|
|
1333
|
+
});
|
|
1334
|
+
forceDisableUpstreamStreaming = true;
|
|
1335
|
+
}
|
|
1141
1336
|
else {
|
|
1142
1337
|
res.writeHead(502, { 'content-type': 'text/plain' });
|
|
1143
1338
|
res.end('Unsupported protocol transformation path');
|
|
1144
1339
|
return { done: true };
|
|
1145
1340
|
}
|
|
1146
1341
|
}
|
|
1147
|
-
|
|
1342
|
+
if (DEBUG()) {
|
|
1343
|
+
log(`Outbound request shape: ${summarizeRequestShape(requestForPeer)}`);
|
|
1344
|
+
}
|
|
1148
1345
|
log(`Routing to peer ${selectedPeer.peerId.slice(0, 12)}...`);
|
|
1149
1346
|
// Forward through P2P
|
|
1150
1347
|
const wantsStreaming = !forceDisableUpstreamStreaming
|
|
@@ -1159,12 +1356,7 @@ export class BuyerProxy {
|
|
|
1159
1356
|
return;
|
|
1160
1357
|
streamed = true;
|
|
1161
1358
|
const streamingHeaders = attachStreamingAntseedHeaders(startResponse.headers, selectedPeer, requestForPeer.requestId);
|
|
1162
|
-
// Ensure SSE-friendly headers so intermediaries don't buffer
|
|
1163
|
-
/* streamingHeaders['cache-control'] = 'no-cache, no-transform'
|
|
1164
|
-
streamingHeaders['x-accel-buffering'] = 'no' */
|
|
1165
1359
|
res.writeHead(startResponse.statusCode, streamingHeaders);
|
|
1166
|
-
// Disable Nagle's algorithm on the underlying socket for low-latency streaming
|
|
1167
|
-
// res.socket?.setNoDelay(true)
|
|
1168
1360
|
if (startResponse.body.length > 0) {
|
|
1169
1361
|
res.write(Buffer.from(startResponse.body));
|
|
1170
1362
|
}
|
|
@@ -1214,7 +1406,7 @@ export class BuyerProxy {
|
|
|
1214
1406
|
}
|
|
1215
1407
|
else {
|
|
1216
1408
|
const upstreamResponse = await this._node.sendRequest(selectedPeer, requestForPeer, { signal: requestSignal });
|
|
1217
|
-
if (upstreamResponse.statusCode >= 400) {
|
|
1409
|
+
if (upstreamResponse.statusCode >= 400 && !adaptResponse) {
|
|
1218
1410
|
log(`Upstream raw error detail: ${summarizeErrorResponse(upstreamResponse)}`);
|
|
1219
1411
|
}
|
|
1220
1412
|
let response = upstreamResponse;
|
|
@@ -1224,7 +1416,8 @@ export class BuyerProxy {
|
|
|
1224
1416
|
const latencyMs = Date.now() - startTime;
|
|
1225
1417
|
log(`Response: ${response.statusCode} (${latencyMs}ms, ${response.body.length} bytes)`);
|
|
1226
1418
|
if (response.statusCode >= 400) {
|
|
1227
|
-
|
|
1419
|
+
const prefix = adaptResponse ? 'Upstream adapted error detail' : 'Upstream error detail';
|
|
1420
|
+
log(`${prefix}: ${summarizeErrorResponse(response)}`);
|
|
1228
1421
|
}
|
|
1229
1422
|
const telemetry = computeResponseTelemetry(requestForPeer, response.headers, response.body, selectedPeer);
|
|
1230
1423
|
const responseHeaders = attachAntseedTelemetryHeaders(response.headers, selectedPeer, telemetry, requestForPeer.requestId, latencyMs);
|
|
@@ -1252,15 +1445,39 @@ export class BuyerProxy {
|
|
|
1252
1445
|
catch (err) {
|
|
1253
1446
|
const latencyMs = Date.now() - startTime;
|
|
1254
1447
|
const message = err instanceof Error ? err.message : String(err);
|
|
1255
|
-
const abortedLocally = requestSignal.aborted
|
|
1448
|
+
const abortedLocally = requestSignal.aborted;
|
|
1449
|
+
const connectionChurnError = isConnectionChurnError(message);
|
|
1256
1450
|
log(`Request failed after ${latencyMs}ms: ${message}`);
|
|
1257
1451
|
if (abortedLocally) {
|
|
1258
1452
|
log(`Request ${requestForPeer.requestId.slice(0, 8)} aborted locally; skipping retry, router penalty, and peer eviction.`);
|
|
1259
|
-
if (res.
|
|
1260
|
-
|
|
1261
|
-
|
|
1453
|
+
if (!res.writableEnded) {
|
|
1454
|
+
let responded = false;
|
|
1455
|
+
if (!res.headersSent) {
|
|
1456
|
+
try {
|
|
1457
|
+
res.writeHead(499, { 'content-type': 'text/plain' });
|
|
1458
|
+
responded = true;
|
|
1459
|
+
}
|
|
1460
|
+
catch {
|
|
1461
|
+
// ignore
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
try {
|
|
1465
|
+
if (res.writableEnded) {
|
|
1466
|
+
// no-op
|
|
1467
|
+
}
|
|
1468
|
+
else {
|
|
1469
|
+
if (responded) {
|
|
1470
|
+
res.end('Request cancelled');
|
|
1471
|
+
}
|
|
1472
|
+
else {
|
|
1473
|
+
res.end();
|
|
1474
|
+
}
|
|
1475
|
+
responded = true;
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
catch {
|
|
1479
|
+
// ignore
|
|
1262
1480
|
}
|
|
1263
|
-
return { done: true };
|
|
1264
1481
|
}
|
|
1265
1482
|
return { done: true };
|
|
1266
1483
|
}
|
|
@@ -1278,6 +1495,16 @@ export class BuyerProxy {
|
|
|
1278
1495
|
if (isControlPlaneModelsRequest) {
|
|
1279
1496
|
log(`Skipping peer eviction for control-plane failure on ${requestForPeer.path}`);
|
|
1280
1497
|
}
|
|
1498
|
+
else if (connectionChurnError) {
|
|
1499
|
+
const currentState = this._node.getPeerConnectionState(selectedPeer.peerId);
|
|
1500
|
+
if (isConnectionHealthy(currentState)) {
|
|
1501
|
+
log(`Skipping peer eviction after connection churn: peer ${selectedPeer.peerId.slice(0, 12)}... `
|
|
1502
|
+
+ `has replacement connection state=${currentState}`);
|
|
1503
|
+
}
|
|
1504
|
+
else {
|
|
1505
|
+
this._evictPeer(selectedPeer.peerId);
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1281
1508
|
else {
|
|
1282
1509
|
// Evict only the failing peer — others remain usable.
|
|
1283
1510
|
this._evictPeer(selectedPeer.peerId);
|