@tokenbuddy/tokenbuddy 1.0.13 → 1.0.15

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 (70) 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 +984 -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 +1132 -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/tests/control-plane-ui-endpoints.test.ts +559 -0
  63. package/tests/daemon-classify.test.ts +9 -0
  64. package/tests/model-index.test.ts +14 -0
  65. package/tests/route-failover.test.ts +16 -0
  66. package/tests/seller-catalog-utilities.test.ts +54 -0
  67. package/tests/seller-pool.test.ts +56 -0
  68. package/tests/seller-route-planner.test.ts +40 -0
  69. package/tests/seller-routing-config.test.ts +13 -0
  70. package/tests/tokenbuddy.test.ts +200 -7
@@ -2,20 +2,78 @@ 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 clientToolStatusFromProvider(provider) {
29
+ return {
30
+ id: provider.id,
31
+ name: provider.name,
32
+ status: provider.status,
33
+ detected: provider.detected,
34
+ configured: provider.configured,
35
+ configPath: provider.configPath,
36
+ commandName: provider.commandName,
37
+ reason: provider.reason,
38
+ };
39
+ }
40
+ function buildCustomClientToolStatus(proxyPort) {
41
+ const openaiBaseUrl = `http://127.0.0.1:${proxyPort}/v1`;
42
+ const anthropicBaseUrl = `http://127.0.0.1:${proxyPort}`;
43
+ return {
44
+ id: "custom",
45
+ name: "Custom client",
46
+ status: "manual",
47
+ detected: true,
48
+ configured: false,
49
+ reason: `OpenAI-compatible: ${openaiBaseUrl} · Anthropic-compatible: ${anthropicBaseUrl}`,
50
+ manualConfig: {
51
+ openaiBaseUrl,
52
+ anthropicBaseUrl,
53
+ apiKey: "TOKENBUDDY_PROXY",
54
+ },
55
+ };
56
+ }
57
+ /**
58
+ * 解析 `tb-ui` 构建产物目录(daemon 静态托管 SPA 用)。
59
+ * 优先级:env TB_UI_DIR > require.resolve("tb-ui") > 相对路径猜
60
+ * 找不到时记录 warning 仍允许 daemon 启动(纯 API 模式);静态请求会 404。
61
+ */
62
+ function resolveUiDir() {
63
+ if (process.env.TB_UI_DIR)
64
+ return process.env.TB_UI_DIR;
65
+ try {
66
+ // require.resolve 在 npm workspaces 装好时能找到 tb-ui/package.json
67
+ const pkgPath = require.resolve("tb-ui/package.json");
68
+ return path.join(path.dirname(pkgPath), "dist");
69
+ }
70
+ catch {
71
+ // fallback: monorepo 假设 daemon dist 跟 tb-ui/dist 在同 root
72
+ // 用 __filename (jest cjs 编译下可用,生产 ESM 也兼容) 推回 src 再到 root
73
+ const here = typeof __filename !== "undefined" ? path.dirname(__filename) : process.cwd();
74
+ return path.resolve(here, "../../tb-ui/dist");
75
+ }
76
+ }
19
77
  function numericHeaderField(value) {
20
78
  if (typeof value === "number" && Number.isFinite(value)) {
21
79
  return value;
@@ -123,6 +181,55 @@ function summarizeProxyBody(body) {
123
181
  temperaturePresent: data.temperature !== undefined
124
182
  };
125
183
  }
184
+ function finiteNumber(value) {
185
+ if (typeof value === "number" && Number.isFinite(value)) {
186
+ return value;
187
+ }
188
+ if (typeof value === "string" && value.trim().length > 0) {
189
+ const parsed = Number(value);
190
+ return Number.isFinite(parsed) ? parsed : undefined;
191
+ }
192
+ return undefined;
193
+ }
194
+ function readErrorCode(bodyText) {
195
+ try {
196
+ const parsed = JSON.parse(bodyText);
197
+ const code = parsed.error?.code ?? parsed.code;
198
+ return typeof code === "string" ? code : undefined;
199
+ }
200
+ catch {
201
+ return undefined;
202
+ }
203
+ }
204
+ function isBusyCapacityErrorBody(bodyText) {
205
+ if (!bodyText) {
206
+ return false;
207
+ }
208
+ return readErrorCode(bodyText) === ErrorCode.BusyCapacity;
209
+ }
210
+ function capacityBlockedUntilFromHealth(body, now) {
211
+ const capacity = body.capacity;
212
+ if (!capacity) {
213
+ return undefined;
214
+ }
215
+ const activeConnections = finiteNumber(capacity.activeConnections ?? capacity.active_connections);
216
+ const maxConnections = finiteNumber(capacity.maxConnections ?? capacity.max_connections);
217
+ const queueDepth = finiteNumber(capacity.queueDepth ?? capacity.queue_depth);
218
+ const maxQueueDepth = finiteNumber(capacity.maxQueueDepth ?? capacity.max_queue_depth);
219
+ if (activeConnections === undefined ||
220
+ maxConnections === undefined ||
221
+ queueDepth === undefined ||
222
+ maxQueueDepth === undefined) {
223
+ return undefined;
224
+ }
225
+ if (maxConnections <= 0) {
226
+ return undefined;
227
+ }
228
+ const connectionsFull = activeConnections >= maxConnections;
229
+ const queueUnavailable = maxQueueDepth <= 0;
230
+ const queueFull = queueUnavailable || queueDepth >= maxQueueDepth;
231
+ return connectionsFull && queueFull ? now + SELLER_CAPACITY_BLOCK_MS : undefined;
232
+ }
126
233
  function reorderDefaultSellerFirst(sellers, defaultSellerId) {
127
234
  if (!defaultSellerId) {
128
235
  return sellers;
@@ -146,6 +253,15 @@ export class TokenbuddyDaemon {
146
253
  selectionMode;
147
254
  selectedSellerId;
148
255
  sellerRouting;
256
+ lastRoutingPrewarmKey;
257
+ lazyPrewarmKeys = new Set();
258
+ clawtipActivationWait;
259
+ clawtipActivationWaitCancelToken;
260
+ /**
261
+ * tb-ui v1 控制平面 `PUT /prewarm/focus-set` 写入的 explicit focus set。
262
+ * 优先级最高;`null` 表示回退到 env / historical(与 `resolveFocusSet()` 原行为一致)。
263
+ */
264
+ currentFocusSet = null;
149
265
  activePurchases = new Map();
150
266
  // v1.2 fallback pipeline: model-index, prewarm-cache, credit-tracker,
151
267
  // pool, and route-failover together replace the v1
@@ -170,10 +286,17 @@ export class TokenbuddyDaemon {
170
286
  this.tokenStore = new BuyerStore({ dbPath: config.dbPath });
171
287
  const storedRouting = this.tokenStore.getDaemonRuntimeConfig(ROUTING_CONFIG_KEY)
172
288
  ?.config;
289
+ const storedFocusSet = this.tokenStore.getDaemonRuntimeConfig(FOCUS_SET_CONFIG_KEY)
290
+ ?.config;
173
291
  this.config = config;
174
292
  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;
293
+ this.selectionMode = selectionModeForRouting(this.sellerRouting);
294
+ this.selectedSellerId = selectedSellerIdForRouting(this.sellerRouting);
295
+ // tb-ui v1: explicit focus set 优先于 env / historical
296
+ if (storedFocusSet && Array.isArray(storedFocusSet.models)) {
297
+ const deduped = Array.from(new Set(storedFocusSet.models.map((m) => m.trim()).filter(Boolean)));
298
+ this.currentFocusSet = deduped.length > 0 ? deduped : null;
299
+ }
177
300
  // v1.2 §18.5: scheduler is created here (not in the field initializer)
178
301
  // because it needs the config-derived prober + idle interval.
179
302
  Object.assign(this, {
@@ -204,7 +327,22 @@ export class TokenbuddyDaemon {
204
327
  if (!res.ok) {
205
328
  return { ok: false, latencyMs: Date.now() - startedAt, httpStatus: res.status, errorMessage: `health returned ${res.status}` };
206
329
  }
207
- return { ok: true, latencyMs: Date.now() - startedAt, httpStatus: res.status };
330
+ const now = Date.now();
331
+ const body = await res.json();
332
+ const upstream = body.upstream;
333
+ const upstreamErrorClass = upstream?.lastErrorClass ?? upstream?.last_error_class;
334
+ return {
335
+ ok: true,
336
+ latencyMs: now - startedAt,
337
+ httpStatus: res.status,
338
+ upstreamStatus: typeof upstream?.status === "string"
339
+ ? upstream.status
340
+ : undefined,
341
+ upstreamErrorClass: typeof upstreamErrorClass === "string"
342
+ ? upstreamErrorClass
343
+ : undefined,
344
+ capacityBlockedUntil: capacityBlockedUntilFromHealth(body, now)
345
+ };
208
346
  }
209
347
  catch (err) {
210
348
  const message = err instanceof Error ? err.message : String(err);
@@ -220,6 +358,193 @@ export class TokenbuddyDaemon {
220
358
  const address = this.proxyServer?.address?.();
221
359
  return typeof address === "object" && address ? address.port : this.config.proxyPort;
222
360
  }
361
+ clawtipStaticDir() {
362
+ return path.join(path.dirname(this.config.dbPath), "static", "clawtip");
363
+ }
364
+ bundledClawtipStaticDir() {
365
+ if (this.config.clawtipBundledStaticDir === false) {
366
+ return undefined;
367
+ }
368
+ if (typeof this.config.clawtipBundledStaticDir === "string") {
369
+ return fs.existsSync(this.config.clawtipBundledStaticDir) ? this.config.clawtipBundledStaticDir : undefined;
370
+ }
371
+ const here = typeof __filename !== "undefined" ? path.dirname(__filename) : process.cwd();
372
+ const candidates = [
373
+ path.resolve(here, "../static/clawtip"),
374
+ path.resolve(here, "../../static/clawtip"),
375
+ path.resolve(process.cwd(), "packages/tokenbuddy-cli/static/clawtip")
376
+ ];
377
+ return candidates.find((candidate) => fs.existsSync(candidate));
378
+ }
379
+ clawtipPublicUrl(fileName) {
380
+ return `${CLAWTIP_STATIC_ROUTE}/${encodeURIComponent(fileName)}`;
381
+ }
382
+ ensureClawtipStaticAssets() {
383
+ const outputDir = this.clawtipStaticDir();
384
+ fs.mkdirSync(outputDir, { recursive: true });
385
+ const rechargeOutputPath = path.join(outputDir, CLAWTIP_RECHARGE_QR_FILE);
386
+ if (fs.existsSync(rechargeOutputPath)) {
387
+ return;
388
+ }
389
+ const bundledDir = this.bundledClawtipStaticDir();
390
+ const rechargeSourcePath = bundledDir ? path.join(bundledDir, CLAWTIP_RECHARGE_QR_FILE) : undefined;
391
+ if (rechargeSourcePath && fs.existsSync(rechargeSourcePath)) {
392
+ fs.copyFileSync(rechargeSourcePath, rechargeOutputPath);
393
+ }
394
+ }
395
+ copyClawtipQrToStatic(mediaPath, orderNo) {
396
+ if (!fs.existsSync(mediaPath)) {
397
+ throw new Error(`ClawTip QR image does not exist: ${mediaPath}`);
398
+ }
399
+ const extension = safeQrExtension(mediaPath);
400
+ const fileName = `${safeStaticFileSegment(orderNo)}-${Date.now()}${extension}`;
401
+ const outputDir = this.clawtipStaticDir();
402
+ fs.mkdirSync(outputDir, { recursive: true });
403
+ const outputPath = path.join(outputDir, fileName);
404
+ fs.copyFileSync(mediaPath, outputPath);
405
+ return {
406
+ fileName,
407
+ path: outputPath,
408
+ url: this.clawtipPublicUrl(fileName)
409
+ };
410
+ }
411
+ async startClawtipActivationQr() {
412
+ const bootstrapUrl = process.env.TOKENBUDDY_BOOTSTRAP_URL || "https://tb-wallet-bootstrap.fly.dev";
413
+ const fetchBootstrap = this.config.clawtipBootstrapFetcher || fetchClawtipBootstrap;
414
+ const bootstrap = await fetchBootstrap(bootstrapUrl);
415
+ const payment = normalizeClawtipActivationPayment(bootstrap);
416
+ const startBootstrap = this.config.clawtipWalletBootstrapStarter || startClawtipWalletBootstrap;
417
+ const activation = await startBootstrap(payment, { home: this.config.clawtipHomeDir });
418
+ if (!activation.parsedOutput.mediaPath) {
419
+ throw new Error("ClawTip activation did not return a QR image.");
420
+ }
421
+ const staticQr = this.copyClawtipQrToStatic(activation.parsedOutput.mediaPath, payment.orderNo);
422
+ const walletConfig = inspectOpenClawWalletConfig(this.config.clawtipHomeDir);
423
+ const existingPayment = this.tokenStore.getPayment("clawtip");
424
+ this.tokenStore.savePayment({
425
+ method: "clawtip",
426
+ enabled: walletConfig.exists,
427
+ isDefault: existingPayment?.isDefault ?? true,
428
+ config: {
429
+ ...(existingPayment?.config ?? {}),
430
+ bootstrapUrl,
431
+ orderNo: payment.orderNo,
432
+ amountFen: payment.amountFen,
433
+ indicator: payment.indicator,
434
+ slug: payment.slug,
435
+ skillId: payment.skillId,
436
+ description: payment.description,
437
+ resourceUrl: payment.resourceUrl,
438
+ activationOrderFile: activation.orderFile,
439
+ walletConfigPath: walletConfig.expectedPath,
440
+ walletConfigPresent: walletConfig.exists,
441
+ activationQrImagePath: activation.parsedOutput.mediaPath,
442
+ activationQrImageUrl: staticQr.url,
443
+ authUrl: activation.parsedOutput.authUrl,
444
+ clawtipId: activation.parsedOutput.clawtipId,
445
+ payCredentialWritten: Boolean(activation.payCredential),
446
+ activationCompletedBy: activation.payCredential
447
+ ? (walletConfig.exists ? "payCredential+wallet-config" : "payCredential")
448
+ : walletConfig.exists ? "wallet-config" : "pending-wallet-scan"
449
+ }
450
+ });
451
+ this.scheduleClawtipActivationWait(activation.parsedOutput.clawtipId);
452
+ return {
453
+ ok: true,
454
+ kind: "activate",
455
+ method: "clawtip",
456
+ orderNo: payment.orderNo,
457
+ amountFen: payment.amountFen,
458
+ qrImageUrl: staticQr.url,
459
+ sourceImagePath: activation.parsedOutput.mediaPath,
460
+ staticImagePath: staticQr.path,
461
+ authUrl: activation.parsedOutput.authUrl,
462
+ clawtipId: activation.parsedOutput.clawtipId,
463
+ orderFile: activation.orderFile,
464
+ walletConfigPath: walletConfig.expectedPath,
465
+ walletConfigPresent: walletConfig.exists,
466
+ requiresWalletAuth: activation.parsedOutput.requiresWalletAuth,
467
+ payCredentialWritten: Boolean(activation.payCredential)
468
+ };
469
+ }
470
+ scheduleClawtipActivationWait(clawtipId) {
471
+ if (this.clawtipActivationWait) {
472
+ return;
473
+ }
474
+ const cancelToken = { cancelled: false };
475
+ this.clawtipActivationWaitCancelToken = cancelToken;
476
+ const waitForActivation = this.config.clawtipActivationWaiter || waitForClawtipActivationConfirmation;
477
+ this.clawtipActivationWait = waitForActivation({
478
+ clawtipId,
479
+ inspectWalletConfig: () => inspectOpenClawWalletConfig(this.config.clawtipHomeDir),
480
+ isCancelled: () => cancelToken.cancelled,
481
+ cancel: () => undefined
482
+ })
483
+ .then((walletRegistered) => {
484
+ if (cancelToken.cancelled) {
485
+ return;
486
+ }
487
+ if (!walletRegistered) {
488
+ logger.info("control.payment.clawtip.activation_wait.pending", "ClawTip activation wait ended before wallet registration");
489
+ return;
490
+ }
491
+ const walletConfig = inspectOpenClawWalletConfig(this.config.clawtipHomeDir);
492
+ const payment = this.tokenStore.getPayment("clawtip");
493
+ if (!payment || payment.method !== "clawtip") {
494
+ return;
495
+ }
496
+ this.tokenStore.savePayment({
497
+ ...payment,
498
+ enabled: walletConfig.exists,
499
+ config: {
500
+ ...(payment.config ?? {}),
501
+ walletConfigPath: walletConfig.expectedPath,
502
+ walletConfigPresent: walletConfig.exists,
503
+ activationCompletedBy: walletConfig.exists
504
+ ? "wallet-config"
505
+ : readConfigString(payment.config, "activationCompletedBy") ?? "pending-wallet-scan"
506
+ }
507
+ });
508
+ logger.info("control.payment.clawtip.activation_wait.completed", "ClawTip activation wait completed", {
509
+ walletRegistered,
510
+ walletConfigPresent: walletConfig.exists
511
+ });
512
+ })
513
+ .catch((error) => {
514
+ const errorMessage = error instanceof Error ? error.message : String(error);
515
+ logger.warn("control.payment.clawtip.activation_wait.failed", "ClawTip activation wait failed", { errorMessage });
516
+ })
517
+ .finally(() => {
518
+ if (this.clawtipActivationWaitCancelToken === cancelToken) {
519
+ this.clawtipActivationWaitCancelToken = undefined;
520
+ this.clawtipActivationWait = undefined;
521
+ }
522
+ });
523
+ }
524
+ clawtipRechargeQr() {
525
+ const payment = this.tokenStore.getPayment("clawtip");
526
+ const resourceUrl = readConfigString(payment?.config, "resourceUrl");
527
+ const orderNo = readConfigString(payment?.config, "orderNo") || "clawtip-recharge";
528
+ const mediaPath = path.join(this.clawtipStaticDir(), CLAWTIP_RECHARGE_QR_FILE);
529
+ if (!fs.existsSync(mediaPath)) {
530
+ throw new Error(`ClawTip fixed recharge QR image is missing: ${mediaPath}`);
531
+ }
532
+ const walletConfig = inspectOpenClawWalletConfig(this.config.clawtipHomeDir);
533
+ return {
534
+ ok: true,
535
+ kind: "recharge",
536
+ method: "clawtip",
537
+ orderNo,
538
+ qrImageUrl: this.clawtipPublicUrl(CLAWTIP_RECHARGE_QR_FILE),
539
+ sourceImagePath: mediaPath,
540
+ staticImagePath: mediaPath,
541
+ resourceUrl,
542
+ walletConfigPath: walletConfig.expectedPath,
543
+ walletConfigPresent: walletConfig.exists,
544
+ requiresWalletAuth: false,
545
+ payCredentialWritten: readConfigBoolean(payment?.config, "payCredentialWritten")
546
+ };
547
+ }
223
548
  // v1.2 §18.9: stale-cache fallback. The buyer remembers the last
224
549
  // successfully fetched registry document and reuses it when the
225
550
  // bootstrap returns 413 (`X-TokenBuddy-Registry-Too-Large: 1`). This
@@ -259,6 +584,7 @@ export class TokenbuddyDaemon {
259
584
  }
260
585
  }
261
586
  runtimeSummary() {
587
+ this.refreshSellerRoutingConfig();
262
588
  return {
263
589
  status: "running",
264
590
  pid: process.pid,
@@ -363,11 +689,13 @@ export class TokenbuddyDaemon {
363
689
  // v1.2: registry is the source of truth for routing. We rebuild the
364
690
  // model-index once per request (cheap; index lookup is in-memory) so
365
691
  // the response always reflects the latest seller list. The previous
366
- // "fetchSellerManifest per candidate" path is removed in favor of
692
+ // "fetchSellerManifest per request" path is removed in favor of
367
693
  // pulling `models` directly off the registry entries.
368
694
  const registry = await this.fetchRegistry();
369
- const routing = this.sellerRouting;
695
+ const routing = resolveSellerRoutingForModel(this.refreshSellerRoutingConfig(), modelId);
370
696
  const registrySellers = reorderDefaultSellerFirst(registry.sellers, registry.defaultSeller);
697
+ this.sellerPool.ensureRegistrySellers(registrySellers);
698
+ this.scheduleLazyPrewarmIfNeeded(modelId, protocol, paymentMethod);
371
699
  const poolById = new Map(this.sellerPool.snapshot().map((entry) => [entry.sellerId, entry]));
372
700
  const planned = planSellerRouteSet({
373
701
  modelId,
@@ -380,8 +708,12 @@ export class TokenbuddyDaemon {
380
708
  sellerId: entry.sellerId,
381
709
  healthScore: entry.healthScore,
382
710
  avgLatencyMs: entry.avgLatencyMs,
383
- circuit: entry.circuit
384
- }))
711
+ ttftMs: entry.ttftMs,
712
+ avgInferenceMs: entry.avgInferenceMs,
713
+ circuit: entry.circuit,
714
+ capacityBlockedUntil: entry.capacityBlockedUntil
715
+ })),
716
+ now: Date.now()
385
717
  });
386
718
  if (planned.routes.length === 0) {
387
719
  throw new Error(`no compatible seller for ${endpoint} model ${modelId}`);
@@ -408,7 +740,109 @@ export class TokenbuddyDaemon {
408
740
  sellerCount: routes.length,
409
741
  sellers: routes.map((route) => route.seller.id)
410
742
  });
411
- return routes;
743
+ return { routes, plan: planned, paymentMethod };
744
+ }
745
+ refreshSellerRoutingConfig() {
746
+ const storedRouting = this.tokenStore.getDaemonRuntimeConfig(ROUTING_CONFIG_KEY)
747
+ ?.config;
748
+ const nextRouting = mergeSellerRoutingConfig(storedRouting, this.config.sellerRouting);
749
+ if (!sameSellerRouting(this.sellerRouting, nextRouting)) {
750
+ const previous = this.sellerRouting;
751
+ this.sellerRouting = nextRouting;
752
+ this.selectionMode = selectionModeForRouting(nextRouting);
753
+ this.selectedSellerId = selectedSellerIdForRouting(nextRouting);
754
+ logger.info("routing.config.reloaded", "seller routing config reloaded", {
755
+ previousMode: previous.mode,
756
+ previousScorer: previous.scorer,
757
+ sellerRoutingMode: nextRouting.mode,
758
+ sellerRoutingScorer: nextRouting.scorer,
759
+ selectedSellerId: this.selectedSellerId
760
+ });
761
+ void this.runRoutingPrewarmSweep(nextRouting);
762
+ }
763
+ return this.sellerRouting;
764
+ }
765
+ async runRoutingPrewarmSweep(routing) {
766
+ const focusSet = this.resolveFocusSet();
767
+ const routingPrewarmKey = `${routingKey(routing)}\u0001${focusSet.join("\u0001")}`;
768
+ if (focusSet.length === 0) {
769
+ logger.info("prewarm.routing.skipped", "no focus set configured after routing reload; relying on lazy prewarms", {
770
+ sellerRoutingMode: routing.mode,
771
+ sellerRoutingScorer: routing.scorer
772
+ });
773
+ return;
774
+ }
775
+ if (this.lastRoutingPrewarmKey === routingPrewarmKey) {
776
+ return;
777
+ }
778
+ this.lastRoutingPrewarmKey = routingPrewarmKey;
779
+ logger.info("prewarm.routing.scheduled", "routing reload prewarm sweep scheduled", {
780
+ sellerRoutingMode: routing.mode,
781
+ sellerRoutingScorer: routing.scorer,
782
+ focusSetSize: focusSet.length,
783
+ focusSet: focusSet.slice(0, 20)
784
+ });
785
+ try {
786
+ await this.fetchRegistry();
787
+ const paymentMethod = this.defaultPaymentMethod();
788
+ for (const modelId of focusSet) {
789
+ this.schedulePrewarmForModel({
790
+ modelId,
791
+ reason: "explicit",
792
+ protocol: this.resolvePrewarmProtocol(modelId, paymentMethod),
793
+ paymentMethod
794
+ });
795
+ }
796
+ }
797
+ catch (err) {
798
+ logger.warn("prewarm.routing.failed", "routing reload prewarm sweep failed", {
799
+ sellerRoutingMode: routing.mode,
800
+ sellerRoutingScorer: routing.scorer,
801
+ errorMessage: err instanceof Error ? err.message : String(err)
802
+ });
803
+ }
804
+ }
805
+ scheduleLazyPrewarmIfNeeded(modelId, protocol, paymentMethod) {
806
+ const freshness = this.prewarmCache.freshness(modelId, protocol, paymentMethod);
807
+ if (freshness.present && !freshness.expired) {
808
+ return;
809
+ }
810
+ const key = prewarmKey(modelId, protocol, paymentMethod);
811
+ if (this.lazyPrewarmKeys.has(key)) {
812
+ return;
813
+ }
814
+ this.lazyPrewarmKeys.add(key);
815
+ logger.info("prewarm.lazy.scheduled", "lazy prewarm scheduled for requested model", {
816
+ modelId,
817
+ protocol,
818
+ paymentMethod,
819
+ freshnessState: freshness.state
820
+ });
821
+ this.schedulePrewarmForModel({
822
+ modelId,
823
+ reason: "lazy",
824
+ protocol,
825
+ paymentMethod
826
+ }).finally(() => {
827
+ this.lazyPrewarmKeys.delete(key);
828
+ });
829
+ }
830
+ schedulePrewarmForModel(input) {
831
+ if (!input.protocol || !input.paymentMethod) {
832
+ logger.warn("prewarm.schedule.skipped", "prewarm schedule skipped because protocol or payment method is missing", {
833
+ modelId: input.modelId,
834
+ reason: input.reason,
835
+ protocol: input.protocol,
836
+ paymentMethod: input.paymentMethod
837
+ });
838
+ return Promise.resolve();
839
+ }
840
+ return this.prewarmScheduler.schedulePrewarm({
841
+ modelId: input.modelId,
842
+ reason: input.reason,
843
+ protocol: input.protocol,
844
+ paymentMethod: input.paymentMethod
845
+ });
412
846
  }
413
847
  failoverErrorMessage(error) {
414
848
  return error instanceof Error ? error.message : String(error);
@@ -422,13 +856,16 @@ export class TokenbuddyDaemon {
422
856
  * caller side because it short-circuits the failure path with a
423
857
  * re-purchase.
424
858
  */
425
- classifyFailureStatus(status) {
859
+ classifyFailureStatus(status, bodyText) {
426
860
  if (status === 401 || status === 403) {
427
861
  return "auth_invalid";
428
862
  }
429
863
  if (status === 402) {
430
864
  return "insufficient_funds";
431
865
  }
866
+ if (status === 429 && isBusyCapacityErrorBody(bodyText)) {
867
+ return "busy_capacity";
868
+ }
432
869
  if (status === 400 || status === 404 || status === 422) {
433
870
  return "hard_4xx";
434
871
  }
@@ -561,7 +998,7 @@ export class TokenbuddyDaemon {
561
998
  }
562
999
  return parseSellerSettlementObject(raw);
563
1000
  }
564
- recordReconciledInference(route, endpoint, requestId, usage, settlement, prompt, response) {
1001
+ recordReconciledInference(route, endpoint, requestId, usage, settlement, prompt, response, extras) {
565
1002
  if (settlement) {
566
1003
  this.tokenStore.reconcileTokenBalance({
567
1004
  sellerKey: route.seller.id,
@@ -589,7 +1026,14 @@ export class TokenbuddyDaemon {
589
1026
  balanceSnapshotMicros: settlement?.remainingCreditMicros,
590
1027
  balanceSource: settlement ? "seller_authoritative" : "estimated",
591
1028
  prompt,
592
- response
1029
+ response,
1030
+ ttftMs: extras?.ttftMs,
1031
+ fallbackCount: extras?.fallbackCount,
1032
+ routeReason: extras?.routeReason,
1033
+ falloverChain: extras?.falloverChain,
1034
+ upstreamStatus: extras?.upstreamStatus,
1035
+ durationMs: extras?.durationMs,
1036
+ paymentMethod: extras?.paymentMethod
593
1037
  });
594
1038
  logger.info("inference.ledger.recorded", "safe inference ledger recorded", {
595
1039
  requestId: settlement?.requestId || requestId,
@@ -604,7 +1048,14 @@ export class TokenbuddyDaemon {
604
1048
  promptTokens: usage.promptTokens,
605
1049
  completionTokens: usage.completionTokens,
606
1050
  balanceSnapshotMicros: settlement?.remainingCreditMicros,
607
- balanceSource: settlement ? "seller_authoritative" : "estimated"
1051
+ balanceSource: settlement ? "seller_authoritative" : "estimated",
1052
+ ttftMs: extras?.ttftMs,
1053
+ fallbackCount: extras?.fallbackCount,
1054
+ routeReason: extras?.routeReason,
1055
+ falloverChain: extras?.falloverChain,
1056
+ upstreamStatus: extras?.upstreamStatus,
1057
+ durationMs: extras?.durationMs,
1058
+ paymentMethod: extras?.paymentMethod
608
1059
  });
609
1060
  }
610
1061
  async refreshSellerBalance(route, token, balanceSource) {
@@ -1074,6 +1525,11 @@ export class TokenbuddyDaemon {
1074
1525
  }
1075
1526
  async forwardProxyRequest(endpoint, req, res) {
1076
1527
  const startedAt = Date.now();
1528
+ let firstByteAt = null;
1529
+ const markFirstByte = () => {
1530
+ if (firstByteAt === null)
1531
+ firstByteAt = Date.now();
1532
+ };
1077
1533
  const body = req.body || {};
1078
1534
  const { requestedModelId, resolvedModelId } = this.resolveRouteModelId(endpoint, body);
1079
1535
  const modelId = resolvedModelId;
@@ -1084,13 +1540,20 @@ export class TokenbuddyDaemon {
1084
1540
  return;
1085
1541
  }
1086
1542
  try {
1087
- const routes = await this.selectSellerRoutes(endpoint, modelId);
1543
+ const { routes, plan, paymentMethod } = await this.selectSellerRoutes(endpoint, modelId);
1544
+ const upstreamStatusFromHeaders = (h) => {
1545
+ const raw = h.get("x-tokenbuddy-upstream-status") || h.get("x-upstream-status");
1546
+ if (!raw)
1547
+ return undefined;
1548
+ return raw === "healthy" || raw === "degraded" || raw === "unhealthy" || raw === "unknown" ? raw : "unknown";
1549
+ };
1088
1550
  let lastError;
1089
1551
  for (let routeIndex = 0; routeIndex < routes.length; routeIndex += 1) {
1090
1552
  const route = routes[routeIndex];
1091
1553
  const sellerKey = route.seller.id;
1092
1554
  logger.info("route.selected", "seller route selected", {
1093
1555
  sellerKey,
1556
+ sellerId: sellerKey,
1094
1557
  model: modelId,
1095
1558
  endpoint,
1096
1559
  protocol: route.protocol,
@@ -1247,7 +1710,7 @@ export class TokenbuddyDaemon {
1247
1710
  status: upstreamResponse.status,
1248
1711
  durationMs: Date.now() - startedAt
1249
1712
  });
1250
- const kind = this.classifyFailureStatus(upstreamResponse.status);
1713
+ const kind = this.classifyFailureStatus(upstreamResponse.status, errorBody);
1251
1714
  const decision = this.routeFailover.decide({
1252
1715
  sellerId: sellerKey,
1253
1716
  status: upstreamResponse.status,
@@ -1317,6 +1780,7 @@ export class TokenbuddyDaemon {
1317
1780
  // 缺 event: 行)由卖方修,buyer 不兜底。
1318
1781
  const sellerChunk = settlementExtractor.push(chunk);
1319
1782
  if (sellerChunk.length > 0) {
1783
+ markFirstByte();
1320
1784
  res.write(sellerChunk);
1321
1785
  }
1322
1786
  }
@@ -1328,21 +1792,40 @@ export class TokenbuddyDaemon {
1328
1792
  if (decoderTail.length > 0) {
1329
1793
  const sellerTail = settlementExtractor.push(decoderTail);
1330
1794
  if (sellerTail.length > 0) {
1795
+ markFirstByte();
1331
1796
  res.write(sellerTail);
1332
1797
  }
1333
1798
  }
1334
1799
  const settlementTrailing = settlementExtractor.finish();
1335
1800
  if (settlementTrailing.downstream.length > 0) {
1801
+ markFirstByte();
1336
1802
  res.write(settlementTrailing.downstream);
1337
1803
  }
1338
1804
  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));
1805
+ 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, {
1806
+ ttftMs: firstByteAt ? firstByteAt - startedAt : undefined,
1807
+ fallbackCount: routeIndex,
1808
+ routeReason: plan.reason,
1809
+ falloverChain: routes.slice(0, routeIndex + 1).map((r) => r.seller.id),
1810
+ upstreamStatus: upstreamStatusFromHeaders(upstreamResponse.headers),
1811
+ durationMs: Date.now() - startedAt,
1812
+ paymentMethod
1813
+ });
1340
1814
  return;
1341
1815
  }
1342
1816
  const responseBody = await upstreamResponse.text();
1817
+ markFirstByte();
1343
1818
  res.send(responseBody);
1344
1819
  const usage = this.readUsage(responseBody);
1345
- this.recordReconciledInference(route, endpoint, requestId, usage, this.parseSellerSettlementSummary(upstreamResponse.headers), this.inferPromptForHash(body), responseBody);
1820
+ this.recordReconciledInference(route, endpoint, requestId, usage, this.parseSellerSettlementSummary(upstreamResponse.headers), this.inferPromptForHash(body), responseBody, {
1821
+ ttftMs: firstByteAt ? firstByteAt - startedAt : undefined,
1822
+ fallbackCount: routeIndex,
1823
+ routeReason: plan.reason,
1824
+ falloverChain: routes.slice(0, routeIndex + 1).map((r) => r.seller.id),
1825
+ upstreamStatus: upstreamStatusFromHeaders(upstreamResponse.headers),
1826
+ durationMs: Date.now() - startedAt,
1827
+ paymentMethod
1828
+ });
1346
1829
  return;
1347
1830
  }
1348
1831
  catch (routeError) {
@@ -1437,9 +1920,41 @@ export class TokenbuddyDaemon {
1437
1920
  controlApp.get("/payments", (req, res) => {
1438
1921
  logger.info("control.payments.requested", "control payments requested", {});
1439
1922
  res.status(200).json({
1440
- payments: this.tokenStore.listPayments()
1923
+ payments: this.tokenStore.listPayments().map((payment) => withLiveClawtipWalletState(payment, this.config.clawtipHomeDir))
1441
1924
  });
1442
1925
  });
1926
+ controlApp.post("/payments/clawtip/activate", async (req, res) => {
1927
+ try {
1928
+ const qr = await this.startClawtipActivationQr();
1929
+ logger.info("control.payment.clawtip.activate_qr.created", "ClawTip activation QR copied for tb-ui", {
1930
+ orderNo: qr.orderNo,
1931
+ qrImageUrl: qr.qrImageUrl,
1932
+ walletConfigPresent: qr.walletConfigPresent
1933
+ });
1934
+ res.status(200).json(qr);
1935
+ }
1936
+ catch (error) {
1937
+ const errorMessage = error instanceof Error ? error.message : String(error);
1938
+ logger.warn("control.payment.clawtip.activate_qr.failed", "ClawTip activation QR failed", { errorMessage });
1939
+ res.status(500).json({ error: { code: "clawtip_activate_qr_failed", message: errorMessage } });
1940
+ }
1941
+ });
1942
+ controlApp.post("/payments/clawtip/recharge", (req, res) => {
1943
+ try {
1944
+ const qr = this.clawtipRechargeQr();
1945
+ logger.info("control.payment.clawtip.recharge_qr.created", "ClawTip fixed recharge QR served for tb-ui", {
1946
+ orderNo: qr.orderNo,
1947
+ qrImageUrl: qr.qrImageUrl,
1948
+ walletConfigPresent: qr.walletConfigPresent
1949
+ });
1950
+ res.status(200).json(qr);
1951
+ }
1952
+ catch (error) {
1953
+ const errorMessage = error instanceof Error ? error.message : String(error);
1954
+ logger.warn("control.payment.clawtip.recharge_qr.failed", "ClawTip recharge QR failed", { errorMessage });
1955
+ res.status(500).json({ error: { code: "clawtip_recharge_qr_failed", message: errorMessage } });
1956
+ }
1957
+ });
1443
1958
  controlApp.get("/ledger/purchases", (req, res) => {
1444
1959
  logger.info("control.ledger.requested", "control purchase ledger requested", {
1445
1960
  ledger: "purchases"
@@ -1578,6 +2093,41 @@ export class TokenbuddyDaemon {
1578
2093
  });
1579
2094
  }
1580
2095
  });
2096
+ controlApp.get("/providers/status", (_req, res) => {
2097
+ try {
2098
+ const providerStatuses = detectProviders({ home: this.config.providerHomeDir }).map(clientToolStatusFromProvider);
2099
+ const clients = [
2100
+ ...providerStatuses,
2101
+ buildCustomClientToolStatus(this.activeProxyPort()),
2102
+ ];
2103
+ const configuredCount = clients.filter((client) => client.configured).length;
2104
+ const detectedCount = clients.filter((client) => client.detected && client.status !== "manual").length;
2105
+ logger.info("provider.status.requested", "provider status requested", {
2106
+ clientCount: clients.length,
2107
+ configuredCount,
2108
+ detectedCount
2109
+ });
2110
+ res.status(200).json({
2111
+ clients,
2112
+ summary: {
2113
+ configuredCount,
2114
+ detectedCount,
2115
+ totalCount: clients.length,
2116
+ installCommand: "tb init"
2117
+ }
2118
+ });
2119
+ }
2120
+ catch (error) {
2121
+ const errorMessage = error instanceof Error ? error.message : String(error);
2122
+ logger.warn("provider.status.failed", "provider status failed", { errorMessage });
2123
+ res.status(400).json({
2124
+ error: {
2125
+ code: "provider_status_failed",
2126
+ message: errorMessage
2127
+ }
2128
+ });
2129
+ }
2130
+ });
1581
2131
  controlApp.post("/providers/install/preview", (req, res) => {
1582
2132
  try {
1583
2133
  const changes = previewProviderInstall({
@@ -1655,6 +2205,169 @@ export class TokenbuddyDaemon {
1655
2205
  });
1656
2206
  }
1657
2207
  });
2208
+ // ─────────────────────────────────────────────────────────────────
2209
+ // tb-ui v1: 控制平面写端点(PR-0)
2210
+ // 所有端点都热生效,不需重启 daemon。改完 store 即下次推理生效。
2211
+ // ─────────────────────────────────────────────────────────────────
2212
+ // 1) GET /routing/strategy — 读当前路由策略 + 来源
2213
+ controlApp.get("/routing/strategy", (req, res) => {
2214
+ try {
2215
+ const stored = this.tokenStore.getDaemonRuntimeConfig(ROUTING_CONFIG_KEY)?.config;
2216
+ const current = this.refreshSellerRoutingConfig();
2217
+ const source = stored !== undefined ? "store" : this.config.sellerRouting ? "config" : "default";
2218
+ logger.info("routing.strategy.read", "routing strategy read", {
2219
+ source,
2220
+ mode: current.mode,
2221
+ scorer: current.scorer
2222
+ });
2223
+ res.status(200).json({ strategy: current, source });
2224
+ }
2225
+ catch (error) {
2226
+ const errorMessage = error instanceof Error ? error.message : String(error);
2227
+ logger.warn("routing.strategy.read_failed", "routing strategy read failed", { errorMessage });
2228
+ res.status(500).json({ error: { code: "routing_strategy_read_failed", message: errorMessage } });
2229
+ }
2230
+ });
2231
+ // 2) GET /routing/preview — 算「假如改完会怎样」,不改 state
2232
+ // query: modelId? protocol? paymentMethod? mode? scorer? sellerId? sellerIds?(逗号分隔)
2233
+ controlApp.get("/routing/preview", (req, res) => {
2234
+ try {
2235
+ const override = buildRoutingConfigFromQuery(req.query);
2236
+ const result = this.buildRoutingPreview({
2237
+ modelId: typeof req.query.modelId === "string" ? req.query.modelId : undefined,
2238
+ protocol: typeof req.query.protocol === "string" ? req.query.protocol : undefined,
2239
+ paymentMethod: typeof req.query.paymentMethod === "string" ? req.query.paymentMethod : undefined,
2240
+ routing: override ?? undefined
2241
+ });
2242
+ if ("error" in result.plan) {
2243
+ res.status(409).json({
2244
+ error: { code: result.plan.error, message: `cannot preview routing: ${result.plan.error}` },
2245
+ modelId: result.modelId,
2246
+ protocol: result.protocol,
2247
+ paymentMethod: result.paymentMethod
2248
+ });
2249
+ return;
2250
+ }
2251
+ res.status(200).json({
2252
+ modelId: result.modelId,
2253
+ protocol: result.protocol,
2254
+ paymentMethod: result.paymentMethod,
2255
+ plan: result.plan
2256
+ });
2257
+ }
2258
+ catch (error) {
2259
+ const errorMessage = error instanceof Error ? error.message : String(error);
2260
+ logger.warn("routing.preview.failed", "routing preview failed", { errorMessage });
2261
+ res.status(400).json({ error: { code: "routing_preview_failed", message: errorMessage } });
2262
+ }
2263
+ });
2264
+ // 3) PUT /routing/strategy — 写策略 + 热更新 + 返回 preview
2265
+ controlApp.put("/routing/strategy", (req, res) => {
2266
+ try {
2267
+ const body = (req.body ?? {});
2268
+ const normalized = normalizeSellerRoutingConfig(body);
2269
+ // 必填字段再次校验(normalize 会回退 default,但 PUT 必须显式)
2270
+ assertSellerRoutingConfig(normalized);
2271
+ this.tokenStore.saveDaemonRuntimeConfig(ROUTING_CONFIG_KEY, normalized);
2272
+ const current = this.refreshSellerRoutingConfig();
2273
+ logger.info("routing.strategy.applied", "routing strategy applied", {
2274
+ mode: current.mode,
2275
+ scorer: current.scorer,
2276
+ sellerId: current.sellerId,
2277
+ sellerIds: current.sellerIds
2278
+ });
2279
+ const preview = this.buildRoutingPreview({ routing: current });
2280
+ const previewPayload = "error" in preview.plan
2281
+ ? { error: preview.plan.error }
2282
+ : {
2283
+ modelId: preview.modelId,
2284
+ protocol: preview.protocol,
2285
+ paymentMethod: preview.paymentMethod,
2286
+ ...preview.plan
2287
+ };
2288
+ res.status(200).json({ applied: true, strategy: current, preview: previewPayload });
2289
+ }
2290
+ catch (error) {
2291
+ const errorMessage = error instanceof Error ? error.message : String(error);
2292
+ logger.warn("routing.strategy.apply_failed", "routing strategy apply failed", { errorMessage });
2293
+ res.status(400).json({ error: { code: "routing_strategy_apply_failed", message: errorMessage } });
2294
+ }
2295
+ });
2296
+ // 4) PUT /prewarm/focus-set — 设置 explicit focus set(覆盖 config.warmupModels / env)
2297
+ // body: { models: ["claude-3-5-sonnet", "gpt-4o"], clear?: false }
2298
+ // clear=true 时 models 数组可省略;表示回退 env / historical
2299
+ controlApp.put("/prewarm/focus-set", (req, res) => {
2300
+ try {
2301
+ const body = (req.body ?? {});
2302
+ const clear = body.clear === true;
2303
+ if (clear) {
2304
+ const result = this.applyFocusSet(null);
2305
+ res.status(200).json({ ok: true, ...result });
2306
+ return;
2307
+ }
2308
+ if (!Array.isArray(body.models)) {
2309
+ res.status(400).json({
2310
+ error: { code: "invalid_focus_set", message: "focus-set body must have a string[] `models` field, or `clear: true`" }
2311
+ });
2312
+ return;
2313
+ }
2314
+ const models = body.models
2315
+ .filter((m) => typeof m === "string")
2316
+ .map((m) => m.trim())
2317
+ .filter(Boolean);
2318
+ const result = this.applyFocusSet(models);
2319
+ res.status(200).json({ ok: true, ...result });
2320
+ }
2321
+ catch (error) {
2322
+ const errorMessage = error instanceof Error ? error.message : String(error);
2323
+ logger.warn("focus_set.apply_failed", "focus set apply failed", { errorMessage });
2324
+ res.status(400).json({ error: { code: "focus_set_apply_failed", message: errorMessage } });
2325
+ }
2326
+ });
2327
+ // 5) POST /daemon/restart — 优雅重启 tb-proxyd(调子进程 `tb daemon restart`)
2328
+ // 现有 CLI 子命令(cli.ts:1129)。detached 模式让子进程独立于 daemon 生命周期。
2329
+ controlApp.post("/daemon/restart", (req, res) => {
2330
+ try {
2331
+ const child = spawn("tb", ["daemon", "restart"], {
2332
+ detached: true,
2333
+ stdio: "ignore"
2334
+ });
2335
+ child.unref();
2336
+ logger.info("daemon.restart.scheduled", "daemon restart scheduled via tb CLI");
2337
+ res.status(202).json({ ok: true, message: "restart scheduled" });
2338
+ }
2339
+ catch (error) {
2340
+ const errorMessage = error instanceof Error ? error.message : String(error);
2341
+ logger.warn("daemon.restart.failed", "daemon restart failed", { errorMessage });
2342
+ res.status(500).json({ error: { code: "daemon_restart_failed", message: errorMessage } });
2343
+ }
2344
+ });
2345
+ this.ensureClawtipStaticAssets();
2346
+ controlApp.use(CLAWTIP_STATIC_ROUTE, express.static(this.clawtipStaticDir(), { index: false, fallthrough: false }));
2347
+ // ────────────────────────────────────────────────────────────
2348
+ // tb-ui v1: 静态托管 SPA(dist 由 `npm run build --workspace tb-ui` 生成)
2349
+ // 必须在所有 API 路由**之后**才挂载,这样:
2350
+ // - `/health` `/ledger/purchases` 等 API 路径仍由上面 17+ 个端点处理
2351
+ // - 真实静态文件(`/index.html` `/assets/index-abc.js` 等)由 express.static 服务
2352
+ // - 未匹配路径(`/overview` `/routing` 等 React Router 路径)走 SPA fallback 回 index.html
2353
+ // ────────────────────────────────────────────────────────────
2354
+ const uiDir = resolveUiDir();
2355
+ if (uiDir && fs.existsSync(uiDir) && fs.existsSync(path.join(uiDir, "index.html"))) {
2356
+ controlApp.use(express.static(uiDir, { index: "index.html", fallthrough: true }));
2357
+ // SPA fallback: React Router 用 BrowserRouter(history mode),客户端路径如
2358
+ // /overview /routing /ledger 不对应任何文件,必须回 index.html 让 React Router 接管。
2359
+ // Express 已经按注册顺序匹配了所有 17+ 个 API 路由,这里只接住"什么都没匹配"的 GET。
2360
+ controlApp.get(/^(?!\/(?:health|status|payments|sellers|models|ledger|routing|prewarm|v1\.2|daemon|providers|static)\/).*/, (_req, res) => {
2361
+ res.sendFile(path.join(uiDir, "index.html"));
2362
+ });
2363
+ logger.info("ui.static.attached", "tb-ui dist attached to control plane SPA fallback", { uiDir });
2364
+ }
2365
+ else {
2366
+ logger.warn("ui.static.missing", "tb-ui dist not found; only control plane API will be served", {
2367
+ uiDir,
2368
+ hint: "run `npm run build --workspace tb-ui` then restart"
2369
+ });
2370
+ }
1658
2371
  this.controlServer = controlApp.listen(this.config.controlPort);
1659
2372
  // 2. Proxy Plane Server (17821)
1660
2373
  const proxyApp = express();
@@ -1717,9 +2430,13 @@ export class TokenbuddyDaemon {
1717
2430
  /**
1718
2431
  * v1.2 §18.4: build the focus set from the explicit config, the env
1719
2432
  * override, and the historical usage in the buyer store. The order of
1720
- * precedence: explicit config > env > historical > empty.
2433
+ * precedence: explicit `currentFocusSet` (set via `PUT /prewarm/focus-set`)
2434
+ * > explicit config > env > historical > empty.
1721
2435
  */
1722
2436
  resolveFocusSet() {
2437
+ if (this.currentFocusSet !== null) {
2438
+ return this.currentFocusSet;
2439
+ }
1723
2440
  const explicit = this.config.warmupModels ?? [];
1724
2441
  if (explicit.length > 0) {
1725
2442
  return explicit;
@@ -1731,6 +2448,86 @@ export class TokenbuddyDaemon {
1731
2448
  }
1732
2449
  return this.tokenStore.recentModels(7, 5);
1733
2450
  }
2451
+ /**
2452
+ * tb-ui v1 `PUT /prewarm/focus-set` 调用的统一入口。`models === null`
2453
+ * 表示「清除 explicit focus set,回退 env/historical」。
2454
+ * 写 store + 触发 `runRoutingPrewarmSweep`,**热生效**(不需重启 daemon)。
2455
+ */
2456
+ applyFocusSet(models) {
2457
+ if (models === null) {
2458
+ this.tokenStore.removeDaemonRuntimeConfig(FOCUS_SET_CONFIG_KEY);
2459
+ this.currentFocusSet = null;
2460
+ }
2461
+ else {
2462
+ const deduped = Array.from(new Set(models.map((m) => m.trim()).filter(Boolean)));
2463
+ this.tokenStore.saveDaemonRuntimeConfig(FOCUS_SET_CONFIG_KEY, { models: deduped });
2464
+ this.currentFocusSet = deduped;
2465
+ }
2466
+ // 触发路由重读 + 预热 sweep(如果当前 routing 已是焦点集合的依赖)。
2467
+ this.refreshSellerRoutingConfig();
2468
+ const focusSet = this.resolveFocusSet();
2469
+ const source = this.currentFocusSet !== null
2470
+ ? "explicit"
2471
+ : (this.config.warmupModels?.length ?? 0) > 0
2472
+ ? "explicit"
2473
+ : (process.env.TB_BUYER_WARMUP_MODELS?.trim() ?? "").length > 0
2474
+ ? "env"
2475
+ : focusSet.length > 0
2476
+ ? "historical"
2477
+ : "empty";
2478
+ logger.info("focus_set.applied", "explicit focus set applied", {
2479
+ source,
2480
+ focusSetSize: focusSet.length,
2481
+ focusSet: focusSet.slice(0, 20)
2482
+ });
2483
+ return { focusSet, source };
2484
+ }
2485
+ /**
2486
+ * tb-ui v1 `GET /routing/preview` 和 `PUT /routing/strategy` 复用的 preview 计算。
2487
+ * 接受任意 routing 覆盖(来自 request body)算「假如改成这个,路由会是啥」。
2488
+ * 不修改任何内部 state,**纯函数式**。
2489
+ */
2490
+ buildRoutingPreview(input) {
2491
+ const registry = this.lastRegistrySnapshot;
2492
+ const focusFirst = this.resolveFocusSet()[0];
2493
+ const registryFirst = registry?.sellers[0]?.models?.[0];
2494
+ const modelId = input.modelId?.trim() || focusFirst || registryFirst || "";
2495
+ const protocol = input.protocol?.trim() || "chat_completions";
2496
+ const paymentMethod = input.paymentMethod?.trim() || this.defaultPaymentMethod() || "clawtip";
2497
+ if (!modelId) {
2498
+ return { modelId, protocol, paymentMethod, plan: { error: "no_focus_model_available" } };
2499
+ }
2500
+ if (!registry) {
2501
+ return { modelId, protocol, paymentMethod, plan: { error: "registry_not_loaded" } };
2502
+ }
2503
+ const current = this.refreshSellerRoutingConfig();
2504
+ const routing = input.routing
2505
+ ? mergeSellerRoutingConfig(current, input.routing)
2506
+ : current;
2507
+ const resolvedRouting = resolveSellerRoutingForModel(routing, modelId);
2508
+ const registrySellers = reorderDefaultSellerFirst(registry.sellers, registry.defaultSeller);
2509
+ this.sellerPool.ensureRegistrySellers(registrySellers);
2510
+ const poolById = new Map(this.sellerPool.snapshot().map((entry) => [entry.sellerId, entry]));
2511
+ const plan = planSellerRouteSet({
2512
+ modelId,
2513
+ protocol,
2514
+ paymentMethod,
2515
+ registrySellers,
2516
+ routing: resolvedRouting,
2517
+ prewarmCandidates: this.prewarmCache.get(modelId, protocol, paymentMethod)?.candidates,
2518
+ sellerMetrics: Array.from(poolById.values()).map((entry) => ({
2519
+ sellerId: entry.sellerId,
2520
+ healthScore: entry.healthScore,
2521
+ avgLatencyMs: entry.avgLatencyMs,
2522
+ ttftMs: entry.ttftMs,
2523
+ avgInferenceMs: entry.avgInferenceMs,
2524
+ circuit: entry.circuit,
2525
+ capacityBlockedUntil: entry.capacityBlockedUntil
2526
+ })),
2527
+ now: Date.now()
2528
+ });
2529
+ return { modelId, protocol, paymentMethod, plan };
2530
+ }
1734
2531
  async runStartupPrewarmSweep() {
1735
2532
  const focusSet = this.resolveFocusSet();
1736
2533
  if (focusSet.length === 0) {
@@ -1745,7 +2542,7 @@ export class TokenbuddyDaemon {
1745
2542
  await this.fetchRegistry();
1746
2543
  await this.prewarmScheduler.runStartupPrewarm(focusSet.map((modelId) => ({
1747
2544
  modelId,
1748
- protocol: this.resolvePrewarmProtocol(modelId)
2545
+ protocol: this.resolvePrewarmProtocol(modelId, this.defaultPaymentMethod())
1749
2546
  })));
1750
2547
  }
1751
2548
  catch (err) {
@@ -1754,15 +2551,18 @@ export class TokenbuddyDaemon {
1754
2551
  });
1755
2552
  }
1756
2553
  }
1757
- resolvePrewarmProtocol(modelId) {
2554
+ resolvePrewarmProtocol(modelId, paymentMethod = "clawtip") {
1758
2555
  for (const protocol of ["chat_completions", "messages", "responses"]) {
1759
- if (this.modelIndex.sellersFor(modelId, { protocol, paymentMethod: "clawtip" }).length > 0) {
2556
+ if (this.modelIndex.sellersFor(modelId, { protocol, paymentMethod }).length > 0) {
1760
2557
  return protocol;
1761
2558
  }
1762
2559
  }
1763
2560
  return undefined;
1764
2561
  }
1765
2562
  stop() {
2563
+ if (this.clawtipActivationWaitCancelToken) {
2564
+ this.clawtipActivationWaitCancelToken.cancelled = true;
2565
+ }
1766
2566
  if (this.controlServer)
1767
2567
  this.controlServer.close();
1768
2568
  if (this.proxyServer)
@@ -1770,5 +2570,166 @@ export class TokenbuddyDaemon {
1770
2570
  void this.prewarmScheduler.stop();
1771
2571
  this.tokenStore.close();
1772
2572
  }
2573
+ /**
2574
+ * @internal — test-only seam to inject a registry snapshot without
2575
+ * hitting the network. Used by `tests/control-plane-ui-endpoints.test.ts`
2576
+ * to drive `buildRoutingPreview` deterministically. Production code
2577
+ * must NOT call this; the real `fetchRegistry()` populates the snapshot.
2578
+ */
2579
+ setLastRegistrySnapshotForTest(snapshot) {
2580
+ this.lastRegistrySnapshot = snapshot;
2581
+ }
2582
+ }
2583
+ function selectionModeForRouting(routing) {
2584
+ return routing.mode === "fullAuto" ? "auto" : "manual";
2585
+ }
2586
+ function withLiveClawtipWalletState(payment, home) {
2587
+ if (payment.method !== "clawtip") {
2588
+ return payment;
2589
+ }
2590
+ const walletConfig = inspectOpenClawWalletConfig(home);
2591
+ return {
2592
+ ...payment,
2593
+ enabled: payment.enabled && walletConfig.exists,
2594
+ config: {
2595
+ ...(payment.config ?? {}),
2596
+ walletConfigPath: walletConfig.expectedPath,
2597
+ walletConfigPresent: walletConfig.exists,
2598
+ nearbyWalletConfigPaths: walletConfig.alternatePaths
2599
+ }
2600
+ };
2601
+ }
2602
+ function normalizeClawtipActivationPayment(bootstrap) {
2603
+ if (!bootstrap.payment?.orderNo || !bootstrap.payment.indicator || !bootstrap.payment.resourceUrl) {
2604
+ throw new Error("ClawTip bootstrap response missing orderNo, indicator, or resourceUrl.");
2605
+ }
2606
+ return {
2607
+ orderNo: bootstrap.payment.orderNo,
2608
+ amountFen: bootstrap.payment.amountFen ?? bootstrap.activationFeeFen ?? 1,
2609
+ payTo: bootstrap.payment.payTo,
2610
+ encryptedData: bootstrap.payment.encryptedData,
2611
+ indicator: bootstrap.payment.indicator,
2612
+ slug: bootstrap.payment.slug,
2613
+ skillId: bootstrap.payment.skillId,
2614
+ description: bootstrap.payment.description,
2615
+ resourceUrl: bootstrap.payment.resourceUrl,
2616
+ };
2617
+ }
2618
+ function safeQrExtension(filePath) {
2619
+ const extension = path.extname(filePath).toLowerCase();
2620
+ if (extension === ".jpg" || extension === ".jpeg" || extension === ".webp") {
2621
+ return extension;
2622
+ }
2623
+ return ".png";
2624
+ }
2625
+ function safeStaticFileSegment(value) {
2626
+ return value.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "clawtip";
2627
+ }
2628
+ function readConfigString(config, key) {
2629
+ const value = config?.[key];
2630
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
2631
+ }
2632
+ function readConfigBoolean(config, key) {
2633
+ return config?.[key] === true;
2634
+ }
2635
+ function selectedSellerIdForRouting(routing) {
2636
+ return routing.mode === "fixed" ? routing.sellerId : undefined;
2637
+ }
2638
+ function routingKey(routing) {
2639
+ const fixedByModel = Object.entries(routing.fixedByModel ?? {})
2640
+ .sort(([left], [right]) => left.localeCompare(right))
2641
+ .map(([modelId, sellerId]) => `${modelId}:${sellerId}`);
2642
+ return [
2643
+ routing.mode,
2644
+ routing.scorer,
2645
+ routing.sellerId ?? "",
2646
+ ...(routing.sellerIds ?? []),
2647
+ ...fixedByModel
2648
+ ].join("\u0001");
2649
+ }
2650
+ /**
2651
+ * 从 query string 构造 `BuyerSellerRoutingConfig` override(用于 GET /routing/preview)。
2652
+ * 接受 `mode` / `scorer` / `sellerId` / `sellerIds`(逗号分隔)。
2653
+ * 任一字段缺失返回 `undefined`,调用方走「用当前 routing」分支。
2654
+ * mode / scorer 非法抛 400,由端点 handler 捕获。
2655
+ */
2656
+ function buildRoutingConfigFromQuery(query) {
2657
+ const mode = typeof query.mode === "string" ? query.mode.trim() : "";
2658
+ const scorer = typeof query.scorer === "string" ? query.scorer.trim() : "";
2659
+ const sellerId = typeof query.sellerId === "string" ? query.sellerId.trim() : "";
2660
+ const sellerIdsRaw = typeof query.sellerIds === "string" ? query.sellerIds.trim() : "";
2661
+ const fixedByModelRaw = typeof query.fixedByModel === "string" ? query.fixedByModel.trim() : "";
2662
+ if (!mode && !scorer && !sellerId && !sellerIdsRaw && !fixedByModelRaw) {
2663
+ return undefined;
2664
+ }
2665
+ const override = {};
2666
+ if (mode) {
2667
+ if (mode !== "fixed" && mode !== "fixedSet" && mode !== "fullAuto") {
2668
+ throw new Error("mode must be fixed, fixedSet, or fullAuto");
2669
+ }
2670
+ override.mode = mode;
2671
+ }
2672
+ if (scorer) {
2673
+ if (scorer !== "speed" && scorer !== "discount" && scorer !== "balanced") {
2674
+ throw new Error("scorer must be speed, discount, or balanced");
2675
+ }
2676
+ override.scorer = scorer;
2677
+ }
2678
+ if (sellerId) {
2679
+ override.sellerId = sellerId;
2680
+ }
2681
+ if (sellerIdsRaw) {
2682
+ override.sellerIds = parseSellerIdList(sellerIdsRaw);
2683
+ }
2684
+ if (fixedByModelRaw) {
2685
+ override.fixedByModel = parseFixedByModel(fixedByModelRaw);
2686
+ }
2687
+ return override;
2688
+ }
2689
+ function sameSellerRouting(a, b) {
2690
+ return a.mode === b.mode
2691
+ && a.scorer === b.scorer
2692
+ && optionalStringEqual(a.sellerId, b.sellerId)
2693
+ && stringArraysEqual(a.sellerIds ?? [], b.sellerIds ?? [])
2694
+ && fixedByModelEqual(a.fixedByModel ?? {}, b.fixedByModel ?? {});
2695
+ }
2696
+ function optionalStringEqual(a, b) {
2697
+ return (a ?? "") === (b ?? "");
2698
+ }
2699
+ function stringArraysEqual(a, b) {
2700
+ if (a.length !== b.length) {
2701
+ return false;
2702
+ }
2703
+ return a.every((entry, index) => entry === b[index]);
2704
+ }
2705
+ function resolveSellerRoutingForModel(routing, modelId) {
2706
+ if (routing.mode !== "fixed") {
2707
+ return routing;
2708
+ }
2709
+ const fixedSellerId = routing.fixedByModel?.[modelId]?.trim() || routing.sellerId;
2710
+ return {
2711
+ mode: "fixed",
2712
+ scorer: routing.scorer,
2713
+ sellerId: fixedSellerId,
2714
+ fixedByModel: routing.fixedByModel
2715
+ };
2716
+ }
2717
+ function parseFixedByModel(value) {
2718
+ const entries = value
2719
+ .split(",")
2720
+ .map((entry) => entry.split(":"))
2721
+ .filter((parts) => parts.length === 2)
2722
+ .map(([modelId, sellerId]) => [modelId.trim(), sellerId.trim()])
2723
+ .filter(([modelId, sellerId]) => modelId.length > 0 && sellerId.length > 0);
2724
+ return Object.fromEntries(entries);
2725
+ }
2726
+ function fixedByModelEqual(a, b) {
2727
+ const aEntries = Object.entries(a).sort(([left], [right]) => left.localeCompare(right));
2728
+ const bEntries = Object.entries(b).sort(([left], [right]) => left.localeCompare(right));
2729
+ return aEntries.length === bEntries.length
2730
+ && aEntries.every(([modelId, sellerId], index) => {
2731
+ const [otherModelId, otherSellerId] = bEntries[index] ?? [];
2732
+ return modelId === otherModelId && sellerId === otherSellerId;
2733
+ });
1773
2734
  }
1774
2735
  //# sourceMappingURL=daemon.js.map