@tokenbuddy/tokenbuddy 1.0.14 → 1.0.16

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 (79) hide show
  1. package/dist/src/buyer-store.d.ts +23 -0
  2. package/dist/src/buyer-store.d.ts.map +1 -1
  3. package/dist/src/buyer-store.js +31 -6
  4. package/dist/src/buyer-store.js.map +1 -1
  5. package/dist/src/clawtip-bootstrap.d.ts +23 -0
  6. package/dist/src/clawtip-bootstrap.d.ts.map +1 -0
  7. package/dist/src/clawtip-bootstrap.js +47 -0
  8. package/dist/src/clawtip-bootstrap.js.map +1 -0
  9. package/dist/src/cli.d.ts +24 -33
  10. package/dist/src/cli.d.ts.map +1 -1
  11. package/dist/src/cli.js +157 -58
  12. package/dist/src/cli.js.map +1 -1
  13. package/dist/src/daemon.d.ts +79 -1
  14. package/dist/src/daemon.d.ts.map +1 -1
  15. package/dist/src/daemon.js +1007 -23
  16. package/dist/src/daemon.js.map +1 -1
  17. package/dist/src/model-index.d.ts +1 -1
  18. package/dist/src/model-index.d.ts.map +1 -1
  19. package/dist/src/model-index.js +4 -0
  20. package/dist/src/model-index.js.map +1 -1
  21. package/dist/src/prewarm-cache.d.ts +4 -0
  22. package/dist/src/prewarm-cache.d.ts.map +1 -1
  23. package/dist/src/prewarm-cache.js +2 -1
  24. package/dist/src/prewarm-cache.js.map +1 -1
  25. package/dist/src/prewarm-scheduler.d.ts +2 -0
  26. package/dist/src/prewarm-scheduler.d.ts.map +1 -1
  27. package/dist/src/prewarm-scheduler.js +4 -2
  28. package/dist/src/prewarm-scheduler.js.map +1 -1
  29. package/dist/src/route-failover.d.ts.map +1 -1
  30. package/dist/src/route-failover.js +10 -0
  31. package/dist/src/route-failover.js.map +1 -1
  32. package/dist/src/seller-catalog.d.ts +17 -0
  33. package/dist/src/seller-catalog.d.ts.map +1 -1
  34. package/dist/src/seller-catalog.js +15 -1
  35. package/dist/src/seller-catalog.js.map +1 -1
  36. package/dist/src/seller-pool.d.ts +12 -1
  37. package/dist/src/seller-pool.d.ts.map +1 -1
  38. package/dist/src/seller-pool.js +61 -7
  39. package/dist/src/seller-pool.js.map +1 -1
  40. package/dist/src/seller-route-planner.d.ts +11 -1
  41. package/dist/src/seller-route-planner.d.ts.map +1 -1
  42. package/dist/src/seller-route-planner.js +21 -9
  43. package/dist/src/seller-route-planner.js.map +1 -1
  44. package/dist/src/seller-routing-config.d.ts +2 -0
  45. package/dist/src/seller-routing-config.d.ts.map +1 -1
  46. package/dist/src/seller-routing-config.js +11 -1
  47. package/dist/src/seller-routing-config.js.map +1 -1
  48. package/package.json +1 -1
  49. package/src/buyer-store.ts +70 -7
  50. package/src/clawtip-bootstrap.ts +64 -0
  51. package/src/cli.ts +201 -76
  52. package/src/daemon.ts +1159 -25
  53. package/src/model-index.ts +4 -1
  54. package/src/prewarm-cache.ts +6 -1
  55. package/src/prewarm-scheduler.ts +6 -2
  56. package/src/route-failover.ts +11 -0
  57. package/src/seller-catalog.ts +24 -1
  58. package/src/seller-pool.ts +69 -7
  59. package/src/seller-route-planner.ts +33 -11
  60. package/src/seller-routing-config.ts +14 -1
  61. package/static/clawtip/recharge.png +0 -0
  62. package/static/ui/assets/index-UMiTTeo8.css +1 -0
  63. package/static/ui/assets/index-YHs-Ca0f.js +206 -0
  64. package/static/ui/assets/index-YHs-Ca0f.js.map +1 -0
  65. package/static/ui/icons/apple-touch-icon.png +0 -0
  66. package/static/ui/icons/tokenbuddy-192.png +0 -0
  67. package/static/ui/icons/tokenbuddy-512.png +0 -0
  68. package/static/ui/index.html +21 -0
  69. package/static/ui/manifest.webmanifest +28 -0
  70. package/static/ui/sw.js +59 -0
  71. package/tests/control-plane-ui-endpoints.test.ts +589 -0
  72. package/tests/daemon-classify.test.ts +9 -0
  73. package/tests/model-index.test.ts +14 -0
  74. package/tests/route-failover.test.ts +16 -0
  75. package/tests/seller-catalog-utilities.test.ts +54 -0
  76. package/tests/seller-pool.test.ts +56 -0
  77. package/tests/seller-route-planner.test.ts +40 -0
  78. package/tests/seller-routing-config.test.ts +13 -0
  79. package/tests/tokenbuddy.test.ts +200 -7
@@ -2,20 +2,101 @@ import express from "express";
2
2
  import * as crypto from "crypto";
3
3
  import { spawn } from "child_process";
4
4
  import * as fs from "fs";
5
+ import * as path from "path";
6
+ import { ErrorCode } from "@tokenbuddy/contracts";
5
7
  import { createModuleLogger } from "@tokenbuddy/logging";
6
8
  import { BuyerStore } from "./buyer-store.js";
9
+ import { fetchClawtipBootstrap } from "./clawtip-bootstrap.js";
10
+ import { inspectOpenClawWalletConfig, } from "./init-payment-options.js";
11
+ import { startClawtipWalletBootstrap, waitForClawtipActivationConfirmation, } from "./init-clawtip-activation.js";
7
12
  import { applyProviderInstall, detectProviders, previewProviderInstall, rollbackProviderInstall, } from "./provider-install.js";
8
13
  import { discoverSellerBackedModels, fetchSellerRegistry, normalizeSellerUrl, RegistryTooLargeError, } from "./seller-catalog.js";
9
14
  import { ModelIndex } from "./model-index.js";
10
- import { PrewarmCache } from "./prewarm-cache.js";
15
+ import { PrewarmCache, prewarmKey } from "./prewarm-cache.js";
11
16
  import { CreditTracker } from "./credit-tracker.js";
12
17
  import { SellerPool } from "./seller-pool.js";
13
18
  import { RouteFailover } from "./route-failover.js";
14
19
  import { PrewarmScheduler } from "./prewarm-scheduler.js";
15
20
  import { planSellerRouteSet } from "./seller-route-planner.js";
16
- import { mergeSellerRoutingConfig, ROUTING_CONFIG_KEY } from "./seller-routing-config.js";
21
+ import { assertSellerRoutingConfig, mergeSellerRoutingConfig, normalizeSellerRoutingConfig, parseSellerIdList, ROUTING_CONFIG_KEY } from "./seller-routing-config.js";
17
22
  const logger = createModuleLogger("tb-proxyd");
23
+ const FOCUS_SET_CONFIG_KEY = "focus-set";
18
24
  const PROXY_JSON_BODY_LIMIT = "10mb";
25
+ const SELLER_CAPACITY_BLOCK_MS = 2_000;
26
+ const CLAWTIP_STATIC_ROUTE = "/static/clawtip";
27
+ const CLAWTIP_RECHARGE_QR_FILE = "recharge.png";
28
+ function currentModuleDir() {
29
+ if (typeof __dirname !== "undefined") {
30
+ return __dirname;
31
+ }
32
+ const stack = new Error().stack || "";
33
+ const fileUrlMatch = stack.match(/(file:\/\/\/[^)\n]+\/daemon\.js):\d+:\d+/);
34
+ if (fileUrlMatch) {
35
+ return path.dirname(new URL(fileUrlMatch[1]).pathname);
36
+ }
37
+ const filePathMatch = stack.match(/(\/[^)\n]+\/daemon\.(?:js|ts)):\d+:\d+/);
38
+ if (filePathMatch) {
39
+ return path.dirname(filePathMatch[1]);
40
+ }
41
+ return process.cwd();
42
+ }
43
+ function clientToolStatusFromProvider(provider) {
44
+ return {
45
+ id: provider.id,
46
+ name: provider.name,
47
+ status: provider.status,
48
+ detected: provider.detected,
49
+ configured: provider.configured,
50
+ configPath: provider.configPath,
51
+ commandName: provider.commandName,
52
+ reason: provider.reason,
53
+ };
54
+ }
55
+ function buildCustomClientToolStatus(proxyPort) {
56
+ const openaiBaseUrl = `http://127.0.0.1:${proxyPort}/v1`;
57
+ const anthropicBaseUrl = `http://127.0.0.1:${proxyPort}`;
58
+ return {
59
+ id: "custom",
60
+ name: "Custom client",
61
+ status: "manual",
62
+ detected: true,
63
+ configured: false,
64
+ reason: `OpenAI-compatible: ${openaiBaseUrl} · Anthropic-compatible: ${anthropicBaseUrl}`,
65
+ manualConfig: {
66
+ openaiBaseUrl,
67
+ anthropicBaseUrl,
68
+ apiKey: "TOKENBUDDY_PROXY",
69
+ },
70
+ };
71
+ }
72
+ /**
73
+ * 解析 `tb-ui` 构建产物目录(daemon 静态托管 SPA 用)。
74
+ * 优先级:env TB_UI_DIR > npm 包内 static/ui > workspace tb-ui/dist。
75
+ * 找不到时记录 warning 仍允许 daemon 启动(纯 API 模式);静态请求会 404。
76
+ */
77
+ function resolveUiDir() {
78
+ if (process.env.TB_UI_DIR)
79
+ return process.env.TB_UI_DIR;
80
+ const here = currentModuleDir();
81
+ const bundledUiDirs = [
82
+ path.resolve(here, "../../static/ui"),
83
+ path.resolve(here, "../static/ui")
84
+ ];
85
+ for (const bundledUiDir of bundledUiDirs) {
86
+ if (fs.existsSync(path.join(bundledUiDir, "index.html"))) {
87
+ return bundledUiDir;
88
+ }
89
+ }
90
+ try {
91
+ // require.resolve 在 npm workspaces 装好时能找到 tb-ui/package.json
92
+ const pkgPath = require.resolve("tb-ui/package.json");
93
+ return path.join(path.dirname(pkgPath), "dist");
94
+ }
95
+ catch {
96
+ // fallback: monorepo 假设 daemon dist 跟 tb-ui/dist 在同 root
97
+ return path.resolve(here, "../../tb-ui/dist");
98
+ }
99
+ }
19
100
  function numericHeaderField(value) {
20
101
  if (typeof value === "number" && Number.isFinite(value)) {
21
102
  return value;
@@ -123,6 +204,55 @@ function summarizeProxyBody(body) {
123
204
  temperaturePresent: data.temperature !== undefined
124
205
  };
125
206
  }
207
+ function finiteNumber(value) {
208
+ if (typeof value === "number" && Number.isFinite(value)) {
209
+ return value;
210
+ }
211
+ if (typeof value === "string" && value.trim().length > 0) {
212
+ const parsed = Number(value);
213
+ return Number.isFinite(parsed) ? parsed : undefined;
214
+ }
215
+ return undefined;
216
+ }
217
+ function readErrorCode(bodyText) {
218
+ try {
219
+ const parsed = JSON.parse(bodyText);
220
+ const code = parsed.error?.code ?? parsed.code;
221
+ return typeof code === "string" ? code : undefined;
222
+ }
223
+ catch {
224
+ return undefined;
225
+ }
226
+ }
227
+ function isBusyCapacityErrorBody(bodyText) {
228
+ if (!bodyText) {
229
+ return false;
230
+ }
231
+ return readErrorCode(bodyText) === ErrorCode.BusyCapacity;
232
+ }
233
+ function capacityBlockedUntilFromHealth(body, now) {
234
+ const capacity = body.capacity;
235
+ if (!capacity) {
236
+ return undefined;
237
+ }
238
+ const activeConnections = finiteNumber(capacity.activeConnections ?? capacity.active_connections);
239
+ const maxConnections = finiteNumber(capacity.maxConnections ?? capacity.max_connections);
240
+ const queueDepth = finiteNumber(capacity.queueDepth ?? capacity.queue_depth);
241
+ const maxQueueDepth = finiteNumber(capacity.maxQueueDepth ?? capacity.max_queue_depth);
242
+ if (activeConnections === undefined ||
243
+ maxConnections === undefined ||
244
+ queueDepth === undefined ||
245
+ maxQueueDepth === undefined) {
246
+ return undefined;
247
+ }
248
+ if (maxConnections <= 0) {
249
+ return undefined;
250
+ }
251
+ const connectionsFull = activeConnections >= maxConnections;
252
+ const queueUnavailable = maxQueueDepth <= 0;
253
+ const queueFull = queueUnavailable || queueDepth >= maxQueueDepth;
254
+ return connectionsFull && queueFull ? now + SELLER_CAPACITY_BLOCK_MS : undefined;
255
+ }
126
256
  function reorderDefaultSellerFirst(sellers, defaultSellerId) {
127
257
  if (!defaultSellerId) {
128
258
  return sellers;
@@ -146,6 +276,15 @@ export class TokenbuddyDaemon {
146
276
  selectionMode;
147
277
  selectedSellerId;
148
278
  sellerRouting;
279
+ lastRoutingPrewarmKey;
280
+ lazyPrewarmKeys = new Set();
281
+ clawtipActivationWait;
282
+ clawtipActivationWaitCancelToken;
283
+ /**
284
+ * tb-ui v1 控制平面 `PUT /prewarm/focus-set` 写入的 explicit focus set。
285
+ * 优先级最高;`null` 表示回退到 env / historical(与 `resolveFocusSet()` 原行为一致)。
286
+ */
287
+ currentFocusSet = null;
149
288
  activePurchases = new Map();
150
289
  // v1.2 fallback pipeline: model-index, prewarm-cache, credit-tracker,
151
290
  // pool, and route-failover together replace the v1
@@ -170,10 +309,17 @@ export class TokenbuddyDaemon {
170
309
  this.tokenStore = new BuyerStore({ dbPath: config.dbPath });
171
310
  const storedRouting = this.tokenStore.getDaemonRuntimeConfig(ROUTING_CONFIG_KEY)
172
311
  ?.config;
312
+ const storedFocusSet = this.tokenStore.getDaemonRuntimeConfig(FOCUS_SET_CONFIG_KEY)
313
+ ?.config;
173
314
  this.config = config;
174
315
  this.sellerRouting = mergeSellerRoutingConfig(storedRouting, config.sellerRouting);
175
- this.selectionMode = this.sellerRouting.mode === "fullAuto" ? "auto" : "manual";
176
- this.selectedSellerId = this.sellerRouting.mode === "fixed" ? this.sellerRouting.sellerId : undefined;
316
+ this.selectionMode = selectionModeForRouting(this.sellerRouting);
317
+ this.selectedSellerId = selectedSellerIdForRouting(this.sellerRouting);
318
+ // tb-ui v1: explicit focus set 优先于 env / historical
319
+ if (storedFocusSet && Array.isArray(storedFocusSet.models)) {
320
+ const deduped = Array.from(new Set(storedFocusSet.models.map((m) => m.trim()).filter(Boolean)));
321
+ this.currentFocusSet = deduped.length > 0 ? deduped : null;
322
+ }
177
323
  // v1.2 §18.5: scheduler is created here (not in the field initializer)
178
324
  // because it needs the config-derived prober + idle interval.
179
325
  Object.assign(this, {
@@ -204,7 +350,22 @@ export class TokenbuddyDaemon {
204
350
  if (!res.ok) {
205
351
  return { ok: false, latencyMs: Date.now() - startedAt, httpStatus: res.status, errorMessage: `health returned ${res.status}` };
206
352
  }
207
- return { ok: true, latencyMs: Date.now() - startedAt, httpStatus: res.status };
353
+ const now = Date.now();
354
+ const body = await res.json();
355
+ const upstream = body.upstream;
356
+ const upstreamErrorClass = upstream?.lastErrorClass ?? upstream?.last_error_class;
357
+ return {
358
+ ok: true,
359
+ latencyMs: now - startedAt,
360
+ httpStatus: res.status,
361
+ upstreamStatus: typeof upstream?.status === "string"
362
+ ? upstream.status
363
+ : undefined,
364
+ upstreamErrorClass: typeof upstreamErrorClass === "string"
365
+ ? upstreamErrorClass
366
+ : undefined,
367
+ capacityBlockedUntil: capacityBlockedUntilFromHealth(body, now)
368
+ };
208
369
  }
209
370
  catch (err) {
210
371
  const message = err instanceof Error ? err.message : String(err);
@@ -220,6 +381,193 @@ export class TokenbuddyDaemon {
220
381
  const address = this.proxyServer?.address?.();
221
382
  return typeof address === "object" && address ? address.port : this.config.proxyPort;
222
383
  }
384
+ clawtipStaticDir() {
385
+ return path.join(path.dirname(this.config.dbPath), "static", "clawtip");
386
+ }
387
+ bundledClawtipStaticDir() {
388
+ if (this.config.clawtipBundledStaticDir === false) {
389
+ return undefined;
390
+ }
391
+ if (typeof this.config.clawtipBundledStaticDir === "string") {
392
+ return fs.existsSync(this.config.clawtipBundledStaticDir) ? this.config.clawtipBundledStaticDir : undefined;
393
+ }
394
+ const here = currentModuleDir();
395
+ const candidates = [
396
+ path.resolve(here, "../static/clawtip"),
397
+ path.resolve(here, "../../static/clawtip"),
398
+ path.resolve(process.cwd(), "packages/tokenbuddy-cli/static/clawtip")
399
+ ];
400
+ return candidates.find((candidate) => fs.existsSync(candidate));
401
+ }
402
+ clawtipPublicUrl(fileName) {
403
+ return `${CLAWTIP_STATIC_ROUTE}/${encodeURIComponent(fileName)}`;
404
+ }
405
+ ensureClawtipStaticAssets() {
406
+ const outputDir = this.clawtipStaticDir();
407
+ fs.mkdirSync(outputDir, { recursive: true });
408
+ const rechargeOutputPath = path.join(outputDir, CLAWTIP_RECHARGE_QR_FILE);
409
+ if (fs.existsSync(rechargeOutputPath)) {
410
+ return;
411
+ }
412
+ const bundledDir = this.bundledClawtipStaticDir();
413
+ const rechargeSourcePath = bundledDir ? path.join(bundledDir, CLAWTIP_RECHARGE_QR_FILE) : undefined;
414
+ if (rechargeSourcePath && fs.existsSync(rechargeSourcePath)) {
415
+ fs.copyFileSync(rechargeSourcePath, rechargeOutputPath);
416
+ }
417
+ }
418
+ copyClawtipQrToStatic(mediaPath, orderNo) {
419
+ if (!fs.existsSync(mediaPath)) {
420
+ throw new Error(`ClawTip QR image does not exist: ${mediaPath}`);
421
+ }
422
+ const extension = safeQrExtension(mediaPath);
423
+ const fileName = `${safeStaticFileSegment(orderNo)}-${Date.now()}${extension}`;
424
+ const outputDir = this.clawtipStaticDir();
425
+ fs.mkdirSync(outputDir, { recursive: true });
426
+ const outputPath = path.join(outputDir, fileName);
427
+ fs.copyFileSync(mediaPath, outputPath);
428
+ return {
429
+ fileName,
430
+ path: outputPath,
431
+ url: this.clawtipPublicUrl(fileName)
432
+ };
433
+ }
434
+ async startClawtipActivationQr() {
435
+ const bootstrapUrl = process.env.TOKENBUDDY_BOOTSTRAP_URL || "https://tb-wallet-bootstrap.fly.dev";
436
+ const fetchBootstrap = this.config.clawtipBootstrapFetcher || fetchClawtipBootstrap;
437
+ const bootstrap = await fetchBootstrap(bootstrapUrl);
438
+ const payment = normalizeClawtipActivationPayment(bootstrap);
439
+ const startBootstrap = this.config.clawtipWalletBootstrapStarter || startClawtipWalletBootstrap;
440
+ const activation = await startBootstrap(payment, { home: this.config.clawtipHomeDir });
441
+ if (!activation.parsedOutput.mediaPath) {
442
+ throw new Error("ClawTip activation did not return a QR image.");
443
+ }
444
+ const staticQr = this.copyClawtipQrToStatic(activation.parsedOutput.mediaPath, payment.orderNo);
445
+ const walletConfig = inspectOpenClawWalletConfig(this.config.clawtipHomeDir);
446
+ const existingPayment = this.tokenStore.getPayment("clawtip");
447
+ this.tokenStore.savePayment({
448
+ method: "clawtip",
449
+ enabled: walletConfig.exists,
450
+ isDefault: existingPayment?.isDefault ?? true,
451
+ config: {
452
+ ...(existingPayment?.config ?? {}),
453
+ bootstrapUrl,
454
+ orderNo: payment.orderNo,
455
+ amountFen: payment.amountFen,
456
+ indicator: payment.indicator,
457
+ slug: payment.slug,
458
+ skillId: payment.skillId,
459
+ description: payment.description,
460
+ resourceUrl: payment.resourceUrl,
461
+ activationOrderFile: activation.orderFile,
462
+ walletConfigPath: walletConfig.expectedPath,
463
+ walletConfigPresent: walletConfig.exists,
464
+ activationQrImagePath: activation.parsedOutput.mediaPath,
465
+ activationQrImageUrl: staticQr.url,
466
+ authUrl: activation.parsedOutput.authUrl,
467
+ clawtipId: activation.parsedOutput.clawtipId,
468
+ payCredentialWritten: Boolean(activation.payCredential),
469
+ activationCompletedBy: activation.payCredential
470
+ ? (walletConfig.exists ? "payCredential+wallet-config" : "payCredential")
471
+ : walletConfig.exists ? "wallet-config" : "pending-wallet-scan"
472
+ }
473
+ });
474
+ this.scheduleClawtipActivationWait(activation.parsedOutput.clawtipId);
475
+ return {
476
+ ok: true,
477
+ kind: "activate",
478
+ method: "clawtip",
479
+ orderNo: payment.orderNo,
480
+ amountFen: payment.amountFen,
481
+ qrImageUrl: staticQr.url,
482
+ sourceImagePath: activation.parsedOutput.mediaPath,
483
+ staticImagePath: staticQr.path,
484
+ authUrl: activation.parsedOutput.authUrl,
485
+ clawtipId: activation.parsedOutput.clawtipId,
486
+ orderFile: activation.orderFile,
487
+ walletConfigPath: walletConfig.expectedPath,
488
+ walletConfigPresent: walletConfig.exists,
489
+ requiresWalletAuth: activation.parsedOutput.requiresWalletAuth,
490
+ payCredentialWritten: Boolean(activation.payCredential)
491
+ };
492
+ }
493
+ scheduleClawtipActivationWait(clawtipId) {
494
+ if (this.clawtipActivationWait) {
495
+ return;
496
+ }
497
+ const cancelToken = { cancelled: false };
498
+ this.clawtipActivationWaitCancelToken = cancelToken;
499
+ const waitForActivation = this.config.clawtipActivationWaiter || waitForClawtipActivationConfirmation;
500
+ this.clawtipActivationWait = waitForActivation({
501
+ clawtipId,
502
+ inspectWalletConfig: () => inspectOpenClawWalletConfig(this.config.clawtipHomeDir),
503
+ isCancelled: () => cancelToken.cancelled,
504
+ cancel: () => undefined
505
+ })
506
+ .then((walletRegistered) => {
507
+ if (cancelToken.cancelled) {
508
+ return;
509
+ }
510
+ if (!walletRegistered) {
511
+ logger.info("control.payment.clawtip.activation_wait.pending", "ClawTip activation wait ended before wallet registration");
512
+ return;
513
+ }
514
+ const walletConfig = inspectOpenClawWalletConfig(this.config.clawtipHomeDir);
515
+ const payment = this.tokenStore.getPayment("clawtip");
516
+ if (!payment || payment.method !== "clawtip") {
517
+ return;
518
+ }
519
+ this.tokenStore.savePayment({
520
+ ...payment,
521
+ enabled: walletConfig.exists,
522
+ config: {
523
+ ...(payment.config ?? {}),
524
+ walletConfigPath: walletConfig.expectedPath,
525
+ walletConfigPresent: walletConfig.exists,
526
+ activationCompletedBy: walletConfig.exists
527
+ ? "wallet-config"
528
+ : readConfigString(payment.config, "activationCompletedBy") ?? "pending-wallet-scan"
529
+ }
530
+ });
531
+ logger.info("control.payment.clawtip.activation_wait.completed", "ClawTip activation wait completed", {
532
+ walletRegistered,
533
+ walletConfigPresent: walletConfig.exists
534
+ });
535
+ })
536
+ .catch((error) => {
537
+ const errorMessage = error instanceof Error ? error.message : String(error);
538
+ logger.warn("control.payment.clawtip.activation_wait.failed", "ClawTip activation wait failed", { errorMessage });
539
+ })
540
+ .finally(() => {
541
+ if (this.clawtipActivationWaitCancelToken === cancelToken) {
542
+ this.clawtipActivationWaitCancelToken = undefined;
543
+ this.clawtipActivationWait = undefined;
544
+ }
545
+ });
546
+ }
547
+ clawtipRechargeQr() {
548
+ const payment = this.tokenStore.getPayment("clawtip");
549
+ const resourceUrl = readConfigString(payment?.config, "resourceUrl");
550
+ const orderNo = readConfigString(payment?.config, "orderNo") || "clawtip-recharge";
551
+ const mediaPath = path.join(this.clawtipStaticDir(), CLAWTIP_RECHARGE_QR_FILE);
552
+ if (!fs.existsSync(mediaPath)) {
553
+ throw new Error(`ClawTip fixed recharge QR image is missing: ${mediaPath}`);
554
+ }
555
+ const walletConfig = inspectOpenClawWalletConfig(this.config.clawtipHomeDir);
556
+ return {
557
+ ok: true,
558
+ kind: "recharge",
559
+ method: "clawtip",
560
+ orderNo,
561
+ qrImageUrl: this.clawtipPublicUrl(CLAWTIP_RECHARGE_QR_FILE),
562
+ sourceImagePath: mediaPath,
563
+ staticImagePath: mediaPath,
564
+ resourceUrl,
565
+ walletConfigPath: walletConfig.expectedPath,
566
+ walletConfigPresent: walletConfig.exists,
567
+ requiresWalletAuth: false,
568
+ payCredentialWritten: readConfigBoolean(payment?.config, "payCredentialWritten")
569
+ };
570
+ }
223
571
  // v1.2 §18.9: stale-cache fallback. The buyer remembers the last
224
572
  // successfully fetched registry document and reuses it when the
225
573
  // bootstrap returns 413 (`X-TokenBuddy-Registry-Too-Large: 1`). This
@@ -259,6 +607,7 @@ export class TokenbuddyDaemon {
259
607
  }
260
608
  }
261
609
  runtimeSummary() {
610
+ this.refreshSellerRoutingConfig();
262
611
  return {
263
612
  status: "running",
264
613
  pid: process.pid,
@@ -363,11 +712,13 @@ export class TokenbuddyDaemon {
363
712
  // v1.2: registry is the source of truth for routing. We rebuild the
364
713
  // model-index once per request (cheap; index lookup is in-memory) so
365
714
  // the response always reflects the latest seller list. The previous
366
- // "fetchSellerManifest per candidate" path is removed in favor of
715
+ // "fetchSellerManifest per request" path is removed in favor of
367
716
  // pulling `models` directly off the registry entries.
368
717
  const registry = await this.fetchRegistry();
369
- const routing = this.sellerRouting;
718
+ const routing = resolveSellerRoutingForModel(this.refreshSellerRoutingConfig(), modelId);
370
719
  const registrySellers = reorderDefaultSellerFirst(registry.sellers, registry.defaultSeller);
720
+ this.sellerPool.ensureRegistrySellers(registrySellers);
721
+ this.scheduleLazyPrewarmIfNeeded(modelId, protocol, paymentMethod);
371
722
  const poolById = new Map(this.sellerPool.snapshot().map((entry) => [entry.sellerId, entry]));
372
723
  const planned = planSellerRouteSet({
373
724
  modelId,
@@ -380,8 +731,12 @@ export class TokenbuddyDaemon {
380
731
  sellerId: entry.sellerId,
381
732
  healthScore: entry.healthScore,
382
733
  avgLatencyMs: entry.avgLatencyMs,
383
- circuit: entry.circuit
384
- }))
734
+ ttftMs: entry.ttftMs,
735
+ avgInferenceMs: entry.avgInferenceMs,
736
+ circuit: entry.circuit,
737
+ capacityBlockedUntil: entry.capacityBlockedUntil
738
+ })),
739
+ now: Date.now()
385
740
  });
386
741
  if (planned.routes.length === 0) {
387
742
  throw new Error(`no compatible seller for ${endpoint} model ${modelId}`);
@@ -408,7 +763,109 @@ export class TokenbuddyDaemon {
408
763
  sellerCount: routes.length,
409
764
  sellers: routes.map((route) => route.seller.id)
410
765
  });
411
- return routes;
766
+ return { routes, plan: planned, paymentMethod };
767
+ }
768
+ refreshSellerRoutingConfig() {
769
+ const storedRouting = this.tokenStore.getDaemonRuntimeConfig(ROUTING_CONFIG_KEY)
770
+ ?.config;
771
+ const nextRouting = mergeSellerRoutingConfig(storedRouting, this.config.sellerRouting);
772
+ if (!sameSellerRouting(this.sellerRouting, nextRouting)) {
773
+ const previous = this.sellerRouting;
774
+ this.sellerRouting = nextRouting;
775
+ this.selectionMode = selectionModeForRouting(nextRouting);
776
+ this.selectedSellerId = selectedSellerIdForRouting(nextRouting);
777
+ logger.info("routing.config.reloaded", "seller routing config reloaded", {
778
+ previousMode: previous.mode,
779
+ previousScorer: previous.scorer,
780
+ sellerRoutingMode: nextRouting.mode,
781
+ sellerRoutingScorer: nextRouting.scorer,
782
+ selectedSellerId: this.selectedSellerId
783
+ });
784
+ void this.runRoutingPrewarmSweep(nextRouting);
785
+ }
786
+ return this.sellerRouting;
787
+ }
788
+ async runRoutingPrewarmSweep(routing) {
789
+ const focusSet = this.resolveFocusSet();
790
+ const routingPrewarmKey = `${routingKey(routing)}\u0001${focusSet.join("\u0001")}`;
791
+ if (focusSet.length === 0) {
792
+ logger.info("prewarm.routing.skipped", "no focus set configured after routing reload; relying on lazy prewarms", {
793
+ sellerRoutingMode: routing.mode,
794
+ sellerRoutingScorer: routing.scorer
795
+ });
796
+ return;
797
+ }
798
+ if (this.lastRoutingPrewarmKey === routingPrewarmKey) {
799
+ return;
800
+ }
801
+ this.lastRoutingPrewarmKey = routingPrewarmKey;
802
+ logger.info("prewarm.routing.scheduled", "routing reload prewarm sweep scheduled", {
803
+ sellerRoutingMode: routing.mode,
804
+ sellerRoutingScorer: routing.scorer,
805
+ focusSetSize: focusSet.length,
806
+ focusSet: focusSet.slice(0, 20)
807
+ });
808
+ try {
809
+ await this.fetchRegistry();
810
+ const paymentMethod = this.defaultPaymentMethod();
811
+ for (const modelId of focusSet) {
812
+ this.schedulePrewarmForModel({
813
+ modelId,
814
+ reason: "explicit",
815
+ protocol: this.resolvePrewarmProtocol(modelId, paymentMethod),
816
+ paymentMethod
817
+ });
818
+ }
819
+ }
820
+ catch (err) {
821
+ logger.warn("prewarm.routing.failed", "routing reload prewarm sweep failed", {
822
+ sellerRoutingMode: routing.mode,
823
+ sellerRoutingScorer: routing.scorer,
824
+ errorMessage: err instanceof Error ? err.message : String(err)
825
+ });
826
+ }
827
+ }
828
+ scheduleLazyPrewarmIfNeeded(modelId, protocol, paymentMethod) {
829
+ const freshness = this.prewarmCache.freshness(modelId, protocol, paymentMethod);
830
+ if (freshness.present && !freshness.expired) {
831
+ return;
832
+ }
833
+ const key = prewarmKey(modelId, protocol, paymentMethod);
834
+ if (this.lazyPrewarmKeys.has(key)) {
835
+ return;
836
+ }
837
+ this.lazyPrewarmKeys.add(key);
838
+ logger.info("prewarm.lazy.scheduled", "lazy prewarm scheduled for requested model", {
839
+ modelId,
840
+ protocol,
841
+ paymentMethod,
842
+ freshnessState: freshness.state
843
+ });
844
+ this.schedulePrewarmForModel({
845
+ modelId,
846
+ reason: "lazy",
847
+ protocol,
848
+ paymentMethod
849
+ }).finally(() => {
850
+ this.lazyPrewarmKeys.delete(key);
851
+ });
852
+ }
853
+ schedulePrewarmForModel(input) {
854
+ if (!input.protocol || !input.paymentMethod) {
855
+ logger.warn("prewarm.schedule.skipped", "prewarm schedule skipped because protocol or payment method is missing", {
856
+ modelId: input.modelId,
857
+ reason: input.reason,
858
+ protocol: input.protocol,
859
+ paymentMethod: input.paymentMethod
860
+ });
861
+ return Promise.resolve();
862
+ }
863
+ return this.prewarmScheduler.schedulePrewarm({
864
+ modelId: input.modelId,
865
+ reason: input.reason,
866
+ protocol: input.protocol,
867
+ paymentMethod: input.paymentMethod
868
+ });
412
869
  }
413
870
  failoverErrorMessage(error) {
414
871
  return error instanceof Error ? error.message : String(error);
@@ -422,13 +879,16 @@ export class TokenbuddyDaemon {
422
879
  * caller side because it short-circuits the failure path with a
423
880
  * re-purchase.
424
881
  */
425
- classifyFailureStatus(status) {
882
+ classifyFailureStatus(status, bodyText) {
426
883
  if (status === 401 || status === 403) {
427
884
  return "auth_invalid";
428
885
  }
429
886
  if (status === 402) {
430
887
  return "insufficient_funds";
431
888
  }
889
+ if (status === 429 && isBusyCapacityErrorBody(bodyText)) {
890
+ return "busy_capacity";
891
+ }
432
892
  if (status === 400 || status === 404 || status === 422) {
433
893
  return "hard_4xx";
434
894
  }
@@ -561,7 +1021,7 @@ export class TokenbuddyDaemon {
561
1021
  }
562
1022
  return parseSellerSettlementObject(raw);
563
1023
  }
564
- recordReconciledInference(route, endpoint, requestId, usage, settlement, prompt, response) {
1024
+ recordReconciledInference(route, endpoint, requestId, usage, settlement, prompt, response, extras) {
565
1025
  if (settlement) {
566
1026
  this.tokenStore.reconcileTokenBalance({
567
1027
  sellerKey: route.seller.id,
@@ -589,7 +1049,14 @@ export class TokenbuddyDaemon {
589
1049
  balanceSnapshotMicros: settlement?.remainingCreditMicros,
590
1050
  balanceSource: settlement ? "seller_authoritative" : "estimated",
591
1051
  prompt,
592
- response
1052
+ response,
1053
+ ttftMs: extras?.ttftMs,
1054
+ fallbackCount: extras?.fallbackCount,
1055
+ routeReason: extras?.routeReason,
1056
+ falloverChain: extras?.falloverChain,
1057
+ upstreamStatus: extras?.upstreamStatus,
1058
+ durationMs: extras?.durationMs,
1059
+ paymentMethod: extras?.paymentMethod
593
1060
  });
594
1061
  logger.info("inference.ledger.recorded", "safe inference ledger recorded", {
595
1062
  requestId: settlement?.requestId || requestId,
@@ -604,7 +1071,14 @@ export class TokenbuddyDaemon {
604
1071
  promptTokens: usage.promptTokens,
605
1072
  completionTokens: usage.completionTokens,
606
1073
  balanceSnapshotMicros: settlement?.remainingCreditMicros,
607
- balanceSource: settlement ? "seller_authoritative" : "estimated"
1074
+ balanceSource: settlement ? "seller_authoritative" : "estimated",
1075
+ ttftMs: extras?.ttftMs,
1076
+ fallbackCount: extras?.fallbackCount,
1077
+ routeReason: extras?.routeReason,
1078
+ falloverChain: extras?.falloverChain,
1079
+ upstreamStatus: extras?.upstreamStatus,
1080
+ durationMs: extras?.durationMs,
1081
+ paymentMethod: extras?.paymentMethod
608
1082
  });
609
1083
  }
610
1084
  async refreshSellerBalance(route, token, balanceSource) {
@@ -1074,6 +1548,11 @@ export class TokenbuddyDaemon {
1074
1548
  }
1075
1549
  async forwardProxyRequest(endpoint, req, res) {
1076
1550
  const startedAt = Date.now();
1551
+ let firstByteAt = null;
1552
+ const markFirstByte = () => {
1553
+ if (firstByteAt === null)
1554
+ firstByteAt = Date.now();
1555
+ };
1077
1556
  const body = req.body || {};
1078
1557
  const { requestedModelId, resolvedModelId } = this.resolveRouteModelId(endpoint, body);
1079
1558
  const modelId = resolvedModelId;
@@ -1084,13 +1563,20 @@ export class TokenbuddyDaemon {
1084
1563
  return;
1085
1564
  }
1086
1565
  try {
1087
- const routes = await this.selectSellerRoutes(endpoint, modelId);
1566
+ const { routes, plan, paymentMethod } = await this.selectSellerRoutes(endpoint, modelId);
1567
+ const upstreamStatusFromHeaders = (h) => {
1568
+ const raw = h.get("x-tokenbuddy-upstream-status") || h.get("x-upstream-status");
1569
+ if (!raw)
1570
+ return undefined;
1571
+ return raw === "healthy" || raw === "degraded" || raw === "unhealthy" || raw === "unknown" ? raw : "unknown";
1572
+ };
1088
1573
  let lastError;
1089
1574
  for (let routeIndex = 0; routeIndex < routes.length; routeIndex += 1) {
1090
1575
  const route = routes[routeIndex];
1091
1576
  const sellerKey = route.seller.id;
1092
1577
  logger.info("route.selected", "seller route selected", {
1093
1578
  sellerKey,
1579
+ sellerId: sellerKey,
1094
1580
  model: modelId,
1095
1581
  endpoint,
1096
1582
  protocol: route.protocol,
@@ -1247,7 +1733,7 @@ export class TokenbuddyDaemon {
1247
1733
  status: upstreamResponse.status,
1248
1734
  durationMs: Date.now() - startedAt
1249
1735
  });
1250
- const kind = this.classifyFailureStatus(upstreamResponse.status);
1736
+ const kind = this.classifyFailureStatus(upstreamResponse.status, errorBody);
1251
1737
  const decision = this.routeFailover.decide({
1252
1738
  sellerId: sellerKey,
1253
1739
  status: upstreamResponse.status,
@@ -1317,6 +1803,7 @@ export class TokenbuddyDaemon {
1317
1803
  // 缺 event: 行)由卖方修,buyer 不兜底。
1318
1804
  const sellerChunk = settlementExtractor.push(chunk);
1319
1805
  if (sellerChunk.length > 0) {
1806
+ markFirstByte();
1320
1807
  res.write(sellerChunk);
1321
1808
  }
1322
1809
  }
@@ -1328,21 +1815,40 @@ export class TokenbuddyDaemon {
1328
1815
  if (decoderTail.length > 0) {
1329
1816
  const sellerTail = settlementExtractor.push(decoderTail);
1330
1817
  if (sellerTail.length > 0) {
1818
+ markFirstByte();
1331
1819
  res.write(sellerTail);
1332
1820
  }
1333
1821
  }
1334
1822
  const settlementTrailing = settlementExtractor.finish();
1335
1823
  if (settlementTrailing.downstream.length > 0) {
1824
+ markFirstByte();
1336
1825
  res.write(settlementTrailing.downstream);
1337
1826
  }
1338
1827
  res.end();
1339
- 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));
1828
+ 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, {
1829
+ ttftMs: firstByteAt ? firstByteAt - startedAt : undefined,
1830
+ fallbackCount: routeIndex,
1831
+ routeReason: plan.reason,
1832
+ falloverChain: routes.slice(0, routeIndex + 1).map((r) => r.seller.id),
1833
+ upstreamStatus: upstreamStatusFromHeaders(upstreamResponse.headers),
1834
+ durationMs: Date.now() - startedAt,
1835
+ paymentMethod
1836
+ });
1340
1837
  return;
1341
1838
  }
1342
1839
  const responseBody = await upstreamResponse.text();
1840
+ markFirstByte();
1343
1841
  res.send(responseBody);
1344
1842
  const usage = this.readUsage(responseBody);
1345
- this.recordReconciledInference(route, endpoint, requestId, usage, this.parseSellerSettlementSummary(upstreamResponse.headers), this.inferPromptForHash(body), responseBody);
1843
+ this.recordReconciledInference(route, endpoint, requestId, usage, this.parseSellerSettlementSummary(upstreamResponse.headers), this.inferPromptForHash(body), responseBody, {
1844
+ ttftMs: firstByteAt ? firstByteAt - startedAt : undefined,
1845
+ fallbackCount: routeIndex,
1846
+ routeReason: plan.reason,
1847
+ falloverChain: routes.slice(0, routeIndex + 1).map((r) => r.seller.id),
1848
+ upstreamStatus: upstreamStatusFromHeaders(upstreamResponse.headers),
1849
+ durationMs: Date.now() - startedAt,
1850
+ paymentMethod
1851
+ });
1346
1852
  return;
1347
1853
  }
1348
1854
  catch (routeError) {
@@ -1437,9 +1943,41 @@ export class TokenbuddyDaemon {
1437
1943
  controlApp.get("/payments", (req, res) => {
1438
1944
  logger.info("control.payments.requested", "control payments requested", {});
1439
1945
  res.status(200).json({
1440
- payments: this.tokenStore.listPayments()
1946
+ payments: this.tokenStore.listPayments().map((payment) => withLiveClawtipWalletState(payment, this.config.clawtipHomeDir))
1441
1947
  });
1442
1948
  });
1949
+ controlApp.post("/payments/clawtip/activate", async (req, res) => {
1950
+ try {
1951
+ const qr = await this.startClawtipActivationQr();
1952
+ logger.info("control.payment.clawtip.activate_qr.created", "ClawTip activation QR copied for tb-ui", {
1953
+ orderNo: qr.orderNo,
1954
+ qrImageUrl: qr.qrImageUrl,
1955
+ walletConfigPresent: qr.walletConfigPresent
1956
+ });
1957
+ res.status(200).json(qr);
1958
+ }
1959
+ catch (error) {
1960
+ const errorMessage = error instanceof Error ? error.message : String(error);
1961
+ logger.warn("control.payment.clawtip.activate_qr.failed", "ClawTip activation QR failed", { errorMessage });
1962
+ res.status(500).json({ error: { code: "clawtip_activate_qr_failed", message: errorMessage } });
1963
+ }
1964
+ });
1965
+ controlApp.post("/payments/clawtip/recharge", (req, res) => {
1966
+ try {
1967
+ const qr = this.clawtipRechargeQr();
1968
+ logger.info("control.payment.clawtip.recharge_qr.created", "ClawTip fixed recharge QR served for tb-ui", {
1969
+ orderNo: qr.orderNo,
1970
+ qrImageUrl: qr.qrImageUrl,
1971
+ walletConfigPresent: qr.walletConfigPresent
1972
+ });
1973
+ res.status(200).json(qr);
1974
+ }
1975
+ catch (error) {
1976
+ const errorMessage = error instanceof Error ? error.message : String(error);
1977
+ logger.warn("control.payment.clawtip.recharge_qr.failed", "ClawTip recharge QR failed", { errorMessage });
1978
+ res.status(500).json({ error: { code: "clawtip_recharge_qr_failed", message: errorMessage } });
1979
+ }
1980
+ });
1443
1981
  controlApp.get("/ledger/purchases", (req, res) => {
1444
1982
  logger.info("control.ledger.requested", "control purchase ledger requested", {
1445
1983
  ledger: "purchases"
@@ -1578,6 +2116,41 @@ export class TokenbuddyDaemon {
1578
2116
  });
1579
2117
  }
1580
2118
  });
2119
+ controlApp.get("/providers/status", (_req, res) => {
2120
+ try {
2121
+ const providerStatuses = detectProviders({ home: this.config.providerHomeDir }).map(clientToolStatusFromProvider);
2122
+ const clients = [
2123
+ ...providerStatuses,
2124
+ buildCustomClientToolStatus(this.activeProxyPort()),
2125
+ ];
2126
+ const configuredCount = clients.filter((client) => client.configured).length;
2127
+ const detectedCount = clients.filter((client) => client.detected && client.status !== "manual").length;
2128
+ logger.info("provider.status.requested", "provider status requested", {
2129
+ clientCount: clients.length,
2130
+ configuredCount,
2131
+ detectedCount
2132
+ });
2133
+ res.status(200).json({
2134
+ clients,
2135
+ summary: {
2136
+ configuredCount,
2137
+ detectedCount,
2138
+ totalCount: clients.length,
2139
+ installCommand: "tb init"
2140
+ }
2141
+ });
2142
+ }
2143
+ catch (error) {
2144
+ const errorMessage = error instanceof Error ? error.message : String(error);
2145
+ logger.warn("provider.status.failed", "provider status failed", { errorMessage });
2146
+ res.status(400).json({
2147
+ error: {
2148
+ code: "provider_status_failed",
2149
+ message: errorMessage
2150
+ }
2151
+ });
2152
+ }
2153
+ });
1581
2154
  controlApp.post("/providers/install/preview", (req, res) => {
1582
2155
  try {
1583
2156
  const changes = previewProviderInstall({
@@ -1655,6 +2228,169 @@ export class TokenbuddyDaemon {
1655
2228
  });
1656
2229
  }
1657
2230
  });
2231
+ // ─────────────────────────────────────────────────────────────────
2232
+ // tb-ui v1: 控制平面写端点(PR-0)
2233
+ // 所有端点都热生效,不需重启 daemon。改完 store 即下次推理生效。
2234
+ // ─────────────────────────────────────────────────────────────────
2235
+ // 1) GET /routing/strategy — 读当前路由策略 + 来源
2236
+ controlApp.get("/routing/strategy", (req, res) => {
2237
+ try {
2238
+ const stored = this.tokenStore.getDaemonRuntimeConfig(ROUTING_CONFIG_KEY)?.config;
2239
+ const current = this.refreshSellerRoutingConfig();
2240
+ const source = stored !== undefined ? "store" : this.config.sellerRouting ? "config" : "default";
2241
+ logger.info("routing.strategy.read", "routing strategy read", {
2242
+ source,
2243
+ mode: current.mode,
2244
+ scorer: current.scorer
2245
+ });
2246
+ res.status(200).json({ strategy: current, source });
2247
+ }
2248
+ catch (error) {
2249
+ const errorMessage = error instanceof Error ? error.message : String(error);
2250
+ logger.warn("routing.strategy.read_failed", "routing strategy read failed", { errorMessage });
2251
+ res.status(500).json({ error: { code: "routing_strategy_read_failed", message: errorMessage } });
2252
+ }
2253
+ });
2254
+ // 2) GET /routing/preview — 算「假如改完会怎样」,不改 state
2255
+ // query: modelId? protocol? paymentMethod? mode? scorer? sellerId? sellerIds?(逗号分隔)
2256
+ controlApp.get("/routing/preview", (req, res) => {
2257
+ try {
2258
+ const override = buildRoutingConfigFromQuery(req.query);
2259
+ const result = this.buildRoutingPreview({
2260
+ modelId: typeof req.query.modelId === "string" ? req.query.modelId : undefined,
2261
+ protocol: typeof req.query.protocol === "string" ? req.query.protocol : undefined,
2262
+ paymentMethod: typeof req.query.paymentMethod === "string" ? req.query.paymentMethod : undefined,
2263
+ routing: override ?? undefined
2264
+ });
2265
+ if ("error" in result.plan) {
2266
+ res.status(409).json({
2267
+ error: { code: result.plan.error, message: `cannot preview routing: ${result.plan.error}` },
2268
+ modelId: result.modelId,
2269
+ protocol: result.protocol,
2270
+ paymentMethod: result.paymentMethod
2271
+ });
2272
+ return;
2273
+ }
2274
+ res.status(200).json({
2275
+ modelId: result.modelId,
2276
+ protocol: result.protocol,
2277
+ paymentMethod: result.paymentMethod,
2278
+ plan: result.plan
2279
+ });
2280
+ }
2281
+ catch (error) {
2282
+ const errorMessage = error instanceof Error ? error.message : String(error);
2283
+ logger.warn("routing.preview.failed", "routing preview failed", { errorMessage });
2284
+ res.status(400).json({ error: { code: "routing_preview_failed", message: errorMessage } });
2285
+ }
2286
+ });
2287
+ // 3) PUT /routing/strategy — 写策略 + 热更新 + 返回 preview
2288
+ controlApp.put("/routing/strategy", (req, res) => {
2289
+ try {
2290
+ const body = (req.body ?? {});
2291
+ const normalized = normalizeSellerRoutingConfig(body);
2292
+ // 必填字段再次校验(normalize 会回退 default,但 PUT 必须显式)
2293
+ assertSellerRoutingConfig(normalized);
2294
+ this.tokenStore.saveDaemonRuntimeConfig(ROUTING_CONFIG_KEY, normalized);
2295
+ const current = this.refreshSellerRoutingConfig();
2296
+ logger.info("routing.strategy.applied", "routing strategy applied", {
2297
+ mode: current.mode,
2298
+ scorer: current.scorer,
2299
+ sellerId: current.sellerId,
2300
+ sellerIds: current.sellerIds
2301
+ });
2302
+ const preview = this.buildRoutingPreview({ routing: current });
2303
+ const previewPayload = "error" in preview.plan
2304
+ ? { error: preview.plan.error }
2305
+ : {
2306
+ modelId: preview.modelId,
2307
+ protocol: preview.protocol,
2308
+ paymentMethod: preview.paymentMethod,
2309
+ ...preview.plan
2310
+ };
2311
+ res.status(200).json({ applied: true, strategy: current, preview: previewPayload });
2312
+ }
2313
+ catch (error) {
2314
+ const errorMessage = error instanceof Error ? error.message : String(error);
2315
+ logger.warn("routing.strategy.apply_failed", "routing strategy apply failed", { errorMessage });
2316
+ res.status(400).json({ error: { code: "routing_strategy_apply_failed", message: errorMessage } });
2317
+ }
2318
+ });
2319
+ // 4) PUT /prewarm/focus-set — 设置 explicit focus set(覆盖 config.warmupModels / env)
2320
+ // body: { models: ["claude-3-5-sonnet", "gpt-4o"], clear?: false }
2321
+ // clear=true 时 models 数组可省略;表示回退 env / historical
2322
+ controlApp.put("/prewarm/focus-set", (req, res) => {
2323
+ try {
2324
+ const body = (req.body ?? {});
2325
+ const clear = body.clear === true;
2326
+ if (clear) {
2327
+ const result = this.applyFocusSet(null);
2328
+ res.status(200).json({ ok: true, ...result });
2329
+ return;
2330
+ }
2331
+ if (!Array.isArray(body.models)) {
2332
+ res.status(400).json({
2333
+ error: { code: "invalid_focus_set", message: "focus-set body must have a string[] `models` field, or `clear: true`" }
2334
+ });
2335
+ return;
2336
+ }
2337
+ const models = body.models
2338
+ .filter((m) => typeof m === "string")
2339
+ .map((m) => m.trim())
2340
+ .filter(Boolean);
2341
+ const result = this.applyFocusSet(models);
2342
+ res.status(200).json({ ok: true, ...result });
2343
+ }
2344
+ catch (error) {
2345
+ const errorMessage = error instanceof Error ? error.message : String(error);
2346
+ logger.warn("focus_set.apply_failed", "focus set apply failed", { errorMessage });
2347
+ res.status(400).json({ error: { code: "focus_set_apply_failed", message: errorMessage } });
2348
+ }
2349
+ });
2350
+ // 5) POST /daemon/restart — 优雅重启 tb-proxyd(调子进程 `tb daemon restart`)
2351
+ // 现有 CLI 子命令(cli.ts:1129)。detached 模式让子进程独立于 daemon 生命周期。
2352
+ controlApp.post("/daemon/restart", (req, res) => {
2353
+ try {
2354
+ const child = spawn("tb", ["daemon", "restart"], {
2355
+ detached: true,
2356
+ stdio: "ignore"
2357
+ });
2358
+ child.unref();
2359
+ logger.info("daemon.restart.scheduled", "daemon restart scheduled via tb CLI");
2360
+ res.status(202).json({ ok: true, message: "restart scheduled" });
2361
+ }
2362
+ catch (error) {
2363
+ const errorMessage = error instanceof Error ? error.message : String(error);
2364
+ logger.warn("daemon.restart.failed", "daemon restart failed", { errorMessage });
2365
+ res.status(500).json({ error: { code: "daemon_restart_failed", message: errorMessage } });
2366
+ }
2367
+ });
2368
+ this.ensureClawtipStaticAssets();
2369
+ controlApp.use(CLAWTIP_STATIC_ROUTE, express.static(this.clawtipStaticDir(), { index: false, fallthrough: false }));
2370
+ // ────────────────────────────────────────────────────────────
2371
+ // tb-ui v1: 静态托管 SPA(dist 由 `npm run build --workspace tb-ui` 生成)
2372
+ // 必须在所有 API 路由**之后**才挂载,这样:
2373
+ // - `/health` `/ledger/purchases` 等 API 路径仍由上面 17+ 个端点处理
2374
+ // - 真实静态文件(`/index.html` `/assets/index-abc.js` 等)由 express.static 服务
2375
+ // - 未匹配路径(`/overview` `/routing` 等 React Router 路径)走 SPA fallback 回 index.html
2376
+ // ────────────────────────────────────────────────────────────
2377
+ const uiDir = resolveUiDir();
2378
+ if (uiDir && fs.existsSync(uiDir) && fs.existsSync(path.join(uiDir, "index.html"))) {
2379
+ controlApp.use(express.static(uiDir, { index: "index.html", fallthrough: true }));
2380
+ // SPA fallback: React Router 用 BrowserRouter(history mode),客户端路径如
2381
+ // /overview /routing /ledger 不对应任何文件,必须回 index.html 让 React Router 接管。
2382
+ // Express 已经按注册顺序匹配了所有 17+ 个 API 路由,这里只接住"什么都没匹配"的 GET。
2383
+ controlApp.get(/^(?!\/(?:health|status|payments|sellers|models|ledger|routing|prewarm|v1\.2|daemon|providers|static)\/).*/, (_req, res) => {
2384
+ res.sendFile(path.join(uiDir, "index.html"));
2385
+ });
2386
+ logger.info("ui.static.attached", "tb-ui dist attached to control plane SPA fallback", { uiDir });
2387
+ }
2388
+ else {
2389
+ logger.warn("ui.static.missing", "tb-ui dist not found; only control plane API will be served", {
2390
+ uiDir,
2391
+ hint: "run `npm run build --workspace tb-ui` then restart"
2392
+ });
2393
+ }
1658
2394
  this.controlServer = controlApp.listen(this.config.controlPort);
1659
2395
  // 2. Proxy Plane Server (17821)
1660
2396
  const proxyApp = express();
@@ -1717,9 +2453,13 @@ export class TokenbuddyDaemon {
1717
2453
  /**
1718
2454
  * v1.2 §18.4: build the focus set from the explicit config, the env
1719
2455
  * override, and the historical usage in the buyer store. The order of
1720
- * precedence: explicit config > env > historical > empty.
2456
+ * precedence: explicit `currentFocusSet` (set via `PUT /prewarm/focus-set`)
2457
+ * > explicit config > env > historical > empty.
1721
2458
  */
1722
2459
  resolveFocusSet() {
2460
+ if (this.currentFocusSet !== null) {
2461
+ return this.currentFocusSet;
2462
+ }
1723
2463
  const explicit = this.config.warmupModels ?? [];
1724
2464
  if (explicit.length > 0) {
1725
2465
  return explicit;
@@ -1731,6 +2471,86 @@ export class TokenbuddyDaemon {
1731
2471
  }
1732
2472
  return this.tokenStore.recentModels(7, 5);
1733
2473
  }
2474
+ /**
2475
+ * tb-ui v1 `PUT /prewarm/focus-set` 调用的统一入口。`models === null`
2476
+ * 表示「清除 explicit focus set,回退 env/historical」。
2477
+ * 写 store + 触发 `runRoutingPrewarmSweep`,**热生效**(不需重启 daemon)。
2478
+ */
2479
+ applyFocusSet(models) {
2480
+ if (models === null) {
2481
+ this.tokenStore.removeDaemonRuntimeConfig(FOCUS_SET_CONFIG_KEY);
2482
+ this.currentFocusSet = null;
2483
+ }
2484
+ else {
2485
+ const deduped = Array.from(new Set(models.map((m) => m.trim()).filter(Boolean)));
2486
+ this.tokenStore.saveDaemonRuntimeConfig(FOCUS_SET_CONFIG_KEY, { models: deduped });
2487
+ this.currentFocusSet = deduped;
2488
+ }
2489
+ // 触发路由重读 + 预热 sweep(如果当前 routing 已是焦点集合的依赖)。
2490
+ this.refreshSellerRoutingConfig();
2491
+ const focusSet = this.resolveFocusSet();
2492
+ const source = this.currentFocusSet !== null
2493
+ ? "explicit"
2494
+ : (this.config.warmupModels?.length ?? 0) > 0
2495
+ ? "explicit"
2496
+ : (process.env.TB_BUYER_WARMUP_MODELS?.trim() ?? "").length > 0
2497
+ ? "env"
2498
+ : focusSet.length > 0
2499
+ ? "historical"
2500
+ : "empty";
2501
+ logger.info("focus_set.applied", "explicit focus set applied", {
2502
+ source,
2503
+ focusSetSize: focusSet.length,
2504
+ focusSet: focusSet.slice(0, 20)
2505
+ });
2506
+ return { focusSet, source };
2507
+ }
2508
+ /**
2509
+ * tb-ui v1 `GET /routing/preview` 和 `PUT /routing/strategy` 复用的 preview 计算。
2510
+ * 接受任意 routing 覆盖(来自 request body)算「假如改成这个,路由会是啥」。
2511
+ * 不修改任何内部 state,**纯函数式**。
2512
+ */
2513
+ buildRoutingPreview(input) {
2514
+ const registry = this.lastRegistrySnapshot;
2515
+ const focusFirst = this.resolveFocusSet()[0];
2516
+ const registryFirst = registry?.sellers[0]?.models?.[0];
2517
+ const modelId = input.modelId?.trim() || focusFirst || registryFirst || "";
2518
+ const protocol = input.protocol?.trim() || "chat_completions";
2519
+ const paymentMethod = input.paymentMethod?.trim() || this.defaultPaymentMethod() || "clawtip";
2520
+ if (!modelId) {
2521
+ return { modelId, protocol, paymentMethod, plan: { error: "no_focus_model_available" } };
2522
+ }
2523
+ if (!registry) {
2524
+ return { modelId, protocol, paymentMethod, plan: { error: "registry_not_loaded" } };
2525
+ }
2526
+ const current = this.refreshSellerRoutingConfig();
2527
+ const routing = input.routing
2528
+ ? mergeSellerRoutingConfig(current, input.routing)
2529
+ : current;
2530
+ const resolvedRouting = resolveSellerRoutingForModel(routing, modelId);
2531
+ const registrySellers = reorderDefaultSellerFirst(registry.sellers, registry.defaultSeller);
2532
+ this.sellerPool.ensureRegistrySellers(registrySellers);
2533
+ const poolById = new Map(this.sellerPool.snapshot().map((entry) => [entry.sellerId, entry]));
2534
+ const plan = planSellerRouteSet({
2535
+ modelId,
2536
+ protocol,
2537
+ paymentMethod,
2538
+ registrySellers,
2539
+ routing: resolvedRouting,
2540
+ prewarmCandidates: this.prewarmCache.get(modelId, protocol, paymentMethod)?.candidates,
2541
+ sellerMetrics: Array.from(poolById.values()).map((entry) => ({
2542
+ sellerId: entry.sellerId,
2543
+ healthScore: entry.healthScore,
2544
+ avgLatencyMs: entry.avgLatencyMs,
2545
+ ttftMs: entry.ttftMs,
2546
+ avgInferenceMs: entry.avgInferenceMs,
2547
+ circuit: entry.circuit,
2548
+ capacityBlockedUntil: entry.capacityBlockedUntil
2549
+ })),
2550
+ now: Date.now()
2551
+ });
2552
+ return { modelId, protocol, paymentMethod, plan };
2553
+ }
1734
2554
  async runStartupPrewarmSweep() {
1735
2555
  const focusSet = this.resolveFocusSet();
1736
2556
  if (focusSet.length === 0) {
@@ -1745,7 +2565,7 @@ export class TokenbuddyDaemon {
1745
2565
  await this.fetchRegistry();
1746
2566
  await this.prewarmScheduler.runStartupPrewarm(focusSet.map((modelId) => ({
1747
2567
  modelId,
1748
- protocol: this.resolvePrewarmProtocol(modelId)
2568
+ protocol: this.resolvePrewarmProtocol(modelId, this.defaultPaymentMethod())
1749
2569
  })));
1750
2570
  }
1751
2571
  catch (err) {
@@ -1754,15 +2574,18 @@ export class TokenbuddyDaemon {
1754
2574
  });
1755
2575
  }
1756
2576
  }
1757
- resolvePrewarmProtocol(modelId) {
2577
+ resolvePrewarmProtocol(modelId, paymentMethod = "clawtip") {
1758
2578
  for (const protocol of ["chat_completions", "messages", "responses"]) {
1759
- if (this.modelIndex.sellersFor(modelId, { protocol, paymentMethod: "clawtip" }).length > 0) {
2579
+ if (this.modelIndex.sellersFor(modelId, { protocol, paymentMethod }).length > 0) {
1760
2580
  return protocol;
1761
2581
  }
1762
2582
  }
1763
2583
  return undefined;
1764
2584
  }
1765
2585
  stop() {
2586
+ if (this.clawtipActivationWaitCancelToken) {
2587
+ this.clawtipActivationWaitCancelToken.cancelled = true;
2588
+ }
1766
2589
  if (this.controlServer)
1767
2590
  this.controlServer.close();
1768
2591
  if (this.proxyServer)
@@ -1770,5 +2593,166 @@ export class TokenbuddyDaemon {
1770
2593
  void this.prewarmScheduler.stop();
1771
2594
  this.tokenStore.close();
1772
2595
  }
2596
+ /**
2597
+ * @internal — test-only seam to inject a registry snapshot without
2598
+ * hitting the network. Used by `tests/control-plane-ui-endpoints.test.ts`
2599
+ * to drive `buildRoutingPreview` deterministically. Production code
2600
+ * must NOT call this; the real `fetchRegistry()` populates the snapshot.
2601
+ */
2602
+ setLastRegistrySnapshotForTest(snapshot) {
2603
+ this.lastRegistrySnapshot = snapshot;
2604
+ }
2605
+ }
2606
+ function selectionModeForRouting(routing) {
2607
+ return routing.mode === "fullAuto" ? "auto" : "manual";
2608
+ }
2609
+ function withLiveClawtipWalletState(payment, home) {
2610
+ if (payment.method !== "clawtip") {
2611
+ return payment;
2612
+ }
2613
+ const walletConfig = inspectOpenClawWalletConfig(home);
2614
+ return {
2615
+ ...payment,
2616
+ enabled: payment.enabled && walletConfig.exists,
2617
+ config: {
2618
+ ...(payment.config ?? {}),
2619
+ walletConfigPath: walletConfig.expectedPath,
2620
+ walletConfigPresent: walletConfig.exists,
2621
+ nearbyWalletConfigPaths: walletConfig.alternatePaths
2622
+ }
2623
+ };
2624
+ }
2625
+ function normalizeClawtipActivationPayment(bootstrap) {
2626
+ if (!bootstrap.payment?.orderNo || !bootstrap.payment.indicator || !bootstrap.payment.resourceUrl) {
2627
+ throw new Error("ClawTip bootstrap response missing orderNo, indicator, or resourceUrl.");
2628
+ }
2629
+ return {
2630
+ orderNo: bootstrap.payment.orderNo,
2631
+ amountFen: bootstrap.payment.amountFen ?? bootstrap.activationFeeFen ?? 1,
2632
+ payTo: bootstrap.payment.payTo,
2633
+ encryptedData: bootstrap.payment.encryptedData,
2634
+ indicator: bootstrap.payment.indicator,
2635
+ slug: bootstrap.payment.slug,
2636
+ skillId: bootstrap.payment.skillId,
2637
+ description: bootstrap.payment.description,
2638
+ resourceUrl: bootstrap.payment.resourceUrl,
2639
+ };
2640
+ }
2641
+ function safeQrExtension(filePath) {
2642
+ const extension = path.extname(filePath).toLowerCase();
2643
+ if (extension === ".jpg" || extension === ".jpeg" || extension === ".webp") {
2644
+ return extension;
2645
+ }
2646
+ return ".png";
2647
+ }
2648
+ function safeStaticFileSegment(value) {
2649
+ return value.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "clawtip";
2650
+ }
2651
+ function readConfigString(config, key) {
2652
+ const value = config?.[key];
2653
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
2654
+ }
2655
+ function readConfigBoolean(config, key) {
2656
+ return config?.[key] === true;
2657
+ }
2658
+ function selectedSellerIdForRouting(routing) {
2659
+ return routing.mode === "fixed" ? routing.sellerId : undefined;
2660
+ }
2661
+ function routingKey(routing) {
2662
+ const fixedByModel = Object.entries(routing.fixedByModel ?? {})
2663
+ .sort(([left], [right]) => left.localeCompare(right))
2664
+ .map(([modelId, sellerId]) => `${modelId}:${sellerId}`);
2665
+ return [
2666
+ routing.mode,
2667
+ routing.scorer,
2668
+ routing.sellerId ?? "",
2669
+ ...(routing.sellerIds ?? []),
2670
+ ...fixedByModel
2671
+ ].join("\u0001");
2672
+ }
2673
+ /**
2674
+ * 从 query string 构造 `BuyerSellerRoutingConfig` override(用于 GET /routing/preview)。
2675
+ * 接受 `mode` / `scorer` / `sellerId` / `sellerIds`(逗号分隔)。
2676
+ * 任一字段缺失返回 `undefined`,调用方走「用当前 routing」分支。
2677
+ * mode / scorer 非法抛 400,由端点 handler 捕获。
2678
+ */
2679
+ function buildRoutingConfigFromQuery(query) {
2680
+ const mode = typeof query.mode === "string" ? query.mode.trim() : "";
2681
+ const scorer = typeof query.scorer === "string" ? query.scorer.trim() : "";
2682
+ const sellerId = typeof query.sellerId === "string" ? query.sellerId.trim() : "";
2683
+ const sellerIdsRaw = typeof query.sellerIds === "string" ? query.sellerIds.trim() : "";
2684
+ const fixedByModelRaw = typeof query.fixedByModel === "string" ? query.fixedByModel.trim() : "";
2685
+ if (!mode && !scorer && !sellerId && !sellerIdsRaw && !fixedByModelRaw) {
2686
+ return undefined;
2687
+ }
2688
+ const override = {};
2689
+ if (mode) {
2690
+ if (mode !== "fixed" && mode !== "fixedSet" && mode !== "fullAuto") {
2691
+ throw new Error("mode must be fixed, fixedSet, or fullAuto");
2692
+ }
2693
+ override.mode = mode;
2694
+ }
2695
+ if (scorer) {
2696
+ if (scorer !== "speed" && scorer !== "discount" && scorer !== "balanced") {
2697
+ throw new Error("scorer must be speed, discount, or balanced");
2698
+ }
2699
+ override.scorer = scorer;
2700
+ }
2701
+ if (sellerId) {
2702
+ override.sellerId = sellerId;
2703
+ }
2704
+ if (sellerIdsRaw) {
2705
+ override.sellerIds = parseSellerIdList(sellerIdsRaw);
2706
+ }
2707
+ if (fixedByModelRaw) {
2708
+ override.fixedByModel = parseFixedByModel(fixedByModelRaw);
2709
+ }
2710
+ return override;
2711
+ }
2712
+ function sameSellerRouting(a, b) {
2713
+ return a.mode === b.mode
2714
+ && a.scorer === b.scorer
2715
+ && optionalStringEqual(a.sellerId, b.sellerId)
2716
+ && stringArraysEqual(a.sellerIds ?? [], b.sellerIds ?? [])
2717
+ && fixedByModelEqual(a.fixedByModel ?? {}, b.fixedByModel ?? {});
2718
+ }
2719
+ function optionalStringEqual(a, b) {
2720
+ return (a ?? "") === (b ?? "");
2721
+ }
2722
+ function stringArraysEqual(a, b) {
2723
+ if (a.length !== b.length) {
2724
+ return false;
2725
+ }
2726
+ return a.every((entry, index) => entry === b[index]);
2727
+ }
2728
+ function resolveSellerRoutingForModel(routing, modelId) {
2729
+ if (routing.mode !== "fixed") {
2730
+ return routing;
2731
+ }
2732
+ const fixedSellerId = routing.fixedByModel?.[modelId]?.trim() || routing.sellerId;
2733
+ return {
2734
+ mode: "fixed",
2735
+ scorer: routing.scorer,
2736
+ sellerId: fixedSellerId,
2737
+ fixedByModel: routing.fixedByModel
2738
+ };
2739
+ }
2740
+ function parseFixedByModel(value) {
2741
+ const entries = value
2742
+ .split(",")
2743
+ .map((entry) => entry.split(":"))
2744
+ .filter((parts) => parts.length === 2)
2745
+ .map(([modelId, sellerId]) => [modelId.trim(), sellerId.trim()])
2746
+ .filter(([modelId, sellerId]) => modelId.length > 0 && sellerId.length > 0);
2747
+ return Object.fromEntries(entries);
2748
+ }
2749
+ function fixedByModelEqual(a, b) {
2750
+ const aEntries = Object.entries(a).sort(([left], [right]) => left.localeCompare(right));
2751
+ const bEntries = Object.entries(b).sort(([left], [right]) => left.localeCompare(right));
2752
+ return aEntries.length === bEntries.length
2753
+ && aEntries.every(([modelId, sellerId], index) => {
2754
+ const [otherModelId, otherSellerId] = bEntries[index] ?? [];
2755
+ return modelId === otherModelId && sellerId === otherSellerId;
2756
+ });
1773
2757
  }
1774
2758
  //# sourceMappingURL=daemon.js.map