@tokenbuddy/tokenbuddy 1.0.26 → 1.0.28
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/bin/tb-clawtip-proof.js +2 -0
- package/dist/src/clawtip-bootstrap.d.ts +1 -0
- package/dist/src/clawtip-bootstrap.d.ts.map +1 -1
- package/dist/src/clawtip-bootstrap.js +1 -0
- package/dist/src/clawtip-bootstrap.js.map +1 -1
- package/dist/src/cli.d.ts +1 -0
- package/dist/src/cli.d.ts.map +1 -1
- package/dist/src/cli.js +172 -51
- package/dist/src/cli.js.map +1 -1
- package/dist/src/daemon.d.ts +6 -0
- package/dist/src/daemon.d.ts.map +1 -1
- package/dist/src/daemon.js +562 -292
- package/dist/src/daemon.js.map +1 -1
- package/dist/src/init-clawtip-activation.d.ts +5 -0
- package/dist/src/init-clawtip-activation.d.ts.map +1 -1
- package/dist/src/init-clawtip-activation.js +61 -1
- package/dist/src/init-clawtip-activation.js.map +1 -1
- package/dist/src/package-update.d.ts +60 -0
- package/dist/src/package-update.d.ts.map +1 -0
- package/dist/src/package-update.js +218 -0
- package/dist/src/package-update.js.map +1 -0
- package/dist/src/registry-trust.d.ts +7 -0
- package/dist/src/registry-trust.d.ts.map +1 -0
- package/dist/src/registry-trust.js +37 -0
- package/dist/src/registry-trust.js.map +1 -0
- package/dist/src/route-failover.d.ts +2 -2
- package/dist/src/route-failover.d.ts.map +1 -1
- package/dist/src/route-failover.js +11 -0
- package/dist/src/route-failover.js.map +1 -1
- package/dist/src/seller-catalog.d.ts +20 -0
- package/dist/src/seller-catalog.d.ts.map +1 -1
- package/dist/src/seller-catalog.js +41 -4
- package/dist/src/seller-catalog.js.map +1 -1
- package/dist/src/seller-concurrency-limiter.d.ts +36 -0
- package/dist/src/seller-concurrency-limiter.d.ts.map +1 -0
- package/dist/src/seller-concurrency-limiter.js +126 -0
- package/dist/src/seller-concurrency-limiter.js.map +1 -0
- package/dist/src/seller-pool.d.ts +7 -1
- package/dist/src/seller-pool.d.ts.map +1 -1
- package/dist/src/seller-pool.js +18 -0
- package/dist/src/seller-pool.js.map +1 -1
- package/dist/src/seller-route-planner.d.ts +21 -0
- package/dist/src/seller-route-planner.d.ts.map +1 -1
- package/dist/src/seller-route-planner.js +98 -20
- package/dist/src/seller-route-planner.js.map +1 -1
- package/dist/src/tb-clawtip-proof.d.ts +3 -0
- package/dist/src/tb-clawtip-proof.d.ts.map +1 -0
- package/dist/src/tb-clawtip-proof.js +24 -0
- package/dist/src/tb-clawtip-proof.js.map +1 -0
- package/dist/src/tb-proxyd.js +45 -3
- package/dist/src/tb-proxyd.js.map +1 -1
- package/package.json +3 -2
- package/src/clawtip-bootstrap.ts +1 -0
- package/src/cli.ts +200 -47
- package/src/daemon.ts +347 -50
- package/src/init-clawtip-activation.ts +77 -1
- package/src/package-update.ts +311 -0
- package/src/registry-trust.ts +51 -0
- package/src/route-failover.ts +14 -2
- package/src/seller-catalog.ts +67 -4
- package/src/seller-concurrency-limiter.ts +161 -0
- package/src/seller-pool.ts +20 -0
- package/src/seller-route-planner.ts +142 -20
- package/src/tb-clawtip-proof.ts +28 -0
- package/src/tb-proxyd.ts +48 -3
- package/static/ui/assets/index-Bzbrp7Qe.css +1 -0
- package/static/ui/assets/index-UAfOhbwC.js +236 -0
- package/static/ui/assets/index-UAfOhbwC.js.map +1 -0
- package/static/ui/index.html +2 -2
- package/tests/cli-routing.test.ts +37 -4
- package/tests/control-plane-ui-endpoints.test.ts +7 -7
- package/tests/daemon-trusted-registry-cache.test.ts +132 -0
- package/tests/e2e.test.ts +14 -1
- package/tests/package-update.test.ts +147 -0
- package/tests/registry-trust.test.ts +28 -0
- package/tests/route-failover.test.ts +13 -0
- package/tests/seller-catalog-413.test.ts +60 -1
- package/tests/seller-concurrency-limiter.test.ts +83 -0
- package/tests/seller-pool.test.ts +23 -0
- package/tests/seller-route-planner.test.ts +78 -0
- package/tests/tokenbuddy.test.ts +316 -34
- package/static/ui/assets/index-1uuyCCzj.css +0 -1
- package/static/ui/assets/index-cm_EgQZ-.js +0 -236
- package/static/ui/assets/index-cm_EgQZ-.js.map +0 -1
package/dist/src/daemon.js
CHANGED
|
@@ -6,22 +6,26 @@ import * as path from "path";
|
|
|
6
6
|
import { ErrorCode } from "@tokenbuddy/contracts";
|
|
7
7
|
import { createModuleLogger } from "@tokenbuddy/logging";
|
|
8
8
|
import { BuyerStore } from "./buyer-store.js";
|
|
9
|
-
import { fetchClawtipBootstrap } from "./clawtip-bootstrap.js";
|
|
9
|
+
import { DEFAULT_CLAWTIP_BOOTSTRAP_URL, fetchClawtipBootstrap } from "./clawtip-bootstrap.js";
|
|
10
10
|
import { inspectOpenClawWalletConfig, } from "./init-payment-options.js";
|
|
11
11
|
import { startClawtipWalletBootstrap, waitForClawtipActivationConfirmation, } from "./init-clawtip-activation.js";
|
|
12
12
|
import { applyProviderInstall, detectProviders, previewProviderInstall, rollbackProviderInstall, } from "./provider-install.js";
|
|
13
|
-
import { discoverSellerBackedModels,
|
|
13
|
+
import { discoverSellerBackedModels, fetchSellerRegistryWithTrust, isBuyerVisibleRegistrySeller, normalizeSellerUrl, RegistryTooLargeError, } from "./seller-catalog.js";
|
|
14
|
+
import { shouldVerifyRegistry, verifyTrustedRegistrySignature } from "./registry-trust.js";
|
|
14
15
|
import { ModelIndex } from "./model-index.js";
|
|
15
16
|
import { PrewarmCache, prewarmKey } from "./prewarm-cache.js";
|
|
16
17
|
import { CreditTracker } from "./credit-tracker.js";
|
|
17
18
|
import { SellerPool } from "./seller-pool.js";
|
|
18
19
|
import { RouteFailover } from "./route-failover.js";
|
|
19
20
|
import { PrewarmScheduler } from "./prewarm-scheduler.js";
|
|
21
|
+
import { SellerConcurrencyLimiter } from "./seller-concurrency-limiter.js";
|
|
20
22
|
import { planSellerRouteSet } from "./seller-route-planner.js";
|
|
21
23
|
import { assertSellerRoutingConfig, mergeSellerRoutingConfig, normalizeSellerRoutingConfig, parseSellerIdList, ROUTING_CONFIG_KEY } from "./seller-routing-config.js";
|
|
22
24
|
import { assertInitSetupSteps, buildCompletedInitSetupMarker, INIT_SETUP_CONFIG_KEY, INIT_SETUP_STEPS, isFreshInitMachine, normalizeInitSetupMarker, resolveInitRecommendedModels, } from "./init-setup.js";
|
|
25
|
+
import { checkPackageUpdate, runPackageUpdate, scheduleLaunchAgentRestart, } from "./package-update.js";
|
|
23
26
|
const logger = createModuleLogger("tb-proxyd");
|
|
24
27
|
const FOCUS_SET_CONFIG_KEY = "focus-set";
|
|
28
|
+
const TRUSTED_REGISTRY_CACHE_CONFIG_KEY = "trusted-registry-snapshot";
|
|
25
29
|
const PROXY_JSON_BODY_LIMIT = "10mb";
|
|
26
30
|
const SELLER_CAPACITY_BLOCK_MS = 2_000;
|
|
27
31
|
const CLAWTIP_STATIC_ROUTE = "/static/clawtip";
|
|
@@ -306,6 +310,7 @@ export class TokenbuddyDaemon {
|
|
|
306
310
|
// config-derived knobs. The `!` opts out of strict-initialization so the
|
|
307
311
|
// rest of the class can treat it as non-nullable.
|
|
308
312
|
prewarmScheduler;
|
|
313
|
+
sellerConcurrencyLimiter;
|
|
309
314
|
constructor(config) {
|
|
310
315
|
this.tokenStore = new BuyerStore({ dbPath: config.dbPath });
|
|
311
316
|
const storedRouting = this.tokenStore.getDaemonRuntimeConfig(ROUTING_CONFIG_KEY)
|
|
@@ -314,6 +319,7 @@ export class TokenbuddyDaemon {
|
|
|
314
319
|
?.config;
|
|
315
320
|
this.config = config;
|
|
316
321
|
this.sellerRouting = mergeSellerRoutingConfig(storedRouting, config.sellerRouting);
|
|
322
|
+
this.sellerConcurrencyLimiter = new SellerConcurrencyLimiter(config.sellerConcurrency);
|
|
317
323
|
this.selectionMode = selectionModeForRouting(this.sellerRouting);
|
|
318
324
|
this.selectedSellerId = selectedSellerIdForRouting(this.sellerRouting);
|
|
319
325
|
// tb-ui v1: explicit focus set 优先于 env / historical
|
|
@@ -433,7 +439,7 @@ export class TokenbuddyDaemon {
|
|
|
433
439
|
};
|
|
434
440
|
}
|
|
435
441
|
async startClawtipActivationQr() {
|
|
436
|
-
const bootstrapUrl = process.env.TOKENBUDDY_BOOTSTRAP_URL ||
|
|
442
|
+
const bootstrapUrl = process.env.TOKENBUDDY_BOOTSTRAP_URL || DEFAULT_CLAWTIP_BOOTSTRAP_URL;
|
|
437
443
|
const fetchBootstrap = this.config.clawtipBootstrapFetcher || fetchClawtipBootstrap;
|
|
438
444
|
const bootstrap = await fetchBootstrap(bootstrapUrl);
|
|
439
445
|
const payment = normalizeClawtipActivationPayment(bootstrap);
|
|
@@ -577,13 +583,15 @@ export class TokenbuddyDaemon {
|
|
|
577
583
|
lastRegistrySnapshot = null;
|
|
578
584
|
async fetchRegistry() {
|
|
579
585
|
try {
|
|
580
|
-
const
|
|
586
|
+
const fetched = await fetchSellerRegistryWithTrust(this.config.sellerRegistryUrl);
|
|
587
|
+
const registry = fetched.registry;
|
|
581
588
|
this.modelIndex.rebuild(registry.sellers, {
|
|
582
589
|
registryVersion: registry.version,
|
|
583
590
|
defaultSellerId: registry.defaultSeller
|
|
584
591
|
});
|
|
585
592
|
this.sellerPool.sync();
|
|
586
593
|
this.lastRegistrySnapshot = registry;
|
|
594
|
+
this.saveTrustedRegistryCache(fetched);
|
|
587
595
|
return registry;
|
|
588
596
|
}
|
|
589
597
|
catch (err) {
|
|
@@ -604,9 +612,103 @@ export class TokenbuddyDaemon {
|
|
|
604
612
|
this.sellerPool.sync();
|
|
605
613
|
return stale;
|
|
606
614
|
}
|
|
615
|
+
const cached = this.loadTrustedRegistryCache(err);
|
|
616
|
+
if (cached) {
|
|
617
|
+
this.modelIndex.rebuild(cached.sellers, {
|
|
618
|
+
registryVersion: cached.version,
|
|
619
|
+
defaultSellerId: cached.defaultSeller
|
|
620
|
+
});
|
|
621
|
+
this.sellerPool.sync();
|
|
622
|
+
this.lastRegistrySnapshot = cached;
|
|
623
|
+
return cached;
|
|
624
|
+
}
|
|
607
625
|
throw err;
|
|
608
626
|
}
|
|
609
627
|
}
|
|
628
|
+
saveTrustedRegistryCache(fetched) {
|
|
629
|
+
const cache = {
|
|
630
|
+
schemaVersion: 1,
|
|
631
|
+
registryUrl: this.config.sellerRegistryUrl,
|
|
632
|
+
version: fetched.registry.version,
|
|
633
|
+
registrySha256: fetched.trust.registrySha256,
|
|
634
|
+
cachedAt: new Date().toISOString(),
|
|
635
|
+
registryJson: fetched.registryJson,
|
|
636
|
+
registry: fetched.registry,
|
|
637
|
+
trust: fetched.trust
|
|
638
|
+
};
|
|
639
|
+
this.tokenStore.saveDaemonRuntimeConfig(TRUSTED_REGISTRY_CACHE_CONFIG_KEY, cache);
|
|
640
|
+
logger.info("registry.trusted_cache.saved", "trusted registry snapshot cached", {
|
|
641
|
+
registryUrl: cache.registryUrl,
|
|
642
|
+
registryVersion: cache.version ?? null,
|
|
643
|
+
registrySha256: cache.registrySha256,
|
|
644
|
+
verified: cache.trust.verified,
|
|
645
|
+
signingKeyId: cache.trust.signingKeyId ?? null
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
loadTrustedRegistryCache(error) {
|
|
649
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
650
|
+
const record = this.tokenStore.getDaemonRuntimeConfig(TRUSTED_REGISTRY_CACHE_CONFIG_KEY)?.config;
|
|
651
|
+
const cache = normalizeTrustedRegistryCache(record);
|
|
652
|
+
if (!cache) {
|
|
653
|
+
logger.warn("registry.trusted_cache.missing", "trusted registry cache is unavailable", {
|
|
654
|
+
registryUrl: this.config.sellerRegistryUrl,
|
|
655
|
+
errorMessage
|
|
656
|
+
});
|
|
657
|
+
return undefined;
|
|
658
|
+
}
|
|
659
|
+
if (cache.registryUrl !== this.config.sellerRegistryUrl) {
|
|
660
|
+
logger.warn("registry.trusted_cache.missing", "trusted registry cache URL mismatch", {
|
|
661
|
+
registryUrl: this.config.sellerRegistryUrl,
|
|
662
|
+
cachedRegistryUrl: cache.registryUrl,
|
|
663
|
+
errorMessage
|
|
664
|
+
});
|
|
665
|
+
return undefined;
|
|
666
|
+
}
|
|
667
|
+
const actualHash = crypto.createHash("sha256").update(cache.registryJson).digest("hex");
|
|
668
|
+
if (actualHash !== cache.registrySha256 || actualHash !== cache.trust.registrySha256) {
|
|
669
|
+
logger.warn("registry.trusted_cache.missing", "trusted registry cache hash mismatch", {
|
|
670
|
+
registryUrl: this.config.sellerRegistryUrl,
|
|
671
|
+
cachedVersion: cache.version ?? null,
|
|
672
|
+
errorMessage
|
|
673
|
+
});
|
|
674
|
+
return undefined;
|
|
675
|
+
}
|
|
676
|
+
if (shouldVerifyRegistry(this.config.sellerRegistryUrl)) {
|
|
677
|
+
if (!cache.trust.signature) {
|
|
678
|
+
logger.warn("registry.trusted_cache.missing", "trusted registry cache has no signature", {
|
|
679
|
+
registryUrl: this.config.sellerRegistryUrl,
|
|
680
|
+
cachedVersion: cache.version ?? null,
|
|
681
|
+
errorMessage
|
|
682
|
+
});
|
|
683
|
+
return undefined;
|
|
684
|
+
}
|
|
685
|
+
try {
|
|
686
|
+
const signingKeyId = verifyTrustedRegistrySignature(cache.registryJson, cache.trust.signature);
|
|
687
|
+
if (cache.trust.signingKeyId && cache.trust.signingKeyId !== signingKeyId) {
|
|
688
|
+
throw new Error("registry cache signing key mismatch");
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
catch (verifyError) {
|
|
692
|
+
logger.warn("registry.trusted_cache.missing", "trusted registry cache signature verification failed", {
|
|
693
|
+
registryUrl: this.config.sellerRegistryUrl,
|
|
694
|
+
cachedVersion: cache.version ?? null,
|
|
695
|
+
errorMessage: verifyError instanceof Error ? verifyError.message : String(verifyError)
|
|
696
|
+
});
|
|
697
|
+
return undefined;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
logger.warn("registry.trusted_cache.used", "using trusted registry cache after refresh failure", {
|
|
701
|
+
registryUrl: this.config.sellerRegistryUrl,
|
|
702
|
+
cachedVersion: cache.version ?? null,
|
|
703
|
+
cachedSellers: cache.registry.sellers.length,
|
|
704
|
+
cachedAt: cache.cachedAt,
|
|
705
|
+
registrySha256: cache.registrySha256,
|
|
706
|
+
verified: cache.trust.verified,
|
|
707
|
+
signingKeyId: cache.trust.signingKeyId ?? null,
|
|
708
|
+
errorMessage
|
|
709
|
+
});
|
|
710
|
+
return cache.registry;
|
|
711
|
+
}
|
|
610
712
|
runtimeSummary() {
|
|
611
713
|
this.refreshSellerRoutingConfig();
|
|
612
714
|
return {
|
|
@@ -621,6 +723,7 @@ export class TokenbuddyDaemon {
|
|
|
621
723
|
selectedSellerId: this.selectedSellerId,
|
|
622
724
|
dbPath: this.config.dbPath,
|
|
623
725
|
sellerRegistryUrl: this.config.sellerRegistryUrl,
|
|
726
|
+
sellerConcurrency: this.sellerConcurrencyLimiter.snapshot(),
|
|
624
727
|
store: this.tokenStore.summary()
|
|
625
728
|
};
|
|
626
729
|
}
|
|
@@ -913,7 +1016,7 @@ export class TokenbuddyDaemon {
|
|
|
913
1016
|
const payments = this.tokenStore.listPayments().filter((payment) => payment.enabled);
|
|
914
1017
|
return payments.find((payment) => payment.isDefault)?.method || payments.find((payment) => payment.method === "mock")?.method;
|
|
915
1018
|
}
|
|
916
|
-
async selectSellerRoutes(endpoint, modelId) {
|
|
1019
|
+
async selectSellerRoutes(endpoint, modelId, requestId) {
|
|
917
1020
|
const protocol = this.endpointProtocol(endpoint);
|
|
918
1021
|
if (!protocol) {
|
|
919
1022
|
throw new Error(`unsupported proxy endpoint: ${endpoint}`);
|
|
@@ -932,7 +1035,10 @@ export class TokenbuddyDaemon {
|
|
|
932
1035
|
const registrySellers = reorderDefaultSellerFirst(registry.sellers, registry.defaultSeller);
|
|
933
1036
|
this.sellerPool.ensureRegistrySellers(registrySellers);
|
|
934
1037
|
this.scheduleLazyPrewarmIfNeeded(modelId, protocol, paymentMethod);
|
|
1038
|
+
this.sellerPool.recycleOpenCircuits();
|
|
935
1039
|
const poolById = new Map(this.sellerPool.snapshot().map((entry) => [entry.sellerId, entry]));
|
|
1040
|
+
const concurrencySnapshot = this.sellerConcurrencyLimiter.snapshot();
|
|
1041
|
+
const localConcurrencyBySellerId = new Map(concurrencySnapshot.active.map((entry) => [entry.sellerId, entry.activeCount]));
|
|
936
1042
|
const planned = planSellerRouteSet({
|
|
937
1043
|
modelId,
|
|
938
1044
|
protocol,
|
|
@@ -947,22 +1053,18 @@ export class TokenbuddyDaemon {
|
|
|
947
1053
|
ttftMs: entry.ttftMs,
|
|
948
1054
|
avgInferenceMs: entry.avgInferenceMs,
|
|
949
1055
|
circuit: entry.circuit,
|
|
950
|
-
capacityBlockedUntil: entry.capacityBlockedUntil
|
|
1056
|
+
capacityBlockedUntil: entry.capacityBlockedUntil,
|
|
1057
|
+
...(concurrencySnapshot.enabled
|
|
1058
|
+
? {
|
|
1059
|
+
localConcurrencyActive: localConcurrencyBySellerId.get(entry.sellerId) ?? 0,
|
|
1060
|
+
localConcurrencyLimit: concurrencySnapshot.maxInFlightPerSeller
|
|
1061
|
+
}
|
|
1062
|
+
: {})
|
|
951
1063
|
})),
|
|
952
1064
|
now: Date.now()
|
|
953
1065
|
});
|
|
954
|
-
if (planned.routes.length === 0) {
|
|
955
|
-
throw new Error(`no compatible seller for ${endpoint} model ${modelId}`);
|
|
956
|
-
}
|
|
957
|
-
const routes = planned.routes.map((route) => ({
|
|
958
|
-
seller: route.seller,
|
|
959
|
-
manifest: null,
|
|
960
|
-
protocol,
|
|
961
|
-
modelId,
|
|
962
|
-
paymentMethod,
|
|
963
|
-
poolEntry: poolById.get(route.seller.id)
|
|
964
|
-
}));
|
|
965
1066
|
logger.info("route.candidates.prewarmed", "seller route candidates prewarmed", {
|
|
1067
|
+
requestId,
|
|
966
1068
|
model: modelId,
|
|
967
1069
|
endpoint,
|
|
968
1070
|
protocol,
|
|
@@ -973,9 +1075,24 @@ export class TokenbuddyDaemon {
|
|
|
973
1075
|
routeSource: planned.source,
|
|
974
1076
|
routeSourceReason: planned.sourceReason,
|
|
975
1077
|
routeReason: planned.reason,
|
|
976
|
-
|
|
977
|
-
|
|
1078
|
+
candidateDiagnostics: planned.diagnostics,
|
|
1079
|
+
sellerCount: planned.routes.length,
|
|
1080
|
+
sellers: planned.routes.map((route) => route.seller.id)
|
|
978
1081
|
});
|
|
1082
|
+
if (planned.routes.length === 0) {
|
|
1083
|
+
throw new Error(`no compatible seller for ${endpoint} model ${modelId}`);
|
|
1084
|
+
}
|
|
1085
|
+
const routes = planned.routes.map((route) => ({
|
|
1086
|
+
seller: route.seller,
|
|
1087
|
+
manifest: null,
|
|
1088
|
+
protocol,
|
|
1089
|
+
modelId,
|
|
1090
|
+
paymentMethod,
|
|
1091
|
+
planSource: planned.source,
|
|
1092
|
+
planReason: planned.reason,
|
|
1093
|
+
planSellerCount: planned.routes.length,
|
|
1094
|
+
poolEntry: poolById.get(route.seller.id)
|
|
1095
|
+
}));
|
|
979
1096
|
return { routes, plan: planned, paymentMethod };
|
|
980
1097
|
}
|
|
981
1098
|
refreshSellerRoutingConfig() {
|
|
@@ -1113,6 +1230,8 @@ export class TokenbuddyDaemon {
|
|
|
1113
1230
|
* the controller loop readable.
|
|
1114
1231
|
*/
|
|
1115
1232
|
handleFailoverDecision(decision, context) {
|
|
1233
|
+
const routesRemainingAfterCurrent = Math.max(0, context.routesRemaining - 1);
|
|
1234
|
+
const hasNextRoute = routesRemainingAfterCurrent > 0;
|
|
1116
1235
|
if (decision.action === "retry_same_seller") {
|
|
1117
1236
|
logger.warn("route.failover.retry_scheduled", "seller route retry scheduled", {
|
|
1118
1237
|
requestId: context.requestId,
|
|
@@ -1121,8 +1240,12 @@ export class TokenbuddyDaemon {
|
|
|
1121
1240
|
endpoint: context.endpoint,
|
|
1122
1241
|
routeIndex: context.routeIndex,
|
|
1123
1242
|
routesRemaining: context.routesRemaining,
|
|
1243
|
+
routesRemainingAfterCurrent,
|
|
1244
|
+
hasNextRoute,
|
|
1124
1245
|
attempt: context.attempt,
|
|
1246
|
+
attemptNumber: context.attempt + 1,
|
|
1125
1247
|
nextAttempt: context.attempt + 1,
|
|
1248
|
+
nextAttemptNumber: context.attempt + 2,
|
|
1126
1249
|
reason: decision.reason,
|
|
1127
1250
|
status: context.status,
|
|
1128
1251
|
retryDelayMs: decision.retryDelayMs
|
|
@@ -1130,7 +1253,7 @@ export class TokenbuddyDaemon {
|
|
|
1130
1253
|
return;
|
|
1131
1254
|
}
|
|
1132
1255
|
if (decision.action === "failover_next") {
|
|
1133
|
-
logger.warn("route.failover.triggered", "seller route failed over
|
|
1256
|
+
logger.warn("route.failover.triggered", "seller route failed over after seller failure", {
|
|
1134
1257
|
requestId: context.requestId,
|
|
1135
1258
|
sellerKey: context.sellerKey,
|
|
1136
1259
|
model: context.model,
|
|
@@ -1138,7 +1261,10 @@ export class TokenbuddyDaemon {
|
|
|
1138
1261
|
routeIndex: context.routeIndex,
|
|
1139
1262
|
nextRouteIndex: context.routeIndex + 1,
|
|
1140
1263
|
routesRemaining: context.routesRemaining,
|
|
1264
|
+
routesRemainingAfterCurrent,
|
|
1265
|
+
hasNextRoute,
|
|
1141
1266
|
attempt: context.attempt,
|
|
1267
|
+
attemptNumber: context.attempt + 1,
|
|
1142
1268
|
reason: decision.reason,
|
|
1143
1269
|
status: context.status,
|
|
1144
1270
|
wastedCreditMicros: decision.wastedCreditMicros,
|
|
@@ -1154,7 +1280,10 @@ export class TokenbuddyDaemon {
|
|
|
1154
1280
|
endpoint: context.endpoint,
|
|
1155
1281
|
routeIndex: context.routeIndex,
|
|
1156
1282
|
routesRemaining: context.routesRemaining,
|
|
1283
|
+
routesRemainingAfterCurrent,
|
|
1284
|
+
hasNextRoute,
|
|
1157
1285
|
attempt: context.attempt,
|
|
1286
|
+
attemptNumber: context.attempt + 1,
|
|
1158
1287
|
action: decision.action,
|
|
1159
1288
|
reason: decision.reason,
|
|
1160
1289
|
status: context.status
|
|
@@ -1198,12 +1327,32 @@ export class TokenbuddyDaemon {
|
|
|
1198
1327
|
});
|
|
1199
1328
|
}
|
|
1200
1329
|
async listSellerBackedModels() {
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1330
|
+
try {
|
|
1331
|
+
const catalog = await discoverSellerBackedModels(this.config.sellerRegistryUrl);
|
|
1332
|
+
this.lastRegistrySnapshot = catalog.registry ?? sellerCatalogResultToRegistrySnapshot(catalog);
|
|
1333
|
+
if (catalog.registry && catalog.registryJson && catalog.registryTrust) {
|
|
1334
|
+
this.saveTrustedRegistryCache({
|
|
1335
|
+
registry: catalog.registry,
|
|
1336
|
+
registryJson: catalog.registryJson,
|
|
1337
|
+
trust: catalog.registryTrust
|
|
1338
|
+
});
|
|
1339
|
+
}
|
|
1340
|
+
return {
|
|
1341
|
+
models: catalog.models,
|
|
1342
|
+
sellers: catalog.sellers
|
|
1343
|
+
};
|
|
1344
|
+
}
|
|
1345
|
+
catch (error) {
|
|
1346
|
+
const cached = this.loadTrustedRegistryCache(error);
|
|
1347
|
+
if (!cached) {
|
|
1348
|
+
throw error;
|
|
1349
|
+
}
|
|
1350
|
+
const snapshot = catalogSnapshotFromRegistry(cached);
|
|
1351
|
+
return {
|
|
1352
|
+
models: snapshot.models,
|
|
1353
|
+
sellers: snapshot.sellers
|
|
1354
|
+
};
|
|
1355
|
+
}
|
|
1207
1356
|
}
|
|
1208
1357
|
readUsage(bodyText) {
|
|
1209
1358
|
const fallback = {
|
|
@@ -1777,7 +1926,7 @@ export class TokenbuddyDaemon {
|
|
|
1777
1926
|
return;
|
|
1778
1927
|
}
|
|
1779
1928
|
try {
|
|
1780
|
-
const { routes, plan, paymentMethod } = await this.selectSellerRoutes(endpoint, modelId);
|
|
1929
|
+
const { routes, plan, paymentMethod } = await this.selectSellerRoutes(endpoint, modelId, requestId);
|
|
1781
1930
|
const upstreamStatusFromHeaders = (h) => {
|
|
1782
1931
|
const raw = h.get("x-tokenbuddy-upstream-status") || h.get("x-upstream-status");
|
|
1783
1932
|
if (!raw)
|
|
@@ -1788,258 +1937,313 @@ export class TokenbuddyDaemon {
|
|
|
1788
1937
|
for (let routeIndex = 0; routeIndex < routes.length; routeIndex += 1) {
|
|
1789
1938
|
const route = routes[routeIndex];
|
|
1790
1939
|
const sellerKey = route.seller.id;
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
routeIndex
|
|
1841
|
-
});
|
|
1842
|
-
lastError = new Error("auto-purchase budget exceeded for this session");
|
|
1843
|
-
break;
|
|
1844
|
-
}
|
|
1845
|
-
// v1.1: a purchase failure means the seller is unreachable for
|
|
1846
|
-
// payment, not "transiently flapping". Do not retry the same
|
|
1847
|
-
// seller; transfer leftover to wasted and fail over immediately.
|
|
1848
|
-
let token;
|
|
1940
|
+
const lease = this.sellerConcurrencyLimiter.tryAcquire(sellerKey, { requestId, modelId, endpoint });
|
|
1941
|
+
if (!lease) {
|
|
1942
|
+
const routesRemaining = routes.length - routeIndex;
|
|
1943
|
+
const routesRemainingAfterCurrent = Math.max(0, routesRemaining - 1);
|
|
1944
|
+
lastError = new Error(`seller ${sellerKey} reached local concurrency limit`);
|
|
1945
|
+
logger.info("route.local_capacity.skipped", "seller route skipped by local concurrency limit", {
|
|
1946
|
+
requestId,
|
|
1947
|
+
sellerKey,
|
|
1948
|
+
sellerId: sellerKey,
|
|
1949
|
+
model: modelId,
|
|
1950
|
+
endpoint,
|
|
1951
|
+
protocol: route.protocol,
|
|
1952
|
+
paymentMethod: route.paymentMethod,
|
|
1953
|
+
routeIndex,
|
|
1954
|
+
routePlanSource: route.planSource,
|
|
1955
|
+
routePlanReason: route.planReason,
|
|
1956
|
+
routePlanSellerCount: route.planSellerCount,
|
|
1957
|
+
routesRemaining,
|
|
1958
|
+
routesRemainingAfterCurrent,
|
|
1959
|
+
hasNextRoute: routesRemainingAfterCurrent > 0,
|
|
1960
|
+
localConcurrencyEnabled: this.sellerConcurrencyLimiter.isEnabled()
|
|
1961
|
+
});
|
|
1962
|
+
continue;
|
|
1963
|
+
}
|
|
1964
|
+
try {
|
|
1965
|
+
logger.info("route.selected", "seller route selected", {
|
|
1966
|
+
requestId,
|
|
1967
|
+
sellerKey,
|
|
1968
|
+
sellerId: sellerKey,
|
|
1969
|
+
model: modelId,
|
|
1970
|
+
endpoint,
|
|
1971
|
+
protocol: route.protocol,
|
|
1972
|
+
paymentMethod: route.paymentMethod,
|
|
1973
|
+
routeIndex,
|
|
1974
|
+
backup: routeIndex > 0,
|
|
1975
|
+
routePlanSource: route.planSource,
|
|
1976
|
+
routePlanReason: route.planReason,
|
|
1977
|
+
routePlanSellerCount: route.planSellerCount,
|
|
1978
|
+
localConcurrencyEnabled: this.sellerConcurrencyLimiter.isEnabled(),
|
|
1979
|
+
localConcurrencyActive: lease.activeCount,
|
|
1980
|
+
localConcurrencyLimit: lease.maxInFlight
|
|
1981
|
+
});
|
|
1982
|
+
let attempt = 0;
|
|
1983
|
+
// Soft-failure retry budget; the route-failover controller decides
|
|
1984
|
+
// whether the same seller should be retried or we move on. The
|
|
1985
|
+
// v1 "1 retry for 4xx fallback" loop is replaced with a
|
|
1986
|
+
// stateful decision per attempt.
|
|
1987
|
+
// eslint-disable-next-line no-constant-condition
|
|
1988
|
+
while (true) {
|
|
1849
1989
|
try {
|
|
1850
|
-
|
|
1851
|
-
}
|
|
1852
|
-
catch (purchaseError) {
|
|
1853
|
-
logger.warn("purchase.failed", "seller auto-purchase failed; failing over without retry", {
|
|
1990
|
+
logger.info("proxy.request.started", "proxy request started", {
|
|
1854
1991
|
requestId,
|
|
1855
1992
|
sellerKey,
|
|
1856
1993
|
model: modelId,
|
|
1994
|
+
requestedModel: requestedModelId,
|
|
1857
1995
|
endpoint,
|
|
1858
|
-
|
|
1859
|
-
});
|
|
1860
|
-
const decision = this.routeFailover.decide({
|
|
1861
|
-
sellerId: sellerKey,
|
|
1862
|
-
errorKind: "deadline",
|
|
1863
|
-
errorMessage: this.failoverErrorMessage(purchaseError),
|
|
1996
|
+
stream: Boolean(body.stream),
|
|
1864
1997
|
attempt
|
|
1865
|
-
}
|
|
1866
|
-
|
|
1998
|
+
});
|
|
1999
|
+
const sellerUrl = normalizeSellerUrl(route.seller);
|
|
2000
|
+
const upstreamBody = this.applyResolvedModelToBody(endpoint, {
|
|
2001
|
+
...body,
|
|
2002
|
+
requestId
|
|
2003
|
+
}, modelId);
|
|
2004
|
+
logger.info("proxy.upstream_fetch.started", "proxy upstream fetch started", {
|
|
1867
2005
|
requestId,
|
|
1868
2006
|
sellerKey,
|
|
1869
2007
|
model: modelId,
|
|
1870
2008
|
endpoint,
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
routesRemaining: routes.length - routeIndex,
|
|
1874
|
-
attempt,
|
|
1875
|
-
reason: "purchase_failed",
|
|
1876
|
-
controllerReason: decision.reason,
|
|
1877
|
-
controllerAction: decision.action
|
|
2009
|
+
stream: Boolean(body.stream),
|
|
2010
|
+
bodySummary: summarizeProxyBody(upstreamBody)
|
|
1878
2011
|
});
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
const requestAc = new AbortController();
|
|
1890
|
-
const requestTimer = setTimeout(() => requestAc.abort(new Error("buyer deadline exceeded")), deadlineMs);
|
|
1891
|
-
const headers = {
|
|
1892
|
-
"Content-Type": "application/json",
|
|
1893
|
-
"Authorization": `Bearer ${token}`,
|
|
1894
|
-
"X-Request-Id": requestId,
|
|
1895
|
-
"Idempotency-Key": idempotencyKey
|
|
1896
|
-
};
|
|
1897
|
-
headers["X-TokenBuddy-Deadline-Ms"] = String(deadlineMs);
|
|
1898
|
-
try {
|
|
1899
|
-
return await fetch(`${sellerUrl}${endpoint}`, {
|
|
1900
|
-
method: "POST",
|
|
1901
|
-
headers,
|
|
1902
|
-
body: JSON.stringify(upstreamBody),
|
|
1903
|
-
signal: requestAc.signal
|
|
2012
|
+
// v1.1 §17.5: refuse to auto-purchase once the session budget is
|
|
2013
|
+
// exhausted. The seller is treated as "no auto-purchase available"
|
|
2014
|
+
// and the request fails over to the next candidate.
|
|
2015
|
+
if (!this.routeFailover.canAutoPurchase()) {
|
|
2016
|
+
logger.warn("purchase.budget.exceeded", "session auto-purchase budget exhausted; failing over without buying", {
|
|
2017
|
+
requestId,
|
|
2018
|
+
sellerKey,
|
|
2019
|
+
model: modelId,
|
|
2020
|
+
endpoint,
|
|
2021
|
+
routeIndex
|
|
1904
2022
|
});
|
|
2023
|
+
lastError = new Error("auto-purchase budget exceeded for this session");
|
|
2024
|
+
break;
|
|
1905
2025
|
}
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
const errorBody = await upstreamResponse.text();
|
|
1913
|
-
if (this.isInsufficientFundsResponse(upstreamResponse.status, errorBody)) {
|
|
1914
|
-
token = await this.recoverFromInsufficientFunds(route, token, requestId);
|
|
1915
|
-
upstreamResponse = await sendSellerRequest(token);
|
|
1916
|
-
if (upstreamResponse.ok) {
|
|
1917
|
-
logger.info("proxy.retry_after_402.succeeded", "seller request succeeded after one-shot auto purchase retry", {
|
|
1918
|
-
requestId,
|
|
1919
|
-
sellerKey,
|
|
1920
|
-
model: modelId,
|
|
1921
|
-
endpoint,
|
|
1922
|
-
durationMs: Date.now() - startedAt
|
|
1923
|
-
});
|
|
1924
|
-
}
|
|
1925
|
-
else {
|
|
1926
|
-
const retryErrorBody = await upstreamResponse.text();
|
|
1927
|
-
logger.warn("proxy.retry_after_402.failed", "seller request still failed after one-shot auto purchase retry", {
|
|
1928
|
-
requestId,
|
|
1929
|
-
sellerKey,
|
|
1930
|
-
model: modelId,
|
|
1931
|
-
endpoint,
|
|
1932
|
-
status: upstreamResponse.status,
|
|
1933
|
-
durationMs: Date.now() - startedAt
|
|
1934
|
-
});
|
|
1935
|
-
this.copyUpstreamHeaders(upstreamResponse, res);
|
|
1936
|
-
res.status(upstreamResponse.status);
|
|
1937
|
-
res.send(retryErrorBody);
|
|
1938
|
-
return;
|
|
1939
|
-
}
|
|
2026
|
+
// v1.1: a purchase failure means the seller is unreachable for
|
|
2027
|
+
// payment, not "transiently flapping". Do not retry the same
|
|
2028
|
+
// seller; fail over immediately without marking old credit wasted.
|
|
2029
|
+
let token;
|
|
2030
|
+
try {
|
|
2031
|
+
token = await this.getOrPurchaseToken(route, requestId);
|
|
1940
2032
|
}
|
|
1941
|
-
|
|
1942
|
-
logger.warn("
|
|
2033
|
+
catch (purchaseError) {
|
|
2034
|
+
logger.warn("purchase.failed", "seller auto-purchase failed; failing over without retry", {
|
|
1943
2035
|
requestId,
|
|
1944
2036
|
sellerKey,
|
|
1945
2037
|
model: modelId,
|
|
1946
2038
|
endpoint,
|
|
1947
|
-
|
|
1948
|
-
durationMs: Date.now() - startedAt
|
|
2039
|
+
errorMessage: this.failoverErrorMessage(purchaseError)
|
|
1949
2040
|
});
|
|
1950
|
-
const kind = this.classifyFailureStatus(upstreamResponse.status, errorBody);
|
|
1951
2041
|
const decision = this.routeFailover.decide({
|
|
1952
2042
|
sellerId: sellerKey,
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
errorMessage: errorBody,
|
|
2043
|
+
errorKind: "purchase_failed",
|
|
2044
|
+
errorMessage: this.failoverErrorMessage(purchaseError),
|
|
1956
2045
|
attempt
|
|
1957
2046
|
}, routes.length - routeIndex);
|
|
1958
|
-
|
|
2047
|
+
logger.warn("route.failover.triggered", "seller route failed over after purchase failure", {
|
|
1959
2048
|
requestId,
|
|
1960
2049
|
sellerKey,
|
|
1961
2050
|
model: modelId,
|
|
1962
2051
|
endpoint,
|
|
1963
2052
|
routeIndex,
|
|
2053
|
+
nextRouteIndex: routeIndex + 1,
|
|
1964
2054
|
routesRemaining: routes.length - routeIndex,
|
|
2055
|
+
routesRemainingAfterCurrent: Math.max(0, routes.length - routeIndex - 1),
|
|
2056
|
+
hasNextRoute: routeIndex + 1 < routes.length,
|
|
1965
2057
|
attempt,
|
|
1966
|
-
|
|
2058
|
+
attemptNumber: attempt + 1,
|
|
2059
|
+
reason: "purchase_failed",
|
|
2060
|
+
controllerReason: decision.reason,
|
|
2061
|
+
controllerAction: decision.action
|
|
1967
2062
|
});
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
2063
|
+
lastError = purchaseError;
|
|
2064
|
+
break;
|
|
2065
|
+
}
|
|
2066
|
+
// v1.2 §8: enforce a hard per-request deadline so a slow
|
|
2067
|
+
// upstream cannot hang the buyer. The deadline is honored by
|
|
2068
|
+
// the AbortController passed to `fetch`; sellers that observe
|
|
2069
|
+
// the `X-TokenBuddy-Deadline-Ms` header (PR-6) can propagate
|
|
2070
|
+
// it to their own upstream fetch via the same signal.
|
|
2071
|
+
const deadlineMs = this.requestDeadlineMs();
|
|
2072
|
+
const sendSellerRequest = async (token) => {
|
|
2073
|
+
const requestAc = new AbortController();
|
|
2074
|
+
const requestTimer = setTimeout(() => requestAc.abort(new Error("buyer deadline exceeded")), deadlineMs);
|
|
2075
|
+
const headers = {
|
|
2076
|
+
"Content-Type": "application/json",
|
|
2077
|
+
"Authorization": `Bearer ${token}`,
|
|
2078
|
+
"X-Request-Id": requestId,
|
|
2079
|
+
"Idempotency-Key": idempotencyKey
|
|
2080
|
+
};
|
|
2081
|
+
headers["X-TokenBuddy-Deadline-Ms"] = String(deadlineMs);
|
|
2082
|
+
try {
|
|
2083
|
+
return await fetch(`${sellerUrl}${endpoint}`, {
|
|
2084
|
+
method: "POST",
|
|
2085
|
+
headers,
|
|
2086
|
+
body: JSON.stringify(upstreamBody),
|
|
2087
|
+
signal: requestAc.signal
|
|
2088
|
+
});
|
|
2089
|
+
}
|
|
2090
|
+
finally {
|
|
2091
|
+
clearTimeout(requestTimer);
|
|
1973
2092
|
}
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
2093
|
+
};
|
|
2094
|
+
let upstreamResponse = await sendSellerRequest(token);
|
|
2095
|
+
lease.refresh();
|
|
2096
|
+
if (!upstreamResponse.ok) {
|
|
2097
|
+
const errorBody = await upstreamResponse.text();
|
|
2098
|
+
lease.refresh();
|
|
2099
|
+
if (this.isInsufficientFundsResponse(upstreamResponse.status, errorBody)) {
|
|
2100
|
+
token = await this.recoverFromInsufficientFunds(route, token, requestId);
|
|
2101
|
+
upstreamResponse = await sendSellerRequest(token);
|
|
2102
|
+
lease.refresh();
|
|
2103
|
+
if (upstreamResponse.ok) {
|
|
2104
|
+
logger.info("proxy.retry_after_402.succeeded", "seller request succeeded after one-shot auto purchase retry", {
|
|
2105
|
+
requestId,
|
|
2106
|
+
sellerKey,
|
|
2107
|
+
model: modelId,
|
|
2108
|
+
endpoint,
|
|
2109
|
+
durationMs: Date.now() - startedAt
|
|
2110
|
+
});
|
|
2111
|
+
}
|
|
2112
|
+
else {
|
|
2113
|
+
const retryErrorBody = await upstreamResponse.text();
|
|
2114
|
+
logger.warn("proxy.retry_after_402.failed", "seller request still failed after one-shot auto purchase retry", {
|
|
2115
|
+
requestId,
|
|
2116
|
+
sellerKey,
|
|
2117
|
+
model: modelId,
|
|
2118
|
+
endpoint,
|
|
2119
|
+
status: upstreamResponse.status,
|
|
2120
|
+
durationMs: Date.now() - startedAt
|
|
2121
|
+
});
|
|
2122
|
+
this.copyUpstreamHeaders(upstreamResponse, res);
|
|
2123
|
+
res.status(upstreamResponse.status);
|
|
2124
|
+
res.send(retryErrorBody);
|
|
2125
|
+
return;
|
|
1978
2126
|
}
|
|
1979
|
-
continue;
|
|
1980
2127
|
}
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2128
|
+
else {
|
|
2129
|
+
logger.warn("proxy.upstream_fetch.failed", "proxy upstream fetch returned non-ok status", {
|
|
2130
|
+
requestId,
|
|
2131
|
+
sellerKey,
|
|
2132
|
+
model: modelId,
|
|
2133
|
+
endpoint,
|
|
2134
|
+
status: upstreamResponse.status,
|
|
2135
|
+
durationMs: Date.now() - startedAt
|
|
2136
|
+
});
|
|
2137
|
+
const kind = this.classifyFailureStatus(upstreamResponse.status, errorBody);
|
|
2138
|
+
const decision = this.routeFailover.decide({
|
|
2139
|
+
sellerId: sellerKey,
|
|
2140
|
+
status: upstreamResponse.status,
|
|
2141
|
+
errorKind: kind,
|
|
2142
|
+
errorMessage: errorBody,
|
|
2143
|
+
attempt
|
|
2144
|
+
}, routes.length - routeIndex);
|
|
2145
|
+
this.handleFailoverDecision(decision, {
|
|
2146
|
+
requestId,
|
|
2147
|
+
sellerKey,
|
|
2148
|
+
model: modelId,
|
|
2149
|
+
endpoint,
|
|
2150
|
+
routeIndex,
|
|
2151
|
+
routesRemaining: routes.length - routeIndex,
|
|
2152
|
+
attempt,
|
|
2153
|
+
status: upstreamResponse.status
|
|
2154
|
+
});
|
|
2155
|
+
if (decision.action === "fail_fast" || decision.action === "abort") {
|
|
2156
|
+
this.copyUpstreamHeaders(upstreamResponse, res);
|
|
2157
|
+
res.status(upstreamResponse.status);
|
|
2158
|
+
res.send(errorBody);
|
|
2159
|
+
return;
|
|
2160
|
+
}
|
|
2161
|
+
if (decision.action === "retry_same_seller") {
|
|
2162
|
+
attempt += 1;
|
|
2163
|
+
if (decision.retryDelayMs) {
|
|
2164
|
+
await new Promise((resolve) => setTimeout(resolve, decision.retryDelayMs));
|
|
2165
|
+
}
|
|
2166
|
+
continue;
|
|
2167
|
+
}
|
|
2168
|
+
// failover_next
|
|
2169
|
+
lastError = new Error(`seller ${sellerKey} returned ${upstreamResponse.status}`);
|
|
2010
2170
|
break;
|
|
2011
2171
|
}
|
|
2012
|
-
bytes += value.byteLength;
|
|
2013
|
-
const chunk = decoder.decode(value, { stream: true });
|
|
2014
|
-
// 透明代理:把 seller 的 SSE 字节原样转给客户端,只剥离我们注入的
|
|
2015
|
-
// tokenbuddy.settlement 事件(不让客户端看到内部记账字段)。除此之外
|
|
2016
|
-
// 不做任何协议转换——卖方格式 bug(如 chat.completion.chunk prefix、
|
|
2017
|
-
// 缺 event: 行)由卖方修,buyer 不兜底。
|
|
2018
|
-
const sellerChunk = settlementExtractor.push(chunk);
|
|
2019
|
-
if (sellerChunk.length > 0) {
|
|
2020
|
-
markFirstByte();
|
|
2021
|
-
res.write(sellerChunk);
|
|
2022
|
-
}
|
|
2023
2172
|
}
|
|
2024
|
-
//
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2173
|
+
// Successful response: stream or buffer.
|
|
2174
|
+
this.copyUpstreamHeaders(upstreamResponse, res);
|
|
2175
|
+
res.status(upstreamResponse.status);
|
|
2176
|
+
logger.info("proxy.upstream_fetch.succeeded", "proxy upstream fetch succeeded", {
|
|
2177
|
+
requestId,
|
|
2178
|
+
sellerKey,
|
|
2179
|
+
model: modelId,
|
|
2180
|
+
endpoint,
|
|
2181
|
+
status: upstreamResponse.status,
|
|
2182
|
+
stream: Boolean(body.stream)
|
|
2183
|
+
});
|
|
2184
|
+
const contentType = upstreamResponse.headers.get("content-type") || "";
|
|
2185
|
+
if (contentType.includes("text/event-stream") || Boolean(body.stream)) {
|
|
2186
|
+
const reader = upstreamResponse.body?.getReader();
|
|
2187
|
+
if (!reader) {
|
|
2188
|
+
res.end();
|
|
2189
|
+
return;
|
|
2190
|
+
}
|
|
2191
|
+
let bytes = 0;
|
|
2192
|
+
const decoder = new TextDecoder();
|
|
2193
|
+
const settlementExtractor = new SellerSettlementStreamExtractor();
|
|
2194
|
+
while (true) {
|
|
2195
|
+
const { done, value } = await reader.read();
|
|
2196
|
+
lease.refresh();
|
|
2197
|
+
if (done) {
|
|
2198
|
+
break;
|
|
2199
|
+
}
|
|
2200
|
+
bytes += value.byteLength;
|
|
2201
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
2202
|
+
// 透明代理:把 seller 的 SSE 字节原样转给客户端,只剥离我们注入的
|
|
2203
|
+
// tokenbuddy.settlement 事件(不让客户端看到内部记账字段)。除此之外
|
|
2204
|
+
// 不做任何协议转换——卖方格式 bug(如 chat.completion.chunk prefix、
|
|
2205
|
+
// 缺 event: 行)由卖方修,buyer 不兜底。
|
|
2206
|
+
const sellerChunk = settlementExtractor.push(chunk);
|
|
2207
|
+
if (sellerChunk.length > 0) {
|
|
2208
|
+
markFirstByte();
|
|
2209
|
+
res.write(sellerChunk);
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
// flush TextDecoder 内部 buffer:stream:true 模式下最后可能留有几个字节的
|
|
2213
|
+
// 不完整 UTF-8 序列(多字节字符被切到下一 chunk 的场景),不调 stream:false
|
|
2214
|
+
// flush 就 break 会丢这批字节。上面的 stream 末尾事件(done / completed)
|
|
2215
|
+
// 之前被吞掉就是这个原因。
|
|
2216
|
+
const decoderTail = decoder.decode();
|
|
2217
|
+
if (decoderTail.length > 0) {
|
|
2218
|
+
const sellerTail = settlementExtractor.push(decoderTail);
|
|
2219
|
+
if (sellerTail.length > 0) {
|
|
2220
|
+
markFirstByte();
|
|
2221
|
+
res.write(sellerTail);
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
const settlementTrailing = settlementExtractor.finish();
|
|
2225
|
+
if (settlementTrailing.downstream.length > 0) {
|
|
2032
2226
|
markFirstByte();
|
|
2033
|
-
res.write(
|
|
2227
|
+
res.write(settlementTrailing.downstream);
|
|
2034
2228
|
}
|
|
2229
|
+
res.end();
|
|
2230
|
+
this.recordReconciledInference(route, endpoint, requestId, { promptTokens: 0, completionTokens: 0, billedMicros: Math.max(1, bytes) }, this.parseSellerSettlementSummary(upstreamResponse.headers) ?? settlementTrailing.settlement ?? settlementExtractor.current(), this.inferPromptForHash(body), undefined, {
|
|
2231
|
+
ttftMs: firstByteAt ? firstByteAt - startedAt : undefined,
|
|
2232
|
+
fallbackCount: routeIndex,
|
|
2233
|
+
routeReason: plan.reason,
|
|
2234
|
+
falloverChain: routes.slice(0, routeIndex + 1).map((r) => r.seller.id),
|
|
2235
|
+
upstreamStatus: upstreamStatusFromHeaders(upstreamResponse.headers),
|
|
2236
|
+
durationMs: Date.now() - startedAt,
|
|
2237
|
+
paymentMethod
|
|
2238
|
+
});
|
|
2239
|
+
return;
|
|
2035
2240
|
}
|
|
2036
|
-
const
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
this.recordReconciledInference(route, endpoint, requestId, { promptTokens: 0, completionTokens: 0, billedMicros: Math.max(1, bytes) }, this.parseSellerSettlementSummary(upstreamResponse.headers) ?? settlementTrailing.settlement ?? settlementExtractor.current(), this.inferPromptForHash(body), undefined, {
|
|
2241
|
+
const responseBody = await upstreamResponse.text();
|
|
2242
|
+
lease.refresh();
|
|
2243
|
+
markFirstByte();
|
|
2244
|
+
res.send(responseBody);
|
|
2245
|
+
const usage = this.readUsage(responseBody);
|
|
2246
|
+
this.recordReconciledInference(route, endpoint, requestId, usage, this.parseSellerSettlementSummary(upstreamResponse.headers), this.inferPromptForHash(body), responseBody, {
|
|
2043
2247
|
ttftMs: firstByteAt ? firstByteAt - startedAt : undefined,
|
|
2044
2248
|
fallbackCount: routeIndex,
|
|
2045
2249
|
routeReason: plan.reason,
|
|
@@ -2050,62 +2254,51 @@ export class TokenbuddyDaemon {
|
|
|
2050
2254
|
});
|
|
2051
2255
|
return;
|
|
2052
2256
|
}
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
});
|
|
2087
|
-
logger.warn("proxy.route.failed", "seller route failed before response", {
|
|
2088
|
-
requestId,
|
|
2089
|
-
sellerKey,
|
|
2090
|
-
model: modelId,
|
|
2091
|
-
endpoint,
|
|
2092
|
-
errorMessage: this.failoverErrorMessage(routeError),
|
|
2093
|
-
durationMs: Date.now() - startedAt
|
|
2094
|
-
});
|
|
2095
|
-
if (decision.action === "retry_same_seller") {
|
|
2096
|
-
attempt += 1;
|
|
2097
|
-
if (decision.retryDelayMs) {
|
|
2098
|
-
await new Promise((resolve) => setTimeout(resolve, decision.retryDelayMs));
|
|
2257
|
+
catch (routeError) {
|
|
2258
|
+
lastError = routeError;
|
|
2259
|
+
const kind = "deadline";
|
|
2260
|
+
const decision = this.routeFailover.decide({
|
|
2261
|
+
sellerId: sellerKey,
|
|
2262
|
+
errorKind: kind,
|
|
2263
|
+
errorMessage: this.failoverErrorMessage(routeError),
|
|
2264
|
+
attempt
|
|
2265
|
+
}, routes.length - routeIndex);
|
|
2266
|
+
this.handleFailoverDecision(decision, {
|
|
2267
|
+
requestId,
|
|
2268
|
+
sellerKey,
|
|
2269
|
+
model: modelId,
|
|
2270
|
+
endpoint,
|
|
2271
|
+
routeIndex,
|
|
2272
|
+
routesRemaining: routes.length - routeIndex,
|
|
2273
|
+
attempt,
|
|
2274
|
+
reason: "exception"
|
|
2275
|
+
});
|
|
2276
|
+
logger.warn("proxy.route.failed", "seller route failed before response", {
|
|
2277
|
+
requestId,
|
|
2278
|
+
sellerKey,
|
|
2279
|
+
model: modelId,
|
|
2280
|
+
endpoint,
|
|
2281
|
+
errorMessage: this.failoverErrorMessage(routeError),
|
|
2282
|
+
durationMs: Date.now() - startedAt
|
|
2283
|
+
});
|
|
2284
|
+
if (decision.action === "retry_same_seller") {
|
|
2285
|
+
attempt += 1;
|
|
2286
|
+
if (decision.retryDelayMs) {
|
|
2287
|
+
await new Promise((resolve) => setTimeout(resolve, decision.retryDelayMs));
|
|
2288
|
+
}
|
|
2289
|
+
continue;
|
|
2099
2290
|
}
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2291
|
+
if (decision.action === "fail_fast" || decision.action === "abort") {
|
|
2292
|
+
throw routeError;
|
|
2293
|
+
}
|
|
2294
|
+
// failover_next
|
|
2295
|
+
break;
|
|
2104
2296
|
}
|
|
2105
|
-
// failover_next
|
|
2106
|
-
break;
|
|
2107
2297
|
}
|
|
2108
2298
|
}
|
|
2299
|
+
finally {
|
|
2300
|
+
lease.release();
|
|
2301
|
+
}
|
|
2109
2302
|
}
|
|
2110
2303
|
throw lastError instanceof Error ? lastError : new Error("all seller routes failed");
|
|
2111
2304
|
}
|
|
@@ -2225,6 +2418,52 @@ export class TokenbuddyDaemon {
|
|
|
2225
2418
|
});
|
|
2226
2419
|
}
|
|
2227
2420
|
});
|
|
2421
|
+
controlApp.get("/update/status", async (_req, res) => {
|
|
2422
|
+
try {
|
|
2423
|
+
const update = await checkPackageUpdate();
|
|
2424
|
+
logger.info("control.update.status.requested", "package update status requested", {
|
|
2425
|
+
currentVersion: update.currentVersion,
|
|
2426
|
+
latestVersion: update.latestVersion,
|
|
2427
|
+
updateAvailable: update.updateAvailable
|
|
2428
|
+
});
|
|
2429
|
+
res.status(200).json(update);
|
|
2430
|
+
}
|
|
2431
|
+
catch (error) {
|
|
2432
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2433
|
+
logger.warn("control.update.status.failed", "package update status failed", { errorMessage });
|
|
2434
|
+
res.status(502).json({
|
|
2435
|
+
error: {
|
|
2436
|
+
code: "update_status_failed",
|
|
2437
|
+
message: errorMessage
|
|
2438
|
+
}
|
|
2439
|
+
});
|
|
2440
|
+
}
|
|
2441
|
+
});
|
|
2442
|
+
controlApp.post("/update/run", async (_req, res) => {
|
|
2443
|
+
try {
|
|
2444
|
+
const result = await runPackageUpdate({ apply: true, controlPort: this.activeControlPort() }, {
|
|
2445
|
+
restartProxyd: async () => scheduleLaunchAgentRestart()
|
|
2446
|
+
});
|
|
2447
|
+
logger.info("control.update.run.completed", "package update run completed", {
|
|
2448
|
+
currentVersion: result.check.currentVersion,
|
|
2449
|
+
latestVersion: result.check.latestVersion,
|
|
2450
|
+
updateAvailable: result.check.updateAvailable,
|
|
2451
|
+
installSucceeded: result.install.succeeded,
|
|
2452
|
+
restartScheduled: result.restart.scheduled === true
|
|
2453
|
+
});
|
|
2454
|
+
res.status(result.install.error || result.restart.error ? 500 : 200).json(result);
|
|
2455
|
+
}
|
|
2456
|
+
catch (error) {
|
|
2457
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2458
|
+
logger.warn("control.update.run.failed", "package update run failed", { errorMessage });
|
|
2459
|
+
res.status(500).json({
|
|
2460
|
+
error: {
|
|
2461
|
+
code: "update_run_failed",
|
|
2462
|
+
message: errorMessage
|
|
2463
|
+
}
|
|
2464
|
+
});
|
|
2465
|
+
}
|
|
2466
|
+
});
|
|
2228
2467
|
controlApp.post("/payments/clawtip/activate", async (req, res) => {
|
|
2229
2468
|
try {
|
|
2230
2469
|
const qr = await this.startClawtipActivationQr();
|
|
@@ -2645,7 +2884,7 @@ export class TokenbuddyDaemon {
|
|
|
2645
2884
|
// SPA fallback: React Router 用 BrowserRouter(history mode),客户端路径如
|
|
2646
2885
|
// /overview /routing /ledger 不对应任何文件,必须回 index.html 让 React Router 接管。
|
|
2647
2886
|
// Express 已经按注册顺序匹配了所有 17+ 个 API 路由,这里只接住"什么都没匹配"的 GET。
|
|
2648
|
-
controlApp.get(/^(?!\/(?:health|status|payments|sellers|models|ledger|routing|prewarm|v1\.2|daemon|providers|static)\/).*/, (_req, res) => {
|
|
2887
|
+
controlApp.get(/^(?!\/(?:health|status|payments|sellers|models|ledger|routing|prewarm|v1\.2|daemon|providers|update|static)\/).*/, (_req, res) => {
|
|
2649
2888
|
res.sendFile(path.join(uiDir, "index.html"));
|
|
2650
2889
|
});
|
|
2651
2890
|
logger.info("ui.static.attached", "tb-ui dist attached to control plane SPA fallback", { uiDir });
|
|
@@ -2984,6 +3223,37 @@ function catalogSnapshotFromRegistry(registry) {
|
|
|
2984
3223
|
}))
|
|
2985
3224
|
};
|
|
2986
3225
|
}
|
|
3226
|
+
function normalizeTrustedRegistryCache(value) {
|
|
3227
|
+
if (!value || typeof value !== "object") {
|
|
3228
|
+
return undefined;
|
|
3229
|
+
}
|
|
3230
|
+
const data = value;
|
|
3231
|
+
if (data.schemaVersion !== 1 ||
|
|
3232
|
+
typeof data.registryUrl !== "string" ||
|
|
3233
|
+
typeof data.registrySha256 !== "string" ||
|
|
3234
|
+
typeof data.cachedAt !== "string" ||
|
|
3235
|
+
typeof data.registryJson !== "string" ||
|
|
3236
|
+
!data.registry ||
|
|
3237
|
+
typeof data.registry !== "object" ||
|
|
3238
|
+
!Array.isArray(data.registry.sellers) ||
|
|
3239
|
+
!data.trust ||
|
|
3240
|
+
typeof data.trust !== "object" ||
|
|
3241
|
+
typeof data.trust.registryUrl !== "string" ||
|
|
3242
|
+
typeof data.trust.registrySha256 !== "string" ||
|
|
3243
|
+
typeof data.trust.verified !== "boolean") {
|
|
3244
|
+
return undefined;
|
|
3245
|
+
}
|
|
3246
|
+
return {
|
|
3247
|
+
schemaVersion: 1,
|
|
3248
|
+
registryUrl: data.registryUrl,
|
|
3249
|
+
version: typeof data.version === "number" ? data.version : data.registry.version,
|
|
3250
|
+
registrySha256: data.registrySha256,
|
|
3251
|
+
cachedAt: data.cachedAt,
|
|
3252
|
+
registryJson: data.registryJson,
|
|
3253
|
+
registry: data.registry,
|
|
3254
|
+
trust: data.trust
|
|
3255
|
+
};
|
|
3256
|
+
}
|
|
2987
3257
|
/**
|
|
2988
3258
|
* 从 query string 构造 `BuyerSellerRoutingConfig` override(用于 GET /routing/preview)。
|
|
2989
3259
|
* 接受 `mode` / `scorer` / `sellerId` / `sellerIds`(逗号分隔)。
|