@tokenbuddy/tokenbuddy 1.0.31 → 1.0.33

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 (57) hide show
  1. package/dist/src/buyer-store.d.ts +26 -0
  2. package/dist/src/buyer-store.d.ts.map +1 -1
  3. package/dist/src/buyer-store.js +61 -8
  4. package/dist/src/buyer-store.js.map +1 -1
  5. package/dist/src/daemon.d.ts +24 -0
  6. package/dist/src/daemon.d.ts.map +1 -1
  7. package/dist/src/daemon.js +1259 -9
  8. package/dist/src/daemon.js.map +1 -1
  9. package/dist/src/doctor-diagnostics.d.ts +12 -0
  10. package/dist/src/doctor-diagnostics.d.ts.map +1 -1
  11. package/dist/src/doctor-diagnostics.js +71 -2
  12. package/dist/src/doctor-diagnostics.js.map +1 -1
  13. package/dist/src/prewarm-cache.js +4 -1
  14. package/dist/src/prewarm-cache.js.map +1 -1
  15. package/dist/src/provider-routing-config.d.ts +91 -0
  16. package/dist/src/provider-routing-config.d.ts.map +1 -0
  17. package/dist/src/provider-routing-config.js +292 -0
  18. package/dist/src/provider-routing-config.js.map +1 -0
  19. package/package.json +1 -1
  20. package/src/buyer-store.ts +113 -8
  21. package/src/daemon.ts +1389 -16
  22. package/src/doctor-diagnostics.ts +100 -1
  23. package/src/prewarm-cache.ts +5 -1
  24. package/src/provider-routing-config.ts +410 -0
  25. package/static/ui/assets/index-0MVXD7bH.css +1 -0
  26. package/static/ui/assets/index-Mt3BZFuP.js +266 -0
  27. package/static/ui/assets/index-Mt3BZFuP.js.map +1 -0
  28. package/static/ui/icons/apple-touch-icon.png +0 -0
  29. package/static/ui/icons/tokenbuddy-192.png +0 -0
  30. package/static/ui/icons/tokenbuddy-512.png +0 -0
  31. package/static/ui/icons/tokenbuddy.svg +6 -0
  32. package/static/ui/index.html +3 -2
  33. package/static/ui/tool-logos/cc-switch.png +0 -0
  34. package/static/ui/tool-logos/claude.svg +1 -0
  35. package/static/ui/tool-logos/cline.svg +1 -0
  36. package/static/ui/tool-logos/codex.svg +1 -0
  37. package/static/ui/tool-logos/cursor.svg +1 -0
  38. package/static/ui/tool-logos/dirac.ico +18 -0
  39. package/static/ui/tool-logos/factory.svg +4 -0
  40. package/static/ui/tool-logos/fast-agent.svg +6 -0
  41. package/static/ui/tool-logos/glm.svg +1 -0
  42. package/static/ui/tool-logos/goose.svg +1 -0
  43. package/static/ui/tool-logos/hermes.svg +1 -0
  44. package/static/ui/tool-logos/kilocode.svg +1 -0
  45. package/static/ui/tool-logos/opencode.svg +1 -0
  46. package/static/ui/tool-logos/pi.svg +28 -0
  47. package/static/ui/tool-logos/qwen-code.png +0 -0
  48. package/tests/cli-routing.test.ts +43 -0
  49. package/tests/control-plane-ui-endpoints.test.ts +776 -0
  50. package/tests/daemon-classify.test.ts +5 -1
  51. package/tests/e2e.test.ts +5 -0
  52. package/tests/prewarm-cache.test.ts +15 -0
  53. package/tests/provider-routing-config.test.ts +150 -0
  54. package/tests/tokenbuddy.test.ts +27 -0
  55. package/static/ui/assets/index-Bzbrp7Qe.css +0 -1
  56. package/static/ui/assets/index-DEDEl8o2.js +0 -236
  57. package/static/ui/assets/index-DEDEl8o2.js.map +0 -1
@@ -22,6 +22,7 @@ import { SellerConcurrencyLimiter } from "./seller-concurrency-limiter.js";
22
22
  import { SellerMetadataCache } from "./seller-metadata-cache.js";
23
23
  import { planSellerRouteSet } from "./seller-route-planner.js";
24
24
  import { assertSellerRoutingConfig, mergeSellerRoutingConfig, normalizeSellerRoutingConfig, parseSellerIdList, ROUTING_CONFIG_KEY } from "./seller-routing-config.js";
25
+ import { AUTO_PROVIDER_CONFIG_KEY, MANUAL_PROVIDER_CONFIG_KEY, MANUAL_PROVIDER_OBSERVATIONS_CONFIG_KEY, PROVIDER_MODE_CONFIG_KEY, defaultProviderModeConfig, normalizeAutoProviderConfig, normalizeManualProviderObservationsConfig, normalizeManualProviderConfig, normalizeManualProvidersConfig, normalizeProviderModeConfig, publicManualProviderConfig } from "./provider-routing-config.js";
25
26
  import { assertInitSetupSteps, buildCompletedInitSetupMarker, INIT_SETUP_CONFIG_KEY, INIT_SETUP_STEPS, isFreshInitMachine, normalizeInitSetupMarker, resolveInitRecommendedModels, } from "./init-setup.js";
26
27
  import { checkPackageUpdate, runPackageUpdate, scheduleLaunchAgentRestart, } from "./package-update.js";
27
28
  const logger = createModuleLogger("tb-proxyd");
@@ -31,6 +32,7 @@ const PROXY_JSON_BODY_LIMIT = "10mb";
31
32
  const SELLER_CAPACITY_BLOCK_MS = 2_000;
32
33
  const CLAWTIP_STATIC_ROUTE = "/static/clawtip";
33
34
  const CLAWTIP_RECHARGE_QR_FILE = "recharge.png";
35
+ const MANUAL_PROVIDER_SECRET_CONFIG_KEY = "manual-provider-secrets";
34
36
  function currentModuleDir() {
35
37
  if (typeof __dirname !== "undefined") {
36
38
  return __dirname;
@@ -113,6 +115,73 @@ function numericHeaderField(value) {
113
115
  }
114
116
  return undefined;
115
117
  }
118
+ function nonNegativeIntegerField(value) {
119
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
120
+ }
121
+ function nonNegativeFiniteField(value) {
122
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
123
+ }
124
+ function usageRecord(value) {
125
+ return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
126
+ }
127
+ function safeBillingServiceTier(value) {
128
+ if (typeof value !== "string")
129
+ return undefined;
130
+ const trimmed = value.trim();
131
+ if (trimmed.length === 0 || trimmed.length > 64)
132
+ return undefined;
133
+ return /^[A-Za-z0-9 _.-]+$/.test(trimmed) ? trimmed : undefined;
134
+ }
135
+ function billingBreakdownSummary(value) {
136
+ const data = usageRecord(value);
137
+ if (!data)
138
+ return undefined;
139
+ const inputPriceMicrosPer1m = nonNegativeIntegerField(data.inputPriceMicrosPer1m ?? data.input_price_micros_per_1m);
140
+ const outputPriceMicrosPer1m = nonNegativeIntegerField(data.outputPriceMicrosPer1m ?? data.output_price_micros_per_1m);
141
+ const cacheReadPriceMicrosPer1m = nonNegativeIntegerField(data.cacheReadPriceMicrosPer1m ?? data.cache_read_price_micros_per_1m);
142
+ const inputCostMicros = nonNegativeIntegerField(data.inputCostMicros ?? data.input_cost_micros);
143
+ const outputCostMicros = nonNegativeIntegerField(data.outputCostMicros ?? data.output_cost_micros);
144
+ const cacheReadCostMicros = nonNegativeIntegerField(data.cacheReadCostMicros ?? data.cache_read_cost_micros);
145
+ const originalUsdMicros = nonNegativeIntegerField(data.originalUsdMicros ?? data.original_usd_micros);
146
+ const billingMultiplier = nonNegativeFiniteField(data.billingMultiplier ?? data.billing_multiplier);
147
+ if (inputPriceMicrosPer1m === undefined ||
148
+ outputPriceMicrosPer1m === undefined ||
149
+ cacheReadPriceMicrosPer1m === undefined ||
150
+ inputCostMicros === undefined ||
151
+ outputCostMicros === undefined ||
152
+ cacheReadCostMicros === undefined ||
153
+ originalUsdMicros === undefined ||
154
+ billingMultiplier === undefined) {
155
+ return undefined;
156
+ }
157
+ return {
158
+ inputPriceMicrosPer1m,
159
+ outputPriceMicrosPer1m,
160
+ cacheReadPriceMicrosPer1m,
161
+ inputCostMicros,
162
+ outputCostMicros,
163
+ cacheReadCostMicros,
164
+ originalUsdMicros,
165
+ billingMultiplier,
166
+ serviceTier: safeBillingServiceTier(data.serviceTier ?? data.service_tier)
167
+ };
168
+ }
169
+ function purchasePaymentSummaryFromQuote(value) {
170
+ const quote = usageRecord(value);
171
+ if (!quote)
172
+ return {};
173
+ const paymentAmount = typeof quote.paymentAmount === "string" && quote.paymentAmount.trim().length > 0
174
+ ? quote.paymentAmount.trim()
175
+ : undefined;
176
+ const paymentCurrency = typeof quote.paymentCurrency === "string" && quote.paymentCurrency.trim().length > 0
177
+ ? quote.paymentCurrency.trim().toUpperCase()
178
+ : undefined;
179
+ return {
180
+ paymentAmount,
181
+ paymentAmountMinor: nonNegativeIntegerField(quote.paymentAmountMinor),
182
+ paymentCurrency
183
+ };
184
+ }
116
185
  class SellerSettlementStreamExtractor {
117
186
  pending = "";
118
187
  settlement;
@@ -180,7 +249,8 @@ function parseSellerSettlementObject(raw) {
180
249
  ? parsed.priceVersion
181
250
  : typeof parsed.price_version === "string"
182
251
  ? parsed.price_version
183
- : undefined
252
+ : undefined,
253
+ billingBreakdown: billingBreakdownSummary(parsed.billingBreakdown ?? parsed.billing_breakdown)
184
254
  };
185
255
  }
186
256
  catch {
@@ -230,6 +300,10 @@ function finiteNumber(value) {
230
300
  }
231
301
  return undefined;
232
302
  }
303
+ function finitePositiveNumber(value) {
304
+ const number = finiteNumber(value);
305
+ return number !== undefined && number > 0 ? number : undefined;
306
+ }
233
307
  function readErrorCode(bodyText) {
234
308
  try {
235
309
  const parsed = JSON.parse(bodyText);
@@ -380,7 +454,7 @@ export class TokenbuddyDaemon {
380
454
  httpStatus: res.status,
381
455
  ttftMs: finiteNumber(latency?.ttftMs ?? latency?.ttft_ms),
382
456
  avgInferenceMs: finiteNumber(latency?.avgInferenceMs ?? latency?.avg_inference_ms),
383
- avgTokensPerSecond: finiteNumber(latency?.avgTokensPerSecond ?? latency?.avg_tokens_per_second),
457
+ avgTokensPerSecond: finitePositiveNumber(latency?.avgTokensPerSecond ?? latency?.avg_tokens_per_second),
384
458
  upstreamStatus: typeof upstream?.status === "string"
385
459
  ? upstream.status
386
460
  : undefined,
@@ -747,6 +821,167 @@ export class TokenbuddyDaemon {
747
821
  livePayments() {
748
822
  return this.tokenStore.listPayments().map((payment) => withLiveClawtipWalletState(payment, this.config.clawtipHomeDir));
749
823
  }
824
+ providerPaymentState() {
825
+ const payment = this.livePayments().find((entry) => entry.enabled && entry.method !== "mock");
826
+ return {
827
+ paymentReady: Boolean(payment),
828
+ paymentLabel: payment ? paymentLabel(payment.method) : undefined
829
+ };
830
+ }
831
+ currentProviderMode() {
832
+ return normalizeProviderModeConfig(this.tokenStore.getDaemonRuntimeConfig(PROVIDER_MODE_CONFIG_KEY)?.config);
833
+ }
834
+ saveProviderMode(mode) {
835
+ const config = {
836
+ ...defaultProviderModeConfig(),
837
+ mode
838
+ };
839
+ this.tokenStore.saveDaemonRuntimeConfig(PROVIDER_MODE_CONFIG_KEY, config);
840
+ return config;
841
+ }
842
+ currentManualProviders() {
843
+ return normalizeManualProvidersConfig(this.tokenStore.getDaemonRuntimeConfig(MANUAL_PROVIDER_CONFIG_KEY)?.config);
844
+ }
845
+ saveManualProviders(config) {
846
+ this.tokenStore.saveDaemonRuntimeConfig(MANUAL_PROVIDER_CONFIG_KEY, {
847
+ ...config,
848
+ updatedAt: new Date().toISOString()
849
+ });
850
+ }
851
+ currentManualProviderSecrets() {
852
+ const value = this.tokenStore.getDaemonRuntimeConfig(MANUAL_PROVIDER_SECRET_CONFIG_KEY)?.config;
853
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
854
+ return { version: 1, secrets: [], updatedAt: new Date().toISOString() };
855
+ }
856
+ const record = value;
857
+ const secrets = (Array.isArray(record.secrets) ? record.secrets : [])
858
+ .filter((entry) => Boolean(entry && typeof entry === "object" && !Array.isArray(entry)))
859
+ .flatMap((entry) => {
860
+ const secretRef = typeof entry.secretRef === "string" ? entry.secretRef.trim() : "";
861
+ const apiKey = typeof entry.apiKey === "string" ? entry.apiKey : "";
862
+ if (!secretRef || !apiKey)
863
+ return [];
864
+ return [{
865
+ secretRef,
866
+ apiKey,
867
+ createdAt: typeof entry.createdAt === "string" ? entry.createdAt : new Date().toISOString(),
868
+ updatedAt: typeof entry.updatedAt === "string" ? entry.updatedAt : new Date().toISOString()
869
+ }];
870
+ });
871
+ return {
872
+ version: 1,
873
+ secrets,
874
+ updatedAt: typeof record.updatedAt === "string" ? record.updatedAt : new Date().toISOString()
875
+ };
876
+ }
877
+ saveManualProviderSecret(secretRef, apiKey) {
878
+ const now = new Date().toISOString();
879
+ const current = this.currentManualProviderSecrets();
880
+ const existing = current.secrets.find((entry) => entry.secretRef === secretRef);
881
+ this.tokenStore.saveDaemonRuntimeConfig(MANUAL_PROVIDER_SECRET_CONFIG_KEY, {
882
+ version: 1,
883
+ secrets: [
884
+ ...current.secrets.filter((entry) => entry.secretRef !== secretRef),
885
+ {
886
+ secretRef,
887
+ apiKey,
888
+ createdAt: existing?.createdAt ?? now,
889
+ updatedAt: now
890
+ }
891
+ ],
892
+ updatedAt: now
893
+ });
894
+ }
895
+ removeManualProviderSecret(secretRef) {
896
+ if (!secretRef)
897
+ return;
898
+ const current = this.currentManualProviderSecrets();
899
+ const secrets = current.secrets.filter((entry) => entry.secretRef !== secretRef);
900
+ if (secrets.length === current.secrets.length)
901
+ return;
902
+ this.tokenStore.saveDaemonRuntimeConfig(MANUAL_PROVIDER_SECRET_CONFIG_KEY, {
903
+ version: 1,
904
+ secrets,
905
+ updatedAt: new Date().toISOString()
906
+ });
907
+ }
908
+ currentManualProviderObservations() {
909
+ return normalizeManualProviderObservationsConfig(this.tokenStore.getDaemonRuntimeConfig(MANUAL_PROVIDER_OBSERVATIONS_CONFIG_KEY)?.config);
910
+ }
911
+ recordManualProviderObservation(input) {
912
+ const now = input.lastAccess ?? new Date().toISOString();
913
+ const current = this.currentManualProviderObservations();
914
+ const observations = current.observations
915
+ .filter((entry) => entry.providerId !== input.providerId)
916
+ .map((entry) => ({
917
+ ...entry,
918
+ current: input.current ? false : entry.current
919
+ }));
920
+ observations.push({
921
+ providerId: input.providerId,
922
+ current: input.current,
923
+ lastAccess: now,
924
+ status: input.status,
925
+ errorClass: input.errorClass,
926
+ errorMessage: input.errorMessage,
927
+ ttftMs: input.ttftMs,
928
+ avgTokensPerSecond: input.avgTokensPerSecond
929
+ });
930
+ this.tokenStore.saveDaemonRuntimeConfig(MANUAL_PROVIDER_OBSERVATIONS_CONFIG_KEY, {
931
+ version: 1,
932
+ observations,
933
+ updatedAt: now
934
+ });
935
+ }
936
+ currentAutoProviderConfig() {
937
+ return normalizeAutoProviderConfig(this.tokenStore.getDaemonRuntimeConfig(AUTO_PROVIDER_CONFIG_KEY)?.config);
938
+ }
939
+ saveAutoProviderConfig(config) {
940
+ this.tokenStore.saveDaemonRuntimeConfig(AUTO_PROVIDER_CONFIG_KEY, {
941
+ ...config,
942
+ updatedAt: new Date().toISOString()
943
+ });
944
+ }
945
+ applyAutoProviderRoutingConfig(config) {
946
+ const routing = config.range === "custom"
947
+ ? {
948
+ mode: "fixedSet",
949
+ scorer: config.scorer,
950
+ sellerIds: config.sellerIds
951
+ }
952
+ : {
953
+ mode: "fullAuto",
954
+ scorer: config.scorer
955
+ };
956
+ assertSellerRoutingConfig(routing);
957
+ this.tokenStore.saveDaemonRuntimeConfig(ROUTING_CONFIG_KEY, routing);
958
+ const current = this.refreshSellerRoutingConfig();
959
+ if (config.modelIds.length > 0) {
960
+ this.applyFocusSet(config.modelIds);
961
+ }
962
+ return current;
963
+ }
964
+ autoProviderCanRoute(config) {
965
+ return config.enabled && (config.range !== "custom" || config.sellerIds.length > 0);
966
+ }
967
+ providerModePayload() {
968
+ const mode = this.currentProviderMode();
969
+ const autoProvider = this.currentAutoProviderConfig();
970
+ const payment = this.providerPaymentState();
971
+ return {
972
+ mode: mode.mode,
973
+ updatedAt: mode.updatedAt,
974
+ active: mode.mode,
975
+ manualEnabled: mode.mode === "manual",
976
+ autoEnabled: mode.mode === "auto" && this.autoProviderCanRoute(autoProvider) && payment.paymentReady,
977
+ paymentReady: payment.paymentReady,
978
+ paymentRequired: !payment.paymentReady,
979
+ paymentLabel: payment.paymentLabel,
980
+ locked: {
981
+ auto: !payment.paymentReady
982
+ }
983
+ };
984
+ }
750
985
  clientToolsSummary() {
751
986
  const providerStatuses = detectProviders({ home: this.config.providerHomeDir }).map(clientToolStatusFromProvider);
752
987
  const clients = [
@@ -977,7 +1212,7 @@ export class TokenbuddyDaemon {
977
1212
  this.sellerPool.recordRuntimeMetrics(route.seller.id, {
978
1213
  ttftMs: finiteNumber(latency?.ttftMs ?? latency?.ttft_ms),
979
1214
  avgInferenceMs: finiteNumber(latency?.avgInferenceMs ?? latency?.avg_inference_ms),
980
- avgTokensPerSecond: finiteNumber(latency?.avgTokensPerSecond ?? latency?.avg_tokens_per_second)
1215
+ avgTokensPerSecond: finitePositiveNumber(latency?.avgTokensPerSecond ?? latency?.avg_tokens_per_second)
981
1216
  });
982
1217
  }
983
1218
  catch (error) {
@@ -1070,6 +1305,89 @@ export class TokenbuddyDaemon {
1070
1305
  const payments = this.tokenStore.listPayments().filter((payment) => payment.enabled);
1071
1306
  return payments.find((payment) => payment.isDefault)?.method || payments.find((payment) => payment.method === "mock")?.method;
1072
1307
  }
1308
+ selectManualProviderRoutes(endpoint, modelId) {
1309
+ const protocol = this.endpointProtocol(endpoint);
1310
+ if (!protocol) {
1311
+ throw new Error(`unsupported proxy endpoint: ${endpoint}`);
1312
+ }
1313
+ const config = this.currentManualProviders();
1314
+ let providers;
1315
+ let reason;
1316
+ if (config.routing.policy === "locked") {
1317
+ const provider = config.providers.find((entry) => entry.id === config.routing.lockedProviderId);
1318
+ if (!provider) {
1319
+ throw new Error(`locked manual provider is not configured: ${config.routing.lockedProviderId}`);
1320
+ }
1321
+ if (!provider.enabled) {
1322
+ throw new Error(`locked manual provider is disabled: ${provider.id}`);
1323
+ }
1324
+ if (!provider.supportedProtocols.includes(protocol)) {
1325
+ throw new Error(`locked manual provider ${provider.id} does not support ${endpoint}`);
1326
+ }
1327
+ if (!provider.models.includes(modelId)) {
1328
+ throw new Error(`locked manual provider ${provider.id} does not support model ${modelId}`);
1329
+ }
1330
+ providers = [provider];
1331
+ reason = `manual:locked:${provider.id}`;
1332
+ }
1333
+ else {
1334
+ providers = config.providers.filter((provider) => (provider.enabled &&
1335
+ provider.models.includes(modelId) &&
1336
+ provider.supportedProtocols.includes(protocol)));
1337
+ reason = `manual:fallback:routes_${providers.length}`;
1338
+ }
1339
+ if (providers.length === 0) {
1340
+ throw new Error(`no compatible manual provider for ${endpoint} model ${modelId}`);
1341
+ }
1342
+ const routes = providers.map((provider) => ({
1343
+ seller: {
1344
+ id: provider.id,
1345
+ name: provider.name,
1346
+ url: provider.baseUrl,
1347
+ status: "active",
1348
+ supportedProtocols: provider.supportedProtocols,
1349
+ paymentMethods: ["provider_key"],
1350
+ models: provider.models
1351
+ },
1352
+ transport: "manual_provider",
1353
+ manualProvider: provider,
1354
+ manifest: null,
1355
+ protocol,
1356
+ modelId,
1357
+ paymentMethod: "provider_key",
1358
+ planSource: "registry_fallback",
1359
+ planReason: reason,
1360
+ planSellerCount: providers.length
1361
+ }));
1362
+ return {
1363
+ routes,
1364
+ paymentMethod: "provider_key",
1365
+ plan: {
1366
+ routes: [],
1367
+ source: "registry_fallback",
1368
+ sourceReason: "manual_provider_config",
1369
+ reason,
1370
+ mode: "fixedSet",
1371
+ scorer: "balanced",
1372
+ candidateCount: providers.length,
1373
+ diagnostics: {
1374
+ registryVisibleCount: providers.length,
1375
+ prewarmCandidateCount: 0,
1376
+ prewarmUsableCount: 0,
1377
+ prewarmMissingSellerIds: [],
1378
+ prewarmBlockedSellerIds: [],
1379
+ prewarmIncompatibleSellerIds: [],
1380
+ sourceCandidateCount: providers.length,
1381
+ blockedOpenCircuitCount: 0,
1382
+ blockedCapacityCount: 0,
1383
+ blockedLocalConcurrencyCount: 0,
1384
+ blockedSellerIds: [],
1385
+ incompatibleCount: 0,
1386
+ incompatibleSellerIds: []
1387
+ }
1388
+ }
1389
+ };
1390
+ }
1073
1391
  async selectSellerRoutes(endpoint, modelId, requestId) {
1074
1392
  const protocol = this.endpointProtocol(endpoint);
1075
1393
  if (!protocol) {
@@ -1130,6 +1448,7 @@ export class TokenbuddyDaemon {
1130
1448
  }
1131
1449
  const routes = planned.routes.map((route) => ({
1132
1450
  seller: route.seller,
1451
+ transport: "tokenbuddy_seller",
1133
1452
  manifest: null,
1134
1453
  protocol,
1135
1454
  modelId,
@@ -1355,6 +1674,9 @@ export class TokenbuddyDaemon {
1355
1674
  ledgerStatus: input.status,
1356
1675
  creditMicros: input.creditMicros,
1357
1676
  currency: input.currency,
1677
+ paymentAmount: input.paymentAmount,
1678
+ paymentAmountMinor: input.paymentAmountMinor,
1679
+ paymentCurrency: input.paymentCurrency,
1358
1680
  durationMs: input.durationMs
1359
1681
  });
1360
1682
  }
@@ -1374,6 +1696,32 @@ export class TokenbuddyDaemon {
1374
1696
  });
1375
1697
  }
1376
1698
  async listSellerBackedModels() {
1699
+ const manualConfig = this.currentManualProviders();
1700
+ if (this.currentProviderMode().mode === "manual" && manualConfig.providers.some((provider) => provider.enabled)) {
1701
+ const observations = new Map(this.currentManualProviderObservations().observations.map((entry) => [entry.providerId, entry]));
1702
+ const providers = manualConfig.providers.filter((provider) => provider.enabled);
1703
+ return {
1704
+ models: providers.flatMap((provider) => provider.models.map((modelId) => ({
1705
+ id: modelId,
1706
+ sellerId: provider.id,
1707
+ sellerName: provider.name,
1708
+ sellerUrl: provider.baseUrl,
1709
+ supportedProtocols: provider.supportedProtocols,
1710
+ paymentMethods: ["provider_key"]
1711
+ }))),
1712
+ sellers: providers.map((provider) => {
1713
+ const observation = observations.get(provider.id);
1714
+ return {
1715
+ id: provider.id,
1716
+ name: provider.name,
1717
+ url: provider.baseUrl,
1718
+ status: observation?.status ?? "active",
1719
+ ttftMs: observation?.ttftMs,
1720
+ avgTokensPerSecond: observation?.avgTokensPerSecond
1721
+ };
1722
+ })
1723
+ };
1724
+ }
1377
1725
  try {
1378
1726
  const catalog = await discoverSellerBackedModels(this.config.sellerRegistryUrl);
1379
1727
  this.lastRegistrySnapshot = catalog.registry ?? sellerCatalogResultToRegistrySnapshot(catalog);
@@ -1408,7 +1756,7 @@ export class TokenbuddyDaemon {
1408
1756
  return {
1409
1757
  ...seller,
1410
1758
  ttftMs: runtime?.ttftMs ?? seller.ttftMs,
1411
- avgTokensPerSecond: runtime?.avgTokensPerSecond ?? seller.avgTokensPerSecond ?? 0
1759
+ avgTokensPerSecond: runtime?.avgTokensPerSecond ?? seller.avgTokensPerSecond
1412
1760
  };
1413
1761
  });
1414
1762
  }
@@ -1441,18 +1789,28 @@ export class TokenbuddyDaemon {
1441
1789
  const fallback = {
1442
1790
  promptTokens: 0,
1443
1791
  completionTokens: 0,
1792
+ cacheReadTokens: 0,
1444
1793
  billedMicros: 0
1445
1794
  };
1446
1795
  if (!bodyText.trim()) {
1447
1796
  return fallback;
1448
1797
  }
1449
1798
  try {
1450
- const data = JSON.parse(bodyText);
1451
- const promptTokens = data.usage?.prompt_tokens ?? data.usage?.input_tokens ?? 0;
1452
- const completionTokens = data.usage?.completion_tokens ?? data.usage?.output_tokens ?? 0;
1799
+ const data = usageRecord(JSON.parse(bodyText));
1800
+ const usage = usageRecord(data?.usage) ?? usageRecord(usageRecord(data?.response)?.usage);
1801
+ const promptDetails = usageRecord(usage?.prompt_tokens_details);
1802
+ const inputDetails = usageRecord(usage?.input_tokens_details);
1803
+ const promptTokens = nonNegativeIntegerField(usage?.prompt_tokens) ?? nonNegativeIntegerField(usage?.input_tokens) ?? 0;
1804
+ const completionTokens = nonNegativeIntegerField(usage?.completion_tokens) ?? nonNegativeIntegerField(usage?.output_tokens) ?? 0;
1805
+ const cacheReadTokens = nonNegativeIntegerField(promptDetails?.cached_tokens)
1806
+ ?? nonNegativeIntegerField(inputDetails?.cached_tokens)
1807
+ ?? nonNegativeIntegerField(usage?.cache_read_input_tokens)
1808
+ ?? nonNegativeIntegerField(usage?.cache_read_tokens)
1809
+ ?? 0;
1453
1810
  return {
1454
1811
  promptTokens,
1455
1812
  completionTokens,
1813
+ cacheReadTokens,
1456
1814
  billedMicros: (promptTokens + completionTokens) * 4
1457
1815
  };
1458
1816
  }
@@ -1482,6 +1840,7 @@ export class TokenbuddyDaemon {
1482
1840
  const sellerRequestId = settlement?.requestId && settlement.requestId !== requestId
1483
1841
  ? settlement.requestId
1484
1842
  : undefined;
1843
+ const billingBreakdown = settlement?.billingBreakdown;
1485
1844
  this.tokenStore.recordInferenceLedger({
1486
1845
  requestId,
1487
1846
  sellerKey: route.seller.id,
@@ -1490,11 +1849,21 @@ export class TokenbuddyDaemon {
1490
1849
  status: settlement ? "settled" : "estimated",
1491
1850
  promptTokens: usage.promptTokens,
1492
1851
  completionTokens: usage.completionTokens,
1852
+ cacheReadTokens: usage.cacheReadTokens,
1493
1853
  billedMicros: settledMicros ?? usage.billedMicros,
1494
1854
  estimatedMicros: usage.billedMicros,
1495
1855
  settledMicros,
1496
1856
  settledUsdMicros: settlement?.settledUsdMicros,
1497
1857
  priceVersion: settlement?.priceVersion,
1858
+ inputPriceMicrosPer1m: billingBreakdown?.inputPriceMicrosPer1m,
1859
+ outputPriceMicrosPer1m: billingBreakdown?.outputPriceMicrosPer1m,
1860
+ cacheReadPriceMicrosPer1m: billingBreakdown?.cacheReadPriceMicrosPer1m,
1861
+ inputCostMicros: billingBreakdown?.inputCostMicros,
1862
+ outputCostMicros: billingBreakdown?.outputCostMicros,
1863
+ cacheReadCostMicros: billingBreakdown?.cacheReadCostMicros,
1864
+ originalUsdMicros: billingBreakdown?.originalUsdMicros,
1865
+ billingMultiplier: billingBreakdown?.billingMultiplier,
1866
+ serviceTier: billingBreakdown?.serviceTier,
1498
1867
  balanceSnapshotMicros: settlement?.remainingCreditMicros,
1499
1868
  balanceSource: settlement ? "seller_authoritative" : "estimated",
1500
1869
  prompt,
@@ -1519,6 +1888,7 @@ export class TokenbuddyDaemon {
1519
1888
  billedMicros: settledMicros ?? usage.billedMicros,
1520
1889
  promptTokens: usage.promptTokens,
1521
1890
  completionTokens: usage.completionTokens,
1891
+ cacheReadTokens: usage.cacheReadTokens,
1522
1892
  balanceSnapshotMicros: settlement?.remainingCreditMicros,
1523
1893
  balanceSource: settlement ? "seller_authoritative" : "estimated",
1524
1894
  sellerRequestId,
@@ -1819,6 +2189,7 @@ export class TokenbuddyDaemon {
1819
2189
  const currency = completeData.currency || createData.currency || "USD";
1820
2190
  const expiresAt = new Date(Date.now() + 86400 * 1000).toISOString();
1821
2191
  const ledgerStatus = completeData.status || "funded";
2192
+ const paymentSummary = purchasePaymentSummaryFromQuote(completeData.quote ?? createData.quote);
1822
2193
  this.tokenStore.saveToken(sellerKey, token, tokenClass, creditMicros, expiresAt);
1823
2194
  this.tokenStore.recordPurchaseLedger({
1824
2195
  purchaseId,
@@ -1828,6 +2199,7 @@ export class TokenbuddyDaemon {
1828
2199
  status: ledgerStatus,
1829
2200
  creditMicros,
1830
2201
  currency,
2202
+ ...paymentSummary,
1831
2203
  paymentReference: completeData.paymentReference || completeData.payment_reference,
1832
2204
  completedAt: new Date().toISOString()
1833
2205
  });
@@ -1840,6 +2212,7 @@ export class TokenbuddyDaemon {
1840
2212
  status: ledgerStatus,
1841
2213
  creditMicros,
1842
2214
  currency,
2215
+ ...paymentSummary,
1843
2216
  durationMs: Date.now() - startedAt
1844
2217
  });
1845
2218
  // v1.1: feed the credit tracker so the route-failover controller
@@ -1854,6 +2227,7 @@ export class TokenbuddyDaemon {
1854
2227
  tokenClass,
1855
2228
  creditMicros,
1856
2229
  currency,
2230
+ ...paymentSummary,
1857
2231
  ledgerStatus,
1858
2232
  completeStatus: completeRes.status,
1859
2233
  durationMs: Date.now() - startedAt
@@ -1996,6 +2370,323 @@ export class TokenbuddyDaemon {
1996
2370
  res.setHeader(key, value);
1997
2371
  });
1998
2372
  }
2373
+ manualProviderApiKey(provider) {
2374
+ if (provider.apiKeyEnv) {
2375
+ const value = process.env[provider.apiKeyEnv];
2376
+ if (!value) {
2377
+ throw new Error(`manual provider key env is not configured: ${provider.apiKeyEnv}`);
2378
+ }
2379
+ return value;
2380
+ }
2381
+ if (provider.secretRef) {
2382
+ const secret = this.currentManualProviderSecrets().secrets.find((entry) => entry.secretRef === provider.secretRef);
2383
+ if (!secret) {
2384
+ throw new Error(`manual provider secret is not configured: ${provider.secretRef}`);
2385
+ }
2386
+ return secret.apiKey;
2387
+ }
2388
+ throw new Error(`manual provider key reference is not configured: ${provider.id}`);
2389
+ }
2390
+ manualProviderEndpointUrl(provider, endpoint) {
2391
+ const baseUrl = provider.baseUrl.replace(/\/+$/, "");
2392
+ if (baseUrl.endsWith("/v1") && endpoint.startsWith("/v1/")) {
2393
+ return `${baseUrl}${endpoint.slice(3)}`;
2394
+ }
2395
+ return `${baseUrl}${endpoint}`;
2396
+ }
2397
+ manualProviderEndpointUrlFromBase(baseUrl, endpoint) {
2398
+ const normalized = baseUrl.replace(/\/+$/, "");
2399
+ if (normalized.endsWith("/v1") && endpoint.startsWith("/v1/")) {
2400
+ return `${normalized}${endpoint.slice(3)}`;
2401
+ }
2402
+ return `${normalized}${endpoint}`;
2403
+ }
2404
+ async probeManualProviderModels(input) {
2405
+ let parsed;
2406
+ try {
2407
+ parsed = new URL(input.baseUrl);
2408
+ }
2409
+ catch {
2410
+ throw new Error("manual provider baseUrl must be a valid URL");
2411
+ }
2412
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
2413
+ throw new Error("manual provider baseUrl must use http or https");
2414
+ }
2415
+ if (input.apiKey.trim().length < 6) {
2416
+ throw new Error("manual provider api key is too short");
2417
+ }
2418
+ const startedAt = Date.now();
2419
+ const ac = new AbortController();
2420
+ const timer = setTimeout(() => ac.abort(new Error("manual provider model probe timeout")), this.config.warmupProbeTimeoutMs ?? 3000);
2421
+ try {
2422
+ const response = await fetch(this.manualProviderEndpointUrlFromBase(parsed.toString(), "/v1/models"), {
2423
+ method: "GET",
2424
+ headers: {
2425
+ "Authorization": `Bearer ${input.apiKey.trim()}`
2426
+ },
2427
+ signal: ac.signal
2428
+ });
2429
+ if (!response.ok) {
2430
+ if (response.status === 401 || response.status === 403) {
2431
+ throw new Error("manual provider authentication failed");
2432
+ }
2433
+ if (response.status === 404) {
2434
+ throw new Error("manual provider /v1/models endpoint was not found");
2435
+ }
2436
+ throw new Error(`manual provider model probe returned HTTP ${response.status}`);
2437
+ }
2438
+ const body = await response.json().catch(() => undefined);
2439
+ const modelIds = parseOpenAiModelIds(body);
2440
+ if (modelIds.length === 0) {
2441
+ throw new Error("manual provider model list is empty");
2442
+ }
2443
+ return {
2444
+ modelIds,
2445
+ elapsedMs: Date.now() - startedAt
2446
+ };
2447
+ }
2448
+ finally {
2449
+ clearTimeout(timer);
2450
+ }
2451
+ }
2452
+ manualProviderErrorClass(status) {
2453
+ if (status === 401 || status === 403) {
2454
+ return "auth_failed";
2455
+ }
2456
+ if (status === 429) {
2457
+ return "rate_limited";
2458
+ }
2459
+ if (status >= 500) {
2460
+ return "upstream_5xx";
2461
+ }
2462
+ if (status === 404) {
2463
+ return "model_or_endpoint_not_found";
2464
+ }
2465
+ return `http_${status}`;
2466
+ }
2467
+ shouldFailoverManualProvider(status) {
2468
+ return status === 408 || status === 429 || status >= 500;
2469
+ }
2470
+ async fetchManualProviderRoute(route, endpoint, requestId, idempotencyKey, routeIndex, attempt, body) {
2471
+ const provider = route.manualProvider;
2472
+ if (!provider) {
2473
+ throw new Error("manual provider route missing provider config");
2474
+ }
2475
+ const deadlineMs = this.requestDeadlineMs();
2476
+ const requestAc = new AbortController();
2477
+ const requestTimer = setTimeout(() => requestAc.abort(new Error("buyer deadline exceeded")), deadlineMs);
2478
+ const attemptContext = sellerAttemptRequestContext(requestId, idempotencyKey, routeIndex, attempt, 0);
2479
+ try {
2480
+ return await fetch(this.manualProviderEndpointUrl(provider, endpoint), {
2481
+ method: "POST",
2482
+ headers: {
2483
+ "Content-Type": "application/json",
2484
+ "Authorization": `Bearer ${this.manualProviderApiKey(provider)}`,
2485
+ "X-Request-Id": attemptContext.requestId,
2486
+ "Idempotency-Key": attemptContext.idempotencyKey,
2487
+ "X-TokenBuddy-Deadline-Ms": String(deadlineMs)
2488
+ },
2489
+ body: JSON.stringify({
2490
+ ...body,
2491
+ requestId: attemptContext.requestId
2492
+ }),
2493
+ signal: requestAc.signal
2494
+ });
2495
+ }
2496
+ finally {
2497
+ clearTimeout(requestTimer);
2498
+ }
2499
+ }
2500
+ async forwardManualProviderRoute(input) {
2501
+ const { route, endpoint, reqBody, res, requestId, idempotencyKey, modelId, requestedModelId, routeIndex, routes, plan, paymentMethod, startedAt, markFirstByte } = input;
2502
+ const provider = route.manualProvider;
2503
+ if (!provider) {
2504
+ throw new Error("manual provider route missing provider config");
2505
+ }
2506
+ const sellerKey = route.seller.id;
2507
+ const attemptStartedAt = Date.now();
2508
+ const upstreamBody = this.applyResolvedModelToBody(endpoint, {
2509
+ ...reqBody,
2510
+ requestId
2511
+ }, modelId);
2512
+ logger.info("manual_provider.request.started", "manual provider request started", {
2513
+ requestId,
2514
+ providerId: provider.id,
2515
+ sellerKey,
2516
+ model: modelId,
2517
+ requestedModel: requestedModelId,
2518
+ endpoint,
2519
+ stream: Boolean(reqBody.stream),
2520
+ routeIndex,
2521
+ backup: routeIndex > 0,
2522
+ routePlanReason: route.planReason,
2523
+ bodySummary: summarizeProxyBody(upstreamBody)
2524
+ });
2525
+ let response;
2526
+ try {
2527
+ response = await this.fetchManualProviderRoute(route, endpoint, requestId, idempotencyKey, routeIndex, 0, upstreamBody);
2528
+ }
2529
+ catch (error) {
2530
+ const errorMessage = error instanceof Error ? error.message : String(error);
2531
+ this.recordManualProviderObservation({
2532
+ providerId: provider.id,
2533
+ current: false,
2534
+ status: "unhealthy",
2535
+ errorClass: "deadline",
2536
+ errorMessage
2537
+ });
2538
+ logger.warn("manual_provider.request.failed", "manual provider request failed before response", {
2539
+ requestId,
2540
+ providerId: provider.id,
2541
+ model: modelId,
2542
+ endpoint,
2543
+ errorMessage,
2544
+ durationMs: Date.now() - attemptStartedAt
2545
+ });
2546
+ if (routeIndex + 1 < routes.length) {
2547
+ return false;
2548
+ }
2549
+ throw error;
2550
+ }
2551
+ if (!response.ok) {
2552
+ const errorBody = await response.text();
2553
+ const errorClass = this.manualProviderErrorClass(response.status);
2554
+ const canFailover = this.shouldFailoverManualProvider(response.status) && routeIndex + 1 < routes.length;
2555
+ this.recordManualProviderObservation({
2556
+ providerId: provider.id,
2557
+ current: false,
2558
+ status: this.shouldFailoverManualProvider(response.status) ? "degraded" : "unhealthy",
2559
+ errorClass,
2560
+ errorMessage: errorBody.slice(0, 512)
2561
+ });
2562
+ logger.warn("manual_provider.upstream_fetch.failed", "manual provider upstream fetch returned non-ok status", {
2563
+ requestId,
2564
+ providerId: provider.id,
2565
+ model: modelId,
2566
+ endpoint,
2567
+ status: response.status,
2568
+ errorClass,
2569
+ failover: canFailover,
2570
+ durationMs: Date.now() - attemptStartedAt
2571
+ });
2572
+ if (canFailover) {
2573
+ return false;
2574
+ }
2575
+ this.copyUpstreamHeaders(response, res);
2576
+ res.status(response.status);
2577
+ res.send(errorBody);
2578
+ return true;
2579
+ }
2580
+ this.copyUpstreamHeaders(response, res);
2581
+ res.status(response.status);
2582
+ logger.info("manual_provider.upstream_fetch.succeeded", "manual provider upstream fetch succeeded", {
2583
+ requestId,
2584
+ providerId: provider.id,
2585
+ model: modelId,
2586
+ endpoint,
2587
+ status: response.status,
2588
+ stream: Boolean(reqBody.stream)
2589
+ });
2590
+ const contentType = response.headers.get("content-type") || "";
2591
+ if (contentType.includes("text/event-stream") || Boolean(reqBody.stream)) {
2592
+ const reader = response.body?.getReader();
2593
+ if (!reader) {
2594
+ res.end();
2595
+ return true;
2596
+ }
2597
+ let bytes = 0;
2598
+ const decoder = new TextDecoder();
2599
+ while (true) {
2600
+ const { done, value } = await reader.read();
2601
+ if (done) {
2602
+ break;
2603
+ }
2604
+ bytes += value.byteLength;
2605
+ const chunk = decoder.decode(value, { stream: true });
2606
+ if (chunk.length > 0) {
2607
+ markFirstByte();
2608
+ res.write(chunk);
2609
+ }
2610
+ }
2611
+ const decoderTail = decoder.decode();
2612
+ if (decoderTail.length > 0) {
2613
+ markFirstByte();
2614
+ res.write(decoderTail);
2615
+ }
2616
+ res.end();
2617
+ const durationMs = Date.now() - startedAt;
2618
+ const ttftMs = durationMs > 0 ? Date.now() - attemptStartedAt : undefined;
2619
+ this.recordManualProviderObservation({
2620
+ providerId: provider.id,
2621
+ current: true,
2622
+ status: "healthy",
2623
+ ttftMs,
2624
+ avgTokensPerSecond: undefined
2625
+ });
2626
+ this.tokenStore.recordInferenceLedger({
2627
+ requestId,
2628
+ sellerKey,
2629
+ modelId,
2630
+ endpoint,
2631
+ status: "ok",
2632
+ promptTokens: 0,
2633
+ completionTokens: 0,
2634
+ cacheReadTokens: 0,
2635
+ billedMicros: Math.max(1, bytes),
2636
+ estimatedMicros: Math.max(1, bytes),
2637
+ priceVersion: `local-provider:${provider.id}`,
2638
+ balanceSource: "self_funded_provider",
2639
+ prompt: this.inferPromptForHash(reqBody),
2640
+ ttftMs,
2641
+ fallbackCount: routeIndex,
2642
+ routeReason: plan.reason,
2643
+ falloverChain: routes.slice(0, routeIndex + 1).map((entry) => entry.seller.id),
2644
+ upstreamStatus: "healthy",
2645
+ durationMs,
2646
+ paymentMethod
2647
+ });
2648
+ return true;
2649
+ }
2650
+ const responseBody = await response.text();
2651
+ markFirstByte();
2652
+ res.send(responseBody);
2653
+ const usage = this.readUsage(responseBody);
2654
+ const durationMs = Date.now() - startedAt;
2655
+ const ttftMs = Date.now() - attemptStartedAt;
2656
+ const completionTokens = usage.completionTokens;
2657
+ const avgTokensPerSecond = durationMs > 0 && completionTokens > 0 ? completionTokens / (durationMs / 1000) : undefined;
2658
+ this.recordManualProviderObservation({
2659
+ providerId: provider.id,
2660
+ current: true,
2661
+ status: "healthy",
2662
+ ttftMs,
2663
+ avgTokensPerSecond
2664
+ });
2665
+ this.tokenStore.recordInferenceLedger({
2666
+ requestId,
2667
+ sellerKey,
2668
+ modelId,
2669
+ endpoint,
2670
+ status: "ok",
2671
+ promptTokens: usage.promptTokens,
2672
+ completionTokens: usage.completionTokens,
2673
+ cacheReadTokens: usage.cacheReadTokens,
2674
+ billedMicros: usage.billedMicros,
2675
+ estimatedMicros: usage.billedMicros,
2676
+ priceVersion: `local-provider:${provider.id}`,
2677
+ balanceSource: "self_funded_provider",
2678
+ prompt: this.inferPromptForHash(reqBody),
2679
+ response: responseBody,
2680
+ ttftMs,
2681
+ fallbackCount: routeIndex,
2682
+ routeReason: plan.reason,
2683
+ falloverChain: routes.slice(0, routeIndex + 1).map((entry) => entry.seller.id),
2684
+ upstreamStatus: "healthy",
2685
+ durationMs,
2686
+ paymentMethod
2687
+ });
2688
+ return true;
2689
+ }
1999
2690
  async forwardProxyRequest(endpoint, req, res) {
2000
2691
  const startedAt = Date.now();
2001
2692
  let firstByteAt = null;
@@ -2013,7 +2704,10 @@ export class TokenbuddyDaemon {
2013
2704
  return;
2014
2705
  }
2015
2706
  try {
2016
- const { routes, plan, paymentMethod } = await this.selectSellerRoutes(endpoint, modelId, requestId);
2707
+ const routeSelection = this.currentProviderMode().mode === "manual"
2708
+ ? this.selectManualProviderRoutes(endpoint, modelId)
2709
+ : await this.selectSellerRoutes(endpoint, modelId, requestId);
2710
+ const { routes, plan, paymentMethod } = routeSelection;
2017
2711
  const upstreamStatusFromHeaders = (h) => {
2018
2712
  const raw = h.get("x-tokenbuddy-upstream-status") || h.get("x-upstream-status");
2019
2713
  if (!raw)
@@ -2024,6 +2718,39 @@ export class TokenbuddyDaemon {
2024
2718
  for (let routeIndex = 0; routeIndex < routes.length; routeIndex += 1) {
2025
2719
  const route = routes[routeIndex];
2026
2720
  const sellerKey = route.seller.id;
2721
+ if (route.transport === "manual_provider") {
2722
+ const completed = await this.forwardManualProviderRoute({
2723
+ route,
2724
+ endpoint,
2725
+ reqBody: body,
2726
+ res,
2727
+ requestId,
2728
+ idempotencyKey,
2729
+ modelId,
2730
+ requestedModelId,
2731
+ routeIndex,
2732
+ routes,
2733
+ plan,
2734
+ paymentMethod,
2735
+ startedAt,
2736
+ markFirstByte
2737
+ });
2738
+ if (completed) {
2739
+ return;
2740
+ }
2741
+ lastError = new Error(`manual provider ${sellerKey} failed over`);
2742
+ logger.warn("manual_provider.route.failover", "manual provider route failed over", {
2743
+ requestId,
2744
+ providerId: sellerKey,
2745
+ model: modelId,
2746
+ endpoint,
2747
+ routeIndex,
2748
+ nextRouteIndex: routeIndex + 1,
2749
+ routesRemaining: routes.length - routeIndex,
2750
+ routesRemainingAfterCurrent: Math.max(0, routes.length - routeIndex - 1)
2751
+ });
2752
+ continue;
2753
+ }
2027
2754
  const lease = this.sellerConcurrencyLimiter.tryAcquire(sellerKey, { requestId, modelId, endpoint });
2028
2755
  if (!lease) {
2029
2756
  const routesRemaining = routes.length - routeIndex;
@@ -2319,7 +3046,7 @@ export class TokenbuddyDaemon {
2319
3046
  }
2320
3047
  res.end();
2321
3048
  void this.refreshSellerRuntimeMetrics(route, requestId);
2322
- 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, {
3049
+ this.recordReconciledInference(route, endpoint, requestId, { promptTokens: 0, completionTokens: 0, cacheReadTokens: 0, billedMicros: Math.max(1, bytes) }, this.parseSellerSettlementSummary(upstreamResponse.headers) ?? settlementTrailing.settlement ?? settlementExtractor.current(), this.inferPromptForHash(body), undefined, {
2323
3050
  ttftMs: firstByteAt ? firstByteAt - startedAt : undefined,
2324
3051
  fallbackCount: routeIndex,
2325
3052
  routeReason: plan.reason,
@@ -2446,6 +3173,56 @@ export class TokenbuddyDaemon {
2446
3173
  payments: this.livePayments()
2447
3174
  });
2448
3175
  });
3176
+ controlApp.put("/payments/default", (req, res) => {
3177
+ try {
3178
+ const method = typeof req.body?.method === "string" ? req.body.method.trim() : "";
3179
+ const normalizedMethod = normalizePaymentMethodName(method);
3180
+ const payments = this.livePayments();
3181
+ const target = payments.find((payment) => normalizePaymentMethodName(payment.method) === normalizedMethod);
3182
+ if (!normalizedMethod || normalizedMethod === "mock" || !target) {
3183
+ res.status(400).json({
3184
+ error: {
3185
+ code: "payment_default_invalid",
3186
+ message: "payment method is not configured"
3187
+ }
3188
+ });
3189
+ return;
3190
+ }
3191
+ if (!target.enabled && !readConfigBoolean(target.config, "walletConfigPresent")) {
3192
+ res.status(400).json({
3193
+ error: {
3194
+ code: "payment_default_not_ready",
3195
+ message: "payment method must be bound before it can be selected"
3196
+ }
3197
+ });
3198
+ return;
3199
+ }
3200
+ for (const payment of payments) {
3201
+ this.tokenStore.savePayment({
3202
+ method: payment.method,
3203
+ enabled: payment.enabled,
3204
+ isDefault: normalizePaymentMethodName(payment.method) === normalizedMethod,
3205
+ config: payment.config
3206
+ });
3207
+ }
3208
+ logger.info("control.payment.default_selected", "default payment selected", {
3209
+ method: target.method
3210
+ });
3211
+ res.status(200).json({
3212
+ payments: this.livePayments()
3213
+ });
3214
+ }
3215
+ catch (error) {
3216
+ const errorMessage = error instanceof Error ? error.message : String(error);
3217
+ logger.warn("control.payment.default_select_failed", "default payment select failed", { errorMessage });
3218
+ res.status(400).json({
3219
+ error: {
3220
+ code: "payment_default_select_failed",
3221
+ message: errorMessage
3222
+ }
3223
+ });
3224
+ }
3225
+ });
2449
3226
  controlApp.get("/init/state", (req, res) => {
2450
3227
  try {
2451
3228
  const state = this.initStateSnapshot();
@@ -2829,6 +3606,452 @@ export class TokenbuddyDaemon {
2829
3606
  // tb-ui v1: 控制平面写端点(PR-0)
2830
3607
  // 所有端点都热生效,不需重启 daemon。改完 store 即下次推理生效。
2831
3608
  // ─────────────────────────────────────────────────────────────────
3609
+ controlApp.get("/routing/provider-mode", (req, res) => {
3610
+ try {
3611
+ res.status(200).json(this.providerModePayload());
3612
+ }
3613
+ catch (error) {
3614
+ const errorMessage = error instanceof Error ? error.message : String(error);
3615
+ logger.warn("routing.provider_mode.read_failed", "provider mode read failed", { errorMessage });
3616
+ res.status(500).json({ error: { code: "provider_mode_read_failed", message: errorMessage } });
3617
+ }
3618
+ });
3619
+ controlApp.put("/routing/provider-mode", (req, res) => {
3620
+ try {
3621
+ const body = (req.body ?? {});
3622
+ const requested = normalizeProviderModeConfig({
3623
+ mode: body.mode,
3624
+ updatedAt: new Date().toISOString()
3625
+ });
3626
+ const payment = this.providerPaymentState();
3627
+ if (requested.mode === "auto" && !payment.paymentReady) {
3628
+ res.status(409).json({
3629
+ error: {
3630
+ code: "payment_required",
3631
+ message: "bind a payment method to enable auto provider"
3632
+ },
3633
+ paymentReady: false,
3634
+ paymentRequired: true,
3635
+ bindTarget: "/overview?bind=clawtip"
3636
+ });
3637
+ return;
3638
+ }
3639
+ const saved = this.saveProviderMode(requested.mode);
3640
+ let strategy;
3641
+ if (saved.mode === "auto") {
3642
+ const nextAuto = {
3643
+ ...this.currentAutoProviderConfig(),
3644
+ enabled: true
3645
+ };
3646
+ const shouldRoute = this.autoProviderCanRoute(nextAuto);
3647
+ this.saveAutoProviderConfig(shouldRoute ? nextAuto : { ...nextAuto, enabled: false });
3648
+ if (shouldRoute) {
3649
+ strategy = this.applyAutoProviderRoutingConfig(nextAuto);
3650
+ }
3651
+ }
3652
+ else {
3653
+ this.saveAutoProviderConfig({
3654
+ ...this.currentAutoProviderConfig(),
3655
+ enabled: false
3656
+ });
3657
+ }
3658
+ logger.info("routing.provider_mode.applied", "provider mode applied", {
3659
+ mode: saved.mode,
3660
+ paymentReady: payment.paymentReady
3661
+ });
3662
+ res.status(200).json({
3663
+ applied: true,
3664
+ strategy,
3665
+ ...this.providerModePayload()
3666
+ });
3667
+ }
3668
+ catch (error) {
3669
+ const errorMessage = error instanceof Error ? error.message : String(error);
3670
+ logger.warn("routing.provider_mode.apply_failed", "provider mode apply failed", { errorMessage });
3671
+ res.status(400).json({ error: { code: "provider_mode_apply_failed", message: errorMessage } });
3672
+ }
3673
+ });
3674
+ controlApp.get("/routing/manual-providers", (req, res) => {
3675
+ try {
3676
+ const config = this.currentManualProviders();
3677
+ const observations = new Map(this.currentManualProviderObservations().observations.map((entry) => [entry.providerId, entry]));
3678
+ res.status(200).json({
3679
+ version: config.version,
3680
+ updatedAt: config.updatedAt,
3681
+ routing: config.routing,
3682
+ providers: config.providers.map((provider) => publicManualProviderConfig(provider, observations.get(provider.id)))
3683
+ });
3684
+ }
3685
+ catch (error) {
3686
+ const errorMessage = error instanceof Error ? error.message : String(error);
3687
+ logger.warn("routing.manual_providers.read_failed", "manual providers read failed", { errorMessage });
3688
+ res.status(500).json({ error: { code: "manual_providers_read_failed", message: errorMessage } });
3689
+ }
3690
+ });
3691
+ controlApp.post("/routing/manual-providers/probe", async (req, res) => {
3692
+ try {
3693
+ const body = (req.body ?? {});
3694
+ const baseUrl = typeof body.baseUrl === "string" ? body.baseUrl.trim() : "";
3695
+ const apiKey = typeof body.apiKey === "string" ? body.apiKey.trim() : "";
3696
+ const result = await this.probeManualProviderModels({ baseUrl, apiKey });
3697
+ logger.info("routing.manual_provider.probed", "manual provider model probe succeeded", {
3698
+ modelCount: result.modelIds.length,
3699
+ elapsedMs: result.elapsedMs
3700
+ });
3701
+ res.status(200).json({
3702
+ ok: true,
3703
+ modelIds: result.modelIds,
3704
+ elapsedMs: result.elapsedMs
3705
+ });
3706
+ }
3707
+ catch (error) {
3708
+ const errorMessage = error instanceof Error ? error.message : String(error);
3709
+ logger.warn("routing.manual_provider.probe_failed", "manual provider model probe failed", { errorMessage });
3710
+ res.status(400).json({ error: { code: "manual_provider_probe_failed", message: errorMessage } });
3711
+ }
3712
+ });
3713
+ controlApp.post("/routing/manual-providers/local", async (req, res) => {
3714
+ try {
3715
+ const body = (req.body ?? {});
3716
+ const rawApiKey = typeof body.apiKey === "string" ? body.apiKey.trim() : "";
3717
+ if (!rawApiKey) {
3718
+ res.status(400).json({ error: { code: "manual_provider_local_create_failed", message: "manual provider apiKey is required" } });
3719
+ return;
3720
+ }
3721
+ const baseUrl = typeof body.baseUrl === "string" ? body.baseUrl.trim() : "";
3722
+ const probe = await this.probeManualProviderModels({ baseUrl, apiKey: rawApiKey });
3723
+ const config = this.currentManualProviders();
3724
+ const existingIds = new Set(config.providers.map((provider) => provider.id));
3725
+ const rawId = typeof body.id === "string" && body.id.trim().length > 0 ? body.id : undefined;
3726
+ const generatedId = `manual-${crypto.randomUUID().slice(0, 8)}`;
3727
+ const providerId = rawId ?? generatedId;
3728
+ const secretRef = `local:${providerId}`;
3729
+ const providerInput = {
3730
+ ...body,
3731
+ apiKey: undefined,
3732
+ apiKeyEnv: undefined,
3733
+ secretRef,
3734
+ models: probe.modelIds,
3735
+ supportedProtocols: Array.isArray(body.supportedProtocols) ? body.supportedProtocols : ["chat_completions"],
3736
+ enabled: body.enabled === undefined ? true : body.enabled
3737
+ };
3738
+ delete providerInput.apiKey;
3739
+ const provider = normalizeManualProviderConfig(providerInput, {
3740
+ id: providerId,
3741
+ existingIds
3742
+ });
3743
+ const nextConfig = {
3744
+ version: 1,
3745
+ providers: [...config.providers, provider],
3746
+ routing: config.routing,
3747
+ updatedAt: new Date().toISOString()
3748
+ };
3749
+ this.saveManualProviderSecret(secretRef, rawApiKey);
3750
+ this.saveManualProviders(nextConfig);
3751
+ logger.info("routing.manual_provider.local_created", "local manual provider created", {
3752
+ providerId: provider.id,
3753
+ modelCount: provider.models.length,
3754
+ enabled: provider.enabled,
3755
+ keyRefKind: "secret"
3756
+ });
3757
+ res.status(201).json({
3758
+ provider: publicManualProviderConfig(provider),
3759
+ routing: nextConfig.routing,
3760
+ providers: nextConfig.providers.map((entry) => publicManualProviderConfig(entry))
3761
+ });
3762
+ }
3763
+ catch (error) {
3764
+ const errorMessage = error instanceof Error ? error.message : String(error);
3765
+ logger.warn("routing.manual_provider.local_create_failed", "local manual provider create failed", { errorMessage });
3766
+ res.status(400).json({ error: { code: "manual_provider_local_create_failed", message: errorMessage } });
3767
+ }
3768
+ });
3769
+ controlApp.post("/routing/manual-providers", (req, res) => {
3770
+ try {
3771
+ const config = this.currentManualProviders();
3772
+ const existingIds = new Set(config.providers.map((provider) => provider.id));
3773
+ const rawId = req.body?.id;
3774
+ const generatedId = `manual-${crypto.randomUUID().slice(0, 8)}`;
3775
+ const provider = normalizeManualProviderConfig(req.body, {
3776
+ id: typeof rawId === "string" && rawId.trim().length > 0 ? rawId : generatedId,
3777
+ existingIds
3778
+ });
3779
+ const nextConfig = {
3780
+ version: 1,
3781
+ providers: [...config.providers, provider],
3782
+ routing: config.routing,
3783
+ updatedAt: new Date().toISOString()
3784
+ };
3785
+ this.saveManualProviders(nextConfig);
3786
+ logger.info("routing.manual_provider.created", "manual provider created", {
3787
+ providerId: provider.id,
3788
+ modelCount: provider.models.length,
3789
+ enabled: provider.enabled,
3790
+ keyRefKind: provider.apiKeyEnv ? "env" : "secret"
3791
+ });
3792
+ res.status(201).json({
3793
+ provider: publicManualProviderConfig(provider),
3794
+ routing: nextConfig.routing,
3795
+ providers: nextConfig.providers.map((entry) => publicManualProviderConfig(entry))
3796
+ });
3797
+ }
3798
+ catch (error) {
3799
+ const errorMessage = error instanceof Error ? error.message : String(error);
3800
+ logger.warn("routing.manual_provider.create_failed", "manual provider create failed", { errorMessage });
3801
+ res.status(400).json({ error: { code: "manual_provider_create_failed", message: errorMessage } });
3802
+ }
3803
+ });
3804
+ controlApp.put("/routing/manual-providers/local/:id", async (req, res) => {
3805
+ try {
3806
+ const providerId = req.params.id;
3807
+ const body = (req.body ?? {});
3808
+ const config = this.currentManualProviders();
3809
+ const provider = config.providers.find((entry) => entry.id === providerId);
3810
+ if (!provider) {
3811
+ res.status(404).json({ error: { code: "manual_provider_not_found", message: `manual provider not found: ${providerId}` } });
3812
+ return;
3813
+ }
3814
+ const name = typeof body.name === "string" && body.name.trim().length > 0 ? body.name.trim() : provider.name;
3815
+ const baseUrl = typeof body.baseUrl === "string" && body.baseUrl.trim().length > 0 ? body.baseUrl.trim() : provider.baseUrl;
3816
+ const rawApiKey = typeof body.apiKey === "string" ? body.apiKey.trim() : "";
3817
+ const apiKey = rawApiKey || this.manualProviderApiKey(provider);
3818
+ const probe = await this.probeManualProviderModels({ baseUrl, apiKey });
3819
+ const secretRef = provider.secretRef ?? `local:${provider.id}`;
3820
+ const existingIds = new Set(config.providers.map((entry) => entry.id).filter((id) => id !== provider.id));
3821
+ const updatedProvider = normalizeManualProviderConfig({
3822
+ ...provider,
3823
+ name,
3824
+ baseUrl,
3825
+ secretRef,
3826
+ models: probe.modelIds,
3827
+ supportedProtocols: provider.supportedProtocols.length > 0 ? provider.supportedProtocols : ["chat_completions"],
3828
+ enabled: body.enabled === undefined ? provider.enabled : body.enabled,
3829
+ updatedAt: new Date().toISOString()
3830
+ }, {
3831
+ id: provider.id,
3832
+ existingIds
3833
+ });
3834
+ const nextConfig = {
3835
+ version: 1,
3836
+ providers: config.providers.map((entry) => entry.id === provider.id ? updatedProvider : entry),
3837
+ routing: config.routing,
3838
+ updatedAt: new Date().toISOString()
3839
+ };
3840
+ if (rawApiKey || !provider.secretRef) {
3841
+ this.saveManualProviderSecret(secretRef, apiKey);
3842
+ }
3843
+ this.saveManualProviders(nextConfig);
3844
+ logger.info("routing.manual_provider.local_updated", "local manual provider updated", {
3845
+ providerId: updatedProvider.id,
3846
+ modelCount: updatedProvider.models.length,
3847
+ enabled: updatedProvider.enabled,
3848
+ keyRefKind: "secret"
3849
+ });
3850
+ res.status(200).json({
3851
+ provider: publicManualProviderConfig(updatedProvider),
3852
+ routing: nextConfig.routing,
3853
+ providers: nextConfig.providers.map((entry) => publicManualProviderConfig(entry))
3854
+ });
3855
+ }
3856
+ catch (error) {
3857
+ const errorMessage = error instanceof Error ? error.message : String(error);
3858
+ logger.warn("routing.manual_provider.local_update_failed", "local manual provider update failed", { errorMessage });
3859
+ res.status(400).json({ error: { code: "manual_provider_local_update_failed", message: errorMessage } });
3860
+ }
3861
+ });
3862
+ controlApp.delete("/routing/manual-providers/:id", (req, res) => {
3863
+ try {
3864
+ const providerId = req.params.id;
3865
+ const config = this.currentManualProviders();
3866
+ const nextProviders = config.providers.filter((provider) => provider.id !== providerId);
3867
+ if (nextProviders.length === config.providers.length) {
3868
+ res.status(404).json({ error: { code: "manual_provider_not_found", message: `manual provider not found: ${providerId}` } });
3869
+ return;
3870
+ }
3871
+ const nextConfig = {
3872
+ version: 1,
3873
+ providers: nextProviders,
3874
+ routing: config.routing.policy === "locked" && config.routing.lockedProviderId === providerId
3875
+ ? { policy: "fallback" }
3876
+ : config.routing,
3877
+ updatedAt: new Date().toISOString()
3878
+ };
3879
+ const removed = config.providers.find((provider) => provider.id === providerId);
3880
+ this.removeManualProviderSecret(removed?.secretRef);
3881
+ this.saveManualProviders(nextConfig);
3882
+ logger.info("routing.manual_provider.deleted", "manual provider deleted", { providerId });
3883
+ res.status(200).json({
3884
+ deleted: true,
3885
+ providerId,
3886
+ routing: nextConfig.routing,
3887
+ providers: nextProviders.map((provider) => publicManualProviderConfig(provider))
3888
+ });
3889
+ }
3890
+ catch (error) {
3891
+ const errorMessage = error instanceof Error ? error.message : String(error);
3892
+ logger.warn("routing.manual_provider.delete_failed", "manual provider delete failed", { errorMessage });
3893
+ res.status(400).json({ error: { code: "manual_provider_delete_failed", message: errorMessage } });
3894
+ }
3895
+ });
3896
+ controlApp.put("/routing/manual-providers/order", (req, res) => {
3897
+ try {
3898
+ const body = (req.body ?? {});
3899
+ const providerIds = Array.isArray(body.providerIds)
3900
+ ? body.providerIds.map((entry) => typeof entry === "string" ? entry.trim() : "")
3901
+ : [];
3902
+ const config = this.currentManualProviders();
3903
+ const currentIds = new Set(config.providers.map((provider) => provider.id));
3904
+ const requestedIds = new Set(providerIds);
3905
+ if (providerIds.length !== config.providers.length ||
3906
+ requestedIds.size !== providerIds.length ||
3907
+ requestedIds.size !== currentIds.size ||
3908
+ providerIds.some((providerId) => !currentIds.has(providerId))) {
3909
+ res.status(400).json({
3910
+ error: {
3911
+ code: "manual_provider_order_invalid",
3912
+ message: "manual provider order must include each configured provider exactly once"
3913
+ }
3914
+ });
3915
+ return;
3916
+ }
3917
+ const providersById = new Map(config.providers.map((provider) => [provider.id, provider]));
3918
+ const nextConfig = {
3919
+ version: 1,
3920
+ providers: providerIds.map((providerId) => providersById.get(providerId)).filter((provider) => Boolean(provider)),
3921
+ routing: config.routing,
3922
+ updatedAt: new Date().toISOString()
3923
+ };
3924
+ this.saveManualProviders(nextConfig);
3925
+ logger.info("routing.manual_provider.order_applied", "manual provider order applied", {
3926
+ providerIds
3927
+ });
3928
+ const observations = new Map(this.currentManualProviderObservations().observations.map((entry) => [entry.providerId, entry]));
3929
+ res.status(200).json({
3930
+ version: nextConfig.version,
3931
+ updatedAt: nextConfig.updatedAt,
3932
+ routing: nextConfig.routing,
3933
+ providers: nextConfig.providers.map((provider) => publicManualProviderConfig(provider, observations.get(provider.id)))
3934
+ });
3935
+ }
3936
+ catch (error) {
3937
+ const errorMessage = error instanceof Error ? error.message : String(error);
3938
+ logger.warn("routing.manual_provider.order_apply_failed", "manual provider order apply failed", { errorMessage });
3939
+ res.status(400).json({ error: { code: "manual_provider_order_apply_failed", message: errorMessage } });
3940
+ }
3941
+ });
3942
+ controlApp.put("/routing/manual-providers/routing", (req, res) => {
3943
+ try {
3944
+ const body = (req.body ?? {});
3945
+ const policy = body.policy;
3946
+ const lockedProviderId = typeof body.lockedProviderId === "string" ? body.lockedProviderId.trim() : undefined;
3947
+ const config = this.currentManualProviders();
3948
+ const routing = policy === "locked"
3949
+ ? { policy: "locked", lockedProviderId }
3950
+ : { policy: "fallback" };
3951
+ const normalized = normalizeManualProvidersConfig({
3952
+ ...config,
3953
+ routing
3954
+ }).routing;
3955
+ if (normalized.policy === "locked") {
3956
+ const provider = config.providers.find((entry) => entry.id === normalized.lockedProviderId);
3957
+ if (!provider) {
3958
+ res.status(404).json({ error: { code: "manual_provider_not_found", message: `manual provider not found: ${normalized.lockedProviderId}` } });
3959
+ return;
3960
+ }
3961
+ if (!provider.enabled) {
3962
+ res.status(400).json({ error: { code: "manual_provider_locked_disabled", message: `manual provider is disabled: ${provider.id}` } });
3963
+ return;
3964
+ }
3965
+ }
3966
+ const nextConfig = {
3967
+ version: 1,
3968
+ providers: config.providers,
3969
+ routing: normalized,
3970
+ updatedAt: new Date().toISOString()
3971
+ };
3972
+ this.saveManualProviders(nextConfig);
3973
+ logger.info("routing.manual_provider.routing_applied", "manual provider routing applied", {
3974
+ policy: normalized.policy,
3975
+ lockedProviderId: normalized.lockedProviderId
3976
+ });
3977
+ const observations = new Map(this.currentManualProviderObservations().observations.map((entry) => [entry.providerId, entry]));
3978
+ res.status(200).json({
3979
+ version: nextConfig.version,
3980
+ updatedAt: nextConfig.updatedAt,
3981
+ routing: nextConfig.routing,
3982
+ providers: nextConfig.providers.map((provider) => publicManualProviderConfig(provider, observations.get(provider.id)))
3983
+ });
3984
+ }
3985
+ catch (error) {
3986
+ const errorMessage = error instanceof Error ? error.message : String(error);
3987
+ logger.warn("routing.manual_provider.routing_apply_failed", "manual provider routing apply failed", { errorMessage });
3988
+ res.status(400).json({ error: { code: "manual_provider_routing_apply_failed", message: errorMessage } });
3989
+ }
3990
+ });
3991
+ controlApp.get("/routing/auto-provider", (req, res) => {
3992
+ try {
3993
+ const config = this.currentAutoProviderConfig();
3994
+ const payment = this.providerPaymentState();
3995
+ const mode = this.currentProviderMode();
3996
+ res.status(200).json({
3997
+ config,
3998
+ active: mode.mode === "auto" && this.autoProviderCanRoute(config) && payment.paymentReady,
3999
+ locked: !payment.paymentReady,
4000
+ paymentReady: payment.paymentReady,
4001
+ paymentRequired: !payment.paymentReady,
4002
+ paymentLabel: payment.paymentLabel
4003
+ });
4004
+ }
4005
+ catch (error) {
4006
+ const errorMessage = error instanceof Error ? error.message : String(error);
4007
+ logger.warn("routing.auto_provider.read_failed", "auto provider read failed", { errorMessage });
4008
+ res.status(500).json({ error: { code: "auto_provider_read_failed", message: errorMessage } });
4009
+ }
4010
+ });
4011
+ controlApp.put("/routing/auto-provider", (req, res) => {
4012
+ try {
4013
+ const next = normalizeAutoProviderConfig(req.body);
4014
+ const payment = this.providerPaymentState();
4015
+ if (next.enabled && !payment.paymentReady) {
4016
+ res.status(409).json({
4017
+ error: {
4018
+ code: "payment_required",
4019
+ message: "bind a payment method to enable auto provider"
4020
+ },
4021
+ paymentReady: false,
4022
+ paymentRequired: true,
4023
+ bindTarget: "/overview?bind=clawtip"
4024
+ });
4025
+ return;
4026
+ }
4027
+ const shouldRoute = this.autoProviderCanRoute(next);
4028
+ this.saveAutoProviderConfig(shouldRoute ? next : { ...next, enabled: false });
4029
+ let strategy;
4030
+ if (shouldRoute) {
4031
+ this.saveProviderMode("auto");
4032
+ strategy = this.applyAutoProviderRoutingConfig(next);
4033
+ }
4034
+ logger.info("routing.auto_provider.applied", "auto provider applied", {
4035
+ enabled: shouldRoute,
4036
+ range: next.range,
4037
+ scorer: next.scorer,
4038
+ modelCount: next.modelIds.length,
4039
+ sellerCount: next.sellerIds.length,
4040
+ paymentReady: payment.paymentReady
4041
+ });
4042
+ res.status(200).json({
4043
+ applied: true,
4044
+ config: this.currentAutoProviderConfig(),
4045
+ strategy,
4046
+ ...this.providerModePayload()
4047
+ });
4048
+ }
4049
+ catch (error) {
4050
+ const errorMessage = error instanceof Error ? error.message : String(error);
4051
+ logger.warn("routing.auto_provider.apply_failed", "auto provider apply failed", { errorMessage });
4052
+ res.status(400).json({ error: { code: "auto_provider_apply_failed", message: errorMessage } });
4053
+ }
4054
+ });
2832
4055
  // 1) GET /routing/strategy — 读当前路由策略 + 来源
2833
4056
  controlApp.get("/routing/strategy", (req, res) => {
2834
4057
  try {
@@ -3214,6 +4437,12 @@ function withLiveClawtipWalletState(payment, home) {
3214
4437
  }
3215
4438
  };
3216
4439
  }
4440
+ function paymentLabel(method) {
4441
+ if (method === "clawtip") {
4442
+ return "ClawTip";
4443
+ }
4444
+ return method;
4445
+ }
3217
4446
  function normalizeClawtipActivationPayment(bootstrap) {
3218
4447
  if (!bootstrap.payment?.orderNo || !bootstrap.payment.indicator || !bootstrap.payment.resourceUrl) {
3219
4448
  throw new Error("ClawTip bootstrap response missing orderNo, indicator, or resourceUrl.");
@@ -3247,6 +4476,9 @@ function readConfigString(config, key) {
3247
4476
  function readConfigBoolean(config, key) {
3248
4477
  return config?.[key] === true;
3249
4478
  }
4479
+ function normalizePaymentMethodName(method) {
4480
+ return method.trim().toLowerCase();
4481
+ }
3250
4482
  function selectedSellerIdForRouting(routing) {
3251
4483
  return routing.mode === "fixed" ? routing.sellerId : undefined;
3252
4484
  }
@@ -3311,6 +4543,24 @@ function catalogSnapshotFromRegistry(registry) {
3311
4543
  }))
3312
4544
  };
3313
4545
  }
4546
+ function parseOpenAiModelIds(value) {
4547
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
4548
+ return [];
4549
+ }
4550
+ const data = value.data;
4551
+ if (!Array.isArray(data)) {
4552
+ return [];
4553
+ }
4554
+ return data
4555
+ .flatMap((entry) => {
4556
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
4557
+ return [];
4558
+ }
4559
+ const id = entry.id;
4560
+ return typeof id === "string" && id.trim().length > 0 ? [id.trim()] : [];
4561
+ })
4562
+ .filter((id, index, all) => all.indexOf(id) === index);
4563
+ }
3314
4564
  function normalizeTrustedRegistryCache(value) {
3315
4565
  if (!value || typeof value !== "object") {
3316
4566
  return undefined;