@tokenbuddy/tokenbuddy 1.0.25 → 1.0.27

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.
Files changed (84) hide show
  1. package/bin/tb-clawtip-proof.js +2 -0
  2. package/dist/src/clawtip-bootstrap.d.ts +1 -0
  3. package/dist/src/clawtip-bootstrap.d.ts.map +1 -1
  4. package/dist/src/clawtip-bootstrap.js +1 -0
  5. package/dist/src/clawtip-bootstrap.js.map +1 -1
  6. package/dist/src/cli.d.ts +1 -0
  7. package/dist/src/cli.d.ts.map +1 -1
  8. package/dist/src/cli.js +172 -51
  9. package/dist/src/cli.js.map +1 -1
  10. package/dist/src/daemon.d.ts +6 -0
  11. package/dist/src/daemon.d.ts.map +1 -1
  12. package/dist/src/daemon.js +562 -292
  13. package/dist/src/daemon.js.map +1 -1
  14. package/dist/src/init-clawtip-activation.d.ts +12 -0
  15. package/dist/src/init-clawtip-activation.d.ts.map +1 -1
  16. package/dist/src/init-clawtip-activation.js +82 -2
  17. package/dist/src/init-clawtip-activation.js.map +1 -1
  18. package/dist/src/package-update.d.ts +60 -0
  19. package/dist/src/package-update.d.ts.map +1 -0
  20. package/dist/src/package-update.js +220 -0
  21. package/dist/src/package-update.js.map +1 -0
  22. package/dist/src/registry-trust.d.ts +7 -0
  23. package/dist/src/registry-trust.d.ts.map +1 -0
  24. package/dist/src/registry-trust.js +37 -0
  25. package/dist/src/registry-trust.js.map +1 -0
  26. package/dist/src/route-failover.d.ts +2 -2
  27. package/dist/src/route-failover.d.ts.map +1 -1
  28. package/dist/src/route-failover.js +11 -0
  29. package/dist/src/route-failover.js.map +1 -1
  30. package/dist/src/seller-catalog.d.ts +20 -0
  31. package/dist/src/seller-catalog.d.ts.map +1 -1
  32. package/dist/src/seller-catalog.js +41 -4
  33. package/dist/src/seller-catalog.js.map +1 -1
  34. package/dist/src/seller-concurrency-limiter.d.ts +36 -0
  35. package/dist/src/seller-concurrency-limiter.d.ts.map +1 -0
  36. package/dist/src/seller-concurrency-limiter.js +126 -0
  37. package/dist/src/seller-concurrency-limiter.js.map +1 -0
  38. package/dist/src/seller-pool.d.ts +7 -1
  39. package/dist/src/seller-pool.d.ts.map +1 -1
  40. package/dist/src/seller-pool.js +18 -0
  41. package/dist/src/seller-pool.js.map +1 -1
  42. package/dist/src/seller-route-planner.d.ts +21 -0
  43. package/dist/src/seller-route-planner.d.ts.map +1 -1
  44. package/dist/src/seller-route-planner.js +98 -20
  45. package/dist/src/seller-route-planner.js.map +1 -1
  46. package/dist/src/tb-clawtip-proof.d.ts +3 -0
  47. package/dist/src/tb-clawtip-proof.d.ts.map +1 -0
  48. package/dist/src/tb-clawtip-proof.js +24 -0
  49. package/dist/src/tb-clawtip-proof.js.map +1 -0
  50. package/dist/src/tb-proxyd.js +45 -3
  51. package/dist/src/tb-proxyd.js.map +1 -1
  52. package/package.json +3 -2
  53. package/src/clawtip-bootstrap.ts +1 -0
  54. package/src/cli.ts +200 -47
  55. package/src/daemon.ts +347 -50
  56. package/src/init-clawtip-activation.ts +77 -1
  57. package/src/package-update.ts +313 -0
  58. package/src/registry-trust.ts +51 -0
  59. package/src/route-failover.ts +14 -2
  60. package/src/seller-catalog.ts +67 -4
  61. package/src/seller-concurrency-limiter.ts +161 -0
  62. package/src/seller-pool.ts +20 -0
  63. package/src/seller-route-planner.ts +142 -20
  64. package/src/tb-clawtip-proof.ts +28 -0
  65. package/src/tb-proxyd.ts +48 -3
  66. package/static/ui/assets/index-Bzbrp7Qe.css +1 -0
  67. package/static/ui/assets/index-UAfOhbwC.js +236 -0
  68. package/static/ui/assets/index-UAfOhbwC.js.map +1 -0
  69. package/static/ui/index.html +2 -2
  70. package/tests/cli-routing.test.ts +37 -4
  71. package/tests/control-plane-ui-endpoints.test.ts +7 -7
  72. package/tests/daemon-trusted-registry-cache.test.ts +132 -0
  73. package/tests/e2e.test.ts +14 -1
  74. package/tests/package-update.test.ts +132 -0
  75. package/tests/registry-trust.test.ts +28 -0
  76. package/tests/route-failover.test.ts +13 -0
  77. package/tests/seller-catalog-413.test.ts +60 -1
  78. package/tests/seller-concurrency-limiter.test.ts +83 -0
  79. package/tests/seller-pool.test.ts +23 -0
  80. package/tests/seller-route-planner.test.ts +78 -0
  81. package/tests/tokenbuddy.test.ts +316 -34
  82. package/static/ui/assets/index-1uuyCCzj.css +0 -1
  83. package/static/ui/assets/index-BJSOFJIU.js +0 -236
  84. package/static/ui/assets/index-BJSOFJIU.js.map +0 -1
@@ -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, fetchSellerRegistry, isBuyerVisibleRegistrySeller, normalizeSellerUrl, RegistryTooLargeError, } from "./seller-catalog.js";
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 || "https://tb-wallet-bootstrap.fly.dev";
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 registry = await fetchSellerRegistry(this.config.sellerRegistryUrl);
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
- sellerCount: routes.length,
977
- sellers: routes.map((route) => route.seller.id)
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 to backup candidate", {
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
- const catalog = await discoverSellerBackedModels(this.config.sellerRegistryUrl);
1202
- this.lastRegistrySnapshot = sellerCatalogResultToRegistrySnapshot(catalog);
1203
- return {
1204
- models: catalog.models,
1205
- sellers: catalog.sellers
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
- logger.info("route.selected", "seller route selected", {
1792
- sellerKey,
1793
- sellerId: sellerKey,
1794
- model: modelId,
1795
- endpoint,
1796
- protocol: route.protocol,
1797
- paymentMethod: route.paymentMethod,
1798
- routeIndex,
1799
- backup: routeIndex > 0
1800
- });
1801
- let attempt = 0;
1802
- // Soft-failure retry budget; the route-failover controller decides
1803
- // whether the same seller should be retried or we move on. The
1804
- // v1 "1 retry for 4xx fallback" loop is replaced with a
1805
- // stateful decision per attempt.
1806
- // eslint-disable-next-line no-constant-condition
1807
- while (true) {
1808
- try {
1809
- logger.info("proxy.request.started", "proxy request started", {
1810
- requestId,
1811
- sellerKey,
1812
- model: modelId,
1813
- requestedModel: requestedModelId,
1814
- endpoint,
1815
- stream: Boolean(body.stream),
1816
- attempt
1817
- });
1818
- const sellerUrl = normalizeSellerUrl(route.seller);
1819
- const upstreamBody = this.applyResolvedModelToBody(endpoint, {
1820
- ...body,
1821
- requestId
1822
- }, modelId);
1823
- logger.info("proxy.upstream_fetch.started", "proxy upstream fetch started", {
1824
- requestId,
1825
- sellerKey,
1826
- model: modelId,
1827
- endpoint,
1828
- stream: Boolean(body.stream),
1829
- bodySummary: summarizeProxyBody(upstreamBody)
1830
- });
1831
- // v1.1 §17.5: refuse to auto-purchase once the session budget is
1832
- // exhausted. The seller is treated as "no auto-purchase available"
1833
- // and the request fails over to the next candidate.
1834
- if (!this.routeFailover.canAutoPurchase()) {
1835
- logger.warn("purchase.budget.exceeded", "session auto-purchase budget exhausted; failing over without buying", {
1836
- requestId,
1837
- sellerKey,
1838
- model: modelId,
1839
- endpoint,
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
- token = await this.getOrPurchaseToken(route, requestId);
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
- errorMessage: this.failoverErrorMessage(purchaseError)
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
- }, routes.length - routeIndex);
1866
- logger.warn("route.failover.triggered", "seller route failed over after purchase failure", {
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
- routeIndex,
1872
- nextRouteIndex: routeIndex + 1,
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
- lastError = purchaseError;
1880
- break;
1881
- }
1882
- // v1.2 §8: enforce a hard per-request deadline so a slow
1883
- // upstream cannot hang the buyer. The deadline is honored by
1884
- // the AbortController passed to `fetch`; sellers that observe
1885
- // the `X-TokenBuddy-Deadline-Ms` header (PR-6) can propagate
1886
- // it to their own upstream fetch via the same signal.
1887
- const deadlineMs = this.requestDeadlineMs();
1888
- const sendSellerRequest = async (token) => {
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
- finally {
1907
- clearTimeout(requestTimer);
1908
- }
1909
- };
1910
- let upstreamResponse = await sendSellerRequest(token);
1911
- if (!upstreamResponse.ok) {
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
- else {
1942
- logger.warn("proxy.upstream_fetch.failed", "proxy upstream fetch returned non-ok status", {
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
- status: upstreamResponse.status,
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
- status: upstreamResponse.status,
1954
- errorKind: kind,
1955
- errorMessage: errorBody,
2043
+ errorKind: "purchase_failed",
2044
+ errorMessage: this.failoverErrorMessage(purchaseError),
1956
2045
  attempt
1957
2046
  }, routes.length - routeIndex);
1958
- this.handleFailoverDecision(decision, {
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
- status: upstreamResponse.status
2058
+ attemptNumber: attempt + 1,
2059
+ reason: "purchase_failed",
2060
+ controllerReason: decision.reason,
2061
+ controllerAction: decision.action
1967
2062
  });
1968
- if (decision.action === "fail_fast" || decision.action === "abort") {
1969
- this.copyUpstreamHeaders(upstreamResponse, res);
1970
- res.status(upstreamResponse.status);
1971
- res.send(errorBody);
1972
- return;
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
- if (decision.action === "retry_same_seller") {
1975
- attempt += 1;
1976
- if (decision.retryDelayMs) {
1977
- await new Promise((resolve) => setTimeout(resolve, decision.retryDelayMs));
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
- // failover_next
1982
- lastError = new Error(`seller ${sellerKey} returned ${upstreamResponse.status}`);
1983
- break;
1984
- }
1985
- }
1986
- // Successful response: stream or buffer.
1987
- this.copyUpstreamHeaders(upstreamResponse, res);
1988
- res.status(upstreamResponse.status);
1989
- logger.info("proxy.upstream_fetch.succeeded", "proxy upstream fetch succeeded", {
1990
- requestId,
1991
- sellerKey,
1992
- model: modelId,
1993
- endpoint,
1994
- status: upstreamResponse.status,
1995
- stream: Boolean(body.stream)
1996
- });
1997
- const contentType = upstreamResponse.headers.get("content-type") || "";
1998
- if (contentType.includes("text/event-stream") || Boolean(body.stream)) {
1999
- const reader = upstreamResponse.body?.getReader();
2000
- if (!reader) {
2001
- res.end();
2002
- return;
2003
- }
2004
- let bytes = 0;
2005
- const decoder = new TextDecoder();
2006
- const settlementExtractor = new SellerSettlementStreamExtractor();
2007
- while (true) {
2008
- const { done, value } = await reader.read();
2009
- if (done) {
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
- // flush TextDecoder 内部 buffer:stream:true 模式下最后可能留有几个字节的
2025
- // 不完整 UTF-8 序列(多字节字符被切到下一 chunk 的场景),不调 stream:false
2026
- // flush 就 break 会丢这批字节。上面的 stream 末尾事件(done / completed)
2027
- // 之前被吞掉就是这个原因。
2028
- const decoderTail = decoder.decode();
2029
- if (decoderTail.length > 0) {
2030
- const sellerTail = settlementExtractor.push(decoderTail);
2031
- if (sellerTail.length > 0) {
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(sellerTail);
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 settlementTrailing = settlementExtractor.finish();
2037
- if (settlementTrailing.downstream.length > 0) {
2038
- markFirstByte();
2039
- res.write(settlementTrailing.downstream);
2040
- }
2041
- res.end();
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
- const responseBody = await upstreamResponse.text();
2054
- markFirstByte();
2055
- res.send(responseBody);
2056
- const usage = this.readUsage(responseBody);
2057
- this.recordReconciledInference(route, endpoint, requestId, usage, this.parseSellerSettlementSummary(upstreamResponse.headers), this.inferPromptForHash(body), responseBody, {
2058
- ttftMs: firstByteAt ? firstByteAt - startedAt : undefined,
2059
- fallbackCount: routeIndex,
2060
- routeReason: plan.reason,
2061
- falloverChain: routes.slice(0, routeIndex + 1).map((r) => r.seller.id),
2062
- upstreamStatus: upstreamStatusFromHeaders(upstreamResponse.headers),
2063
- durationMs: Date.now() - startedAt,
2064
- paymentMethod
2065
- });
2066
- return;
2067
- }
2068
- catch (routeError) {
2069
- lastError = routeError;
2070
- const kind = "deadline";
2071
- const decision = this.routeFailover.decide({
2072
- sellerId: sellerKey,
2073
- errorKind: kind,
2074
- errorMessage: this.failoverErrorMessage(routeError),
2075
- attempt
2076
- }, routes.length - routeIndex);
2077
- this.handleFailoverDecision(decision, {
2078
- requestId,
2079
- sellerKey,
2080
- model: modelId,
2081
- endpoint,
2082
- routeIndex,
2083
- routesRemaining: routes.length - routeIndex,
2084
- attempt,
2085
- reason: "exception"
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
- continue;
2101
- }
2102
- if (decision.action === "fail_fast" || decision.action === "abort") {
2103
- throw routeError;
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`(逗号分隔)。