@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
package/src/daemon.ts CHANGED
@@ -2,15 +2,30 @@ import express, { Request, Response } 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";
5
6
  import { AddressInfo } from "net";
7
+ import { ErrorCode } from "@tokenbuddy/contracts";
6
8
  import { createModuleLogger } from "@tokenbuddy/logging";
7
- import { BuyerStore } from "./buyer-store.js";
9
+ import { BuyerStore, type PaymentConfig } from "./buyer-store.js";
10
+ import { fetchClawtipBootstrap } from "./clawtip-bootstrap.js";
11
+ import type { ClawtipBootstrapResponse } from "./clawtip-bootstrap.js";
12
+ import {
13
+ inspectOpenClawWalletConfig,
14
+ } from "./init-payment-options.js";
15
+ import {
16
+ startClawtipWalletBootstrap,
17
+ waitForClawtipActivationConfirmation,
18
+ type ClawtipBootstrapPayment,
19
+ type ParsedClawtipOutput,
20
+ type WaitForClawtipActivationOptions,
21
+ } from "./init-clawtip-activation.js";
8
22
  import {
9
23
  applyProviderInstall,
10
24
  detectProviders,
11
25
  previewProviderInstall,
12
26
  rollbackProviderInstall,
13
27
  type ClaudeCodeModelMappingConfig,
28
+ type ProviderCandidate,
14
29
  } from "./provider-install.js";
15
30
  import {
16
31
  discoverSellerBackedModels,
@@ -25,21 +40,122 @@ import {
25
40
  type SellerRegistryDocument,
26
41
  } from "./seller-catalog.js";
27
42
  import { ModelIndex } from "./model-index.js";
28
- import { PrewarmCache } from "./prewarm-cache.js";
43
+ import { PrewarmCache, prewarmKey } from "./prewarm-cache.js";
29
44
  import { CreditTracker } from "./credit-tracker.js";
30
45
  import { SellerPool, type FailureKind } from "./seller-pool.js";
31
46
  import { RouteFailover, type FailoverDecision, type RouteCandidate } from "./route-failover.js";
32
- import { PrewarmScheduler, type SellerProber } from "./prewarm-scheduler.js";
47
+ import { PrewarmScheduler, type PrewarmReason, type SellerProber } from "./prewarm-scheduler.js";
33
48
  import type { PoolEntry } from "./seller-pool.js";
34
49
  import { planSellerRouteSet } from "./seller-route-planner.js";
50
+ import type { SellerRoutePlan } from "./seller-route-planner.js";
35
51
  import {
52
+ assertSellerRoutingConfig,
36
53
  mergeSellerRoutingConfig,
54
+ normalizeSellerRoutingConfig,
55
+ parseSellerIdList,
37
56
  ROUTING_CONFIG_KEY,
38
57
  type BuyerSellerRoutingConfig
39
58
  } from "./seller-routing-config.js";
40
59
 
41
60
  const logger = createModuleLogger("tb-proxyd");
61
+ const FOCUS_SET_CONFIG_KEY = "focus-set";
42
62
  const PROXY_JSON_BODY_LIMIT = "10mb";
63
+ const SELLER_CAPACITY_BLOCK_MS = 2_000;
64
+ const CLAWTIP_STATIC_ROUTE = "/static/clawtip";
65
+ const CLAWTIP_RECHARGE_QR_FILE = "recharge.png";
66
+
67
+ function currentModuleDir(): string {
68
+ if (typeof __dirname !== "undefined") {
69
+ return __dirname;
70
+ }
71
+
72
+ const stack = new Error().stack || "";
73
+ const fileUrlMatch = stack.match(/(file:\/\/\/[^)\n]+\/daemon\.js):\d+:\d+/);
74
+ if (fileUrlMatch) {
75
+ return path.dirname(new URL(fileUrlMatch[1]).pathname);
76
+ }
77
+
78
+ const filePathMatch = stack.match(/(\/[^)\n]+\/daemon\.(?:js|ts)):\d+:\d+/);
79
+ if (filePathMatch) {
80
+ return path.dirname(filePathMatch[1]);
81
+ }
82
+
83
+ return process.cwd();
84
+ }
85
+
86
+ interface ClientToolStatus {
87
+ id: string;
88
+ name: string;
89
+ status: ProviderCandidate["status"] | "manual";
90
+ detected: boolean;
91
+ configured: boolean;
92
+ configPath?: string;
93
+ commandName?: string;
94
+ reason: string;
95
+ manualConfig?: {
96
+ openaiBaseUrl: string;
97
+ anthropicBaseUrl: string;
98
+ apiKey: string;
99
+ };
100
+ }
101
+
102
+ function clientToolStatusFromProvider(provider: ProviderCandidate): ClientToolStatus {
103
+ return {
104
+ id: provider.id,
105
+ name: provider.name,
106
+ status: provider.status,
107
+ detected: provider.detected,
108
+ configured: provider.configured,
109
+ configPath: provider.configPath,
110
+ commandName: provider.commandName,
111
+ reason: provider.reason,
112
+ };
113
+ }
114
+
115
+ function buildCustomClientToolStatus(proxyPort: number): ClientToolStatus {
116
+ const openaiBaseUrl = `http://127.0.0.1:${proxyPort}/v1`;
117
+ const anthropicBaseUrl = `http://127.0.0.1:${proxyPort}`;
118
+ return {
119
+ id: "custom",
120
+ name: "Custom client",
121
+ status: "manual",
122
+ detected: true,
123
+ configured: false,
124
+ reason: `OpenAI-compatible: ${openaiBaseUrl} · Anthropic-compatible: ${anthropicBaseUrl}`,
125
+ manualConfig: {
126
+ openaiBaseUrl,
127
+ anthropicBaseUrl,
128
+ apiKey: "TOKENBUDDY_PROXY",
129
+ },
130
+ };
131
+ }
132
+
133
+ /**
134
+ * 解析 `tb-ui` 构建产物目录(daemon 静态托管 SPA 用)。
135
+ * 优先级:env TB_UI_DIR > npm 包内 static/ui > workspace tb-ui/dist。
136
+ * 找不到时记录 warning 仍允许 daemon 启动(纯 API 模式);静态请求会 404。
137
+ */
138
+ function resolveUiDir(): string | undefined {
139
+ if (process.env.TB_UI_DIR) return process.env.TB_UI_DIR;
140
+ const here = currentModuleDir();
141
+ const bundledUiDirs = [
142
+ path.resolve(here, "../../static/ui"),
143
+ path.resolve(here, "../static/ui")
144
+ ];
145
+ for (const bundledUiDir of bundledUiDirs) {
146
+ if (fs.existsSync(path.join(bundledUiDir, "index.html"))) {
147
+ return bundledUiDir;
148
+ }
149
+ }
150
+ try {
151
+ // require.resolve 在 npm workspaces 装好时能找到 tb-ui/package.json
152
+ const pkgPath = require.resolve("tb-ui/package.json");
153
+ return path.join(path.dirname(pkgPath), "dist");
154
+ } catch {
155
+ // fallback: monorepo 假设 daemon dist 跟 tb-ui/dist 在同 root
156
+ return path.resolve(here, "../../tb-ui/dist");
157
+ }
158
+ }
43
159
 
44
160
  /**
45
161
  * buyer 端守护进程(`tb-proxyd`)的配置。
@@ -56,6 +172,21 @@ export interface DaemonConfig {
56
172
  sellerRegistryUrl: string;
57
173
  /** 路由策略覆盖(与 `TB_SELLER_ROUTING_*` env 合并) */
58
174
  sellerRouting?: BuyerSellerRoutingConfig;
175
+ /** test-only override for ClawTip bootstrap payload fetch. */
176
+ clawtipBootstrapFetcher?: (bootstrapUrl: string) => Promise<ClawtipBootstrapResponse>;
177
+ /** test-only override for the existing tb init ClawTip activation runner. */
178
+ clawtipWalletBootstrapStarter?: (
179
+ payment: ClawtipBootstrapPayment,
180
+ options?: { home?: string }
181
+ ) => Promise<{ orderFile: string; parsedOutput: ParsedClawtipOutput; payCredential?: string }>;
182
+ /** test-only override for waiting on the existing tb init ClawTip registration loop. */
183
+ clawtipActivationWaiter?: (options?: WaitForClawtipActivationOptions) => Promise<boolean>;
184
+ /** test-only override for bundled ClawTip static assets; false disables bundled assets. */
185
+ clawtipBundledStaticDir?: string | false;
186
+ /** test-only home override for OpenClaw wallet and QR discovery. */
187
+ clawtipHomeDir?: string;
188
+ /** test-only home override for provider/client configuration detection. */
189
+ providerHomeDir?: string;
59
190
  /**
60
191
  * v1.2 §18.4 预热 focus-set 覆盖。
61
192
  * 缺省时由 BuyerStore 历史模型使用情况 + `TB_BUYER_WARMUP_MODELS` env 推导。
@@ -94,6 +225,24 @@ interface ProxyBodySummary {
94
225
  temperaturePresent?: boolean;
95
226
  }
96
227
 
228
+ interface SellerHealthBody {
229
+ upstream?: {
230
+ status?: unknown;
231
+ lastErrorClass?: unknown;
232
+ last_error_class?: unknown;
233
+ };
234
+ capacity?: {
235
+ activeConnections?: unknown;
236
+ active_connections?: unknown;
237
+ maxConnections?: unknown;
238
+ max_connections?: unknown;
239
+ queueDepth?: unknown;
240
+ queue_depth?: unknown;
241
+ maxQueueDepth?: unknown;
242
+ max_queue_depth?: unknown;
243
+ };
244
+ }
245
+
97
246
  interface SellerSettlementSummary {
98
247
  requestId: string;
99
248
  settledMicros: number;
@@ -227,6 +376,60 @@ function summarizeProxyBody(body: unknown): ProxyBodySummary {
227
376
  };
228
377
  }
229
378
 
379
+ function finiteNumber(value: unknown): number | undefined {
380
+ if (typeof value === "number" && Number.isFinite(value)) {
381
+ return value;
382
+ }
383
+ if (typeof value === "string" && value.trim().length > 0) {
384
+ const parsed = Number(value);
385
+ return Number.isFinite(parsed) ? parsed : undefined;
386
+ }
387
+ return undefined;
388
+ }
389
+
390
+ function readErrorCode(bodyText: string): string | undefined {
391
+ try {
392
+ const parsed = JSON.parse(bodyText) as { error?: { code?: unknown }; code?: unknown };
393
+ const code = parsed.error?.code ?? parsed.code;
394
+ return typeof code === "string" ? code : undefined;
395
+ } catch {
396
+ return undefined;
397
+ }
398
+ }
399
+
400
+ function isBusyCapacityErrorBody(bodyText: string | undefined): boolean {
401
+ if (!bodyText) {
402
+ return false;
403
+ }
404
+ return readErrorCode(bodyText) === ErrorCode.BusyCapacity;
405
+ }
406
+
407
+ function capacityBlockedUntilFromHealth(body: SellerHealthBody, now: number): number | undefined {
408
+ const capacity = body.capacity;
409
+ if (!capacity) {
410
+ return undefined;
411
+ }
412
+ const activeConnections = finiteNumber(capacity.activeConnections ?? capacity.active_connections);
413
+ const maxConnections = finiteNumber(capacity.maxConnections ?? capacity.max_connections);
414
+ const queueDepth = finiteNumber(capacity.queueDepth ?? capacity.queue_depth);
415
+ const maxQueueDepth = finiteNumber(capacity.maxQueueDepth ?? capacity.max_queue_depth);
416
+ if (
417
+ activeConnections === undefined ||
418
+ maxConnections === undefined ||
419
+ queueDepth === undefined ||
420
+ maxQueueDepth === undefined
421
+ ) {
422
+ return undefined;
423
+ }
424
+ if (maxConnections <= 0) {
425
+ return undefined;
426
+ }
427
+ const connectionsFull = activeConnections >= maxConnections;
428
+ const queueUnavailable = maxQueueDepth <= 0;
429
+ const queueFull = queueUnavailable || queueDepth >= maxQueueDepth;
430
+ return connectionsFull && queueFull ? now + SELLER_CAPACITY_BLOCK_MS : undefined;
431
+ }
432
+
230
433
  function reorderDefaultSellerFirst(sellers: RegistrySeller[], defaultSellerId: string | undefined): RegistrySeller[] {
231
434
  if (!defaultSellerId) {
232
435
  return sellers;
@@ -275,6 +478,15 @@ export class TokenbuddyDaemon {
275
478
  private selectionMode: "auto" | "manual";
276
479
  private selectedSellerId?: string;
277
480
  private sellerRouting: BuyerSellerRoutingConfig;
481
+ private lastRoutingPrewarmKey?: string;
482
+ private readonly lazyPrewarmKeys = new Set<string>();
483
+ private clawtipActivationWait?: Promise<void>;
484
+ private clawtipActivationWaitCancelToken?: { cancelled: boolean };
485
+ /**
486
+ * tb-ui v1 控制平面 `PUT /prewarm/focus-set` 写入的 explicit focus set。
487
+ * 优先级最高;`null` 表示回退到 env / historical(与 `resolveFocusSet()` 原行为一致)。
488
+ */
489
+ private currentFocusSet: string[] | null = null;
278
490
 
279
491
  private activePurchases = new Map<string, Promise<string>>();
280
492
 
@@ -302,13 +514,20 @@ export class TokenbuddyDaemon {
302
514
  this.tokenStore = new BuyerStore({ dbPath: config.dbPath });
303
515
  const storedRouting = this.tokenStore.getDaemonRuntimeConfig<unknown>(ROUTING_CONFIG_KEY)
304
516
  ?.config;
517
+ const storedFocusSet = this.tokenStore.getDaemonRuntimeConfig<{ models?: string[] }>(FOCUS_SET_CONFIG_KEY)
518
+ ?.config;
305
519
  this.config = config;
306
520
  this.sellerRouting = mergeSellerRoutingConfig(
307
521
  storedRouting,
308
522
  config.sellerRouting
309
523
  );
310
- this.selectionMode = this.sellerRouting.mode === "fullAuto" ? "auto" : "manual";
311
- this.selectedSellerId = this.sellerRouting.mode === "fixed" ? this.sellerRouting.sellerId : undefined;
524
+ this.selectionMode = selectionModeForRouting(this.sellerRouting);
525
+ this.selectedSellerId = selectedSellerIdForRouting(this.sellerRouting);
526
+ // tb-ui v1: explicit focus set 优先于 env / historical
527
+ if (storedFocusSet && Array.isArray(storedFocusSet.models)) {
528
+ const deduped = Array.from(new Set(storedFocusSet.models.map((m) => m.trim()).filter(Boolean)));
529
+ this.currentFocusSet = deduped.length > 0 ? deduped : null;
530
+ }
312
531
  // v1.2 §18.5: scheduler is created here (not in the field initializer)
313
532
  // because it needs the config-derived prober + idle interval.
314
533
  Object.assign(this, {
@@ -339,7 +558,22 @@ export class TokenbuddyDaemon {
339
558
  if (!res.ok) {
340
559
  return { ok: false, latencyMs: Date.now() - startedAt, httpStatus: res.status, errorMessage: `health returned ${res.status}` };
341
560
  }
342
- return { ok: true, latencyMs: Date.now() - startedAt, httpStatus: res.status };
561
+ const now = Date.now();
562
+ const body = await res.json() as SellerHealthBody;
563
+ const upstream = body.upstream;
564
+ const upstreamErrorClass = upstream?.lastErrorClass ?? upstream?.last_error_class;
565
+ return {
566
+ ok: true,
567
+ latencyMs: now - startedAt,
568
+ httpStatus: res.status,
569
+ upstreamStatus: typeof upstream?.status === "string"
570
+ ? upstream.status as "healthy" | "degraded" | "unhealthy" | "unknown"
571
+ : undefined,
572
+ upstreamErrorClass: typeof upstreamErrorClass === "string"
573
+ ? upstreamErrorClass
574
+ : undefined,
575
+ capacityBlockedUntil: capacityBlockedUntilFromHealth(body, now)
576
+ };
343
577
  } catch (err) {
344
578
  const message = err instanceof Error ? err.message : String(err);
345
579
  return { ok: false, latencyMs: 0, errorMessage: message };
@@ -357,6 +591,201 @@ export class TokenbuddyDaemon {
357
591
  return typeof address === "object" && address ? address.port : this.config.proxyPort;
358
592
  }
359
593
 
594
+ private clawtipStaticDir(): string {
595
+ return path.join(path.dirname(this.config.dbPath), "static", "clawtip");
596
+ }
597
+
598
+ private bundledClawtipStaticDir(): string | undefined {
599
+ if (this.config.clawtipBundledStaticDir === false) {
600
+ return undefined;
601
+ }
602
+ if (typeof this.config.clawtipBundledStaticDir === "string") {
603
+ return fs.existsSync(this.config.clawtipBundledStaticDir) ? this.config.clawtipBundledStaticDir : undefined;
604
+ }
605
+ const here = currentModuleDir();
606
+ const candidates = [
607
+ path.resolve(here, "../static/clawtip"),
608
+ path.resolve(here, "../../static/clawtip"),
609
+ path.resolve(process.cwd(), "packages/tokenbuddy-cli/static/clawtip")
610
+ ];
611
+ return candidates.find((candidate) => fs.existsSync(candidate));
612
+ }
613
+
614
+ private clawtipPublicUrl(fileName: string): string {
615
+ return `${CLAWTIP_STATIC_ROUTE}/${encodeURIComponent(fileName)}`;
616
+ }
617
+
618
+ private ensureClawtipStaticAssets(): void {
619
+ const outputDir = this.clawtipStaticDir();
620
+ fs.mkdirSync(outputDir, { recursive: true });
621
+ const rechargeOutputPath = path.join(outputDir, CLAWTIP_RECHARGE_QR_FILE);
622
+ if (fs.existsSync(rechargeOutputPath)) {
623
+ return;
624
+ }
625
+ const bundledDir = this.bundledClawtipStaticDir();
626
+ const rechargeSourcePath = bundledDir ? path.join(bundledDir, CLAWTIP_RECHARGE_QR_FILE) : undefined;
627
+ if (rechargeSourcePath && fs.existsSync(rechargeSourcePath)) {
628
+ fs.copyFileSync(rechargeSourcePath, rechargeOutputPath);
629
+ }
630
+ }
631
+
632
+ private copyClawtipQrToStatic(mediaPath: string, orderNo: string): { fileName: string; url: string; path: string } {
633
+ if (!fs.existsSync(mediaPath)) {
634
+ throw new Error(`ClawTip QR image does not exist: ${mediaPath}`);
635
+ }
636
+ const extension = safeQrExtension(mediaPath);
637
+ const fileName = `${safeStaticFileSegment(orderNo)}-${Date.now()}${extension}`;
638
+ const outputDir = this.clawtipStaticDir();
639
+ fs.mkdirSync(outputDir, { recursive: true });
640
+ const outputPath = path.join(outputDir, fileName);
641
+ fs.copyFileSync(mediaPath, outputPath);
642
+ return {
643
+ fileName,
644
+ path: outputPath,
645
+ url: this.clawtipPublicUrl(fileName)
646
+ };
647
+ }
648
+
649
+ private async startClawtipActivationQr(): Promise<ClawtipQrResponse> {
650
+ const bootstrapUrl = process.env.TOKENBUDDY_BOOTSTRAP_URL || "https://tb-wallet-bootstrap.fly.dev";
651
+ const fetchBootstrap = this.config.clawtipBootstrapFetcher || fetchClawtipBootstrap;
652
+ const bootstrap = await fetchBootstrap(bootstrapUrl);
653
+ const payment = normalizeClawtipActivationPayment(bootstrap);
654
+ const startBootstrap = this.config.clawtipWalletBootstrapStarter || startClawtipWalletBootstrap;
655
+ const activation = await startBootstrap(payment, { home: this.config.clawtipHomeDir });
656
+ if (!activation.parsedOutput.mediaPath) {
657
+ throw new Error("ClawTip activation did not return a QR image.");
658
+ }
659
+ const staticQr = this.copyClawtipQrToStatic(activation.parsedOutput.mediaPath, payment.orderNo);
660
+ const walletConfig = inspectOpenClawWalletConfig(this.config.clawtipHomeDir);
661
+ const existingPayment = this.tokenStore.getPayment("clawtip");
662
+ this.tokenStore.savePayment({
663
+ method: "clawtip",
664
+ enabled: walletConfig.exists,
665
+ isDefault: existingPayment?.isDefault ?? true,
666
+ config: {
667
+ ...(existingPayment?.config ?? {}),
668
+ bootstrapUrl,
669
+ orderNo: payment.orderNo,
670
+ amountFen: payment.amountFen,
671
+ indicator: payment.indicator,
672
+ slug: payment.slug,
673
+ skillId: payment.skillId,
674
+ description: payment.description,
675
+ resourceUrl: payment.resourceUrl,
676
+ activationOrderFile: activation.orderFile,
677
+ walletConfigPath: walletConfig.expectedPath,
678
+ walletConfigPresent: walletConfig.exists,
679
+ activationQrImagePath: activation.parsedOutput.mediaPath,
680
+ activationQrImageUrl: staticQr.url,
681
+ authUrl: activation.parsedOutput.authUrl,
682
+ clawtipId: activation.parsedOutput.clawtipId,
683
+ payCredentialWritten: Boolean(activation.payCredential),
684
+ activationCompletedBy: activation.payCredential
685
+ ? (walletConfig.exists ? "payCredential+wallet-config" : "payCredential")
686
+ : walletConfig.exists ? "wallet-config" : "pending-wallet-scan"
687
+ }
688
+ });
689
+ this.scheduleClawtipActivationWait(activation.parsedOutput.clawtipId);
690
+ return {
691
+ ok: true,
692
+ kind: "activate",
693
+ method: "clawtip",
694
+ orderNo: payment.orderNo,
695
+ amountFen: payment.amountFen,
696
+ qrImageUrl: staticQr.url,
697
+ sourceImagePath: activation.parsedOutput.mediaPath,
698
+ staticImagePath: staticQr.path,
699
+ authUrl: activation.parsedOutput.authUrl,
700
+ clawtipId: activation.parsedOutput.clawtipId,
701
+ orderFile: activation.orderFile,
702
+ walletConfigPath: walletConfig.expectedPath,
703
+ walletConfigPresent: walletConfig.exists,
704
+ requiresWalletAuth: activation.parsedOutput.requiresWalletAuth,
705
+ payCredentialWritten: Boolean(activation.payCredential)
706
+ };
707
+ }
708
+
709
+ private scheduleClawtipActivationWait(clawtipId?: string): void {
710
+ if (this.clawtipActivationWait) {
711
+ return;
712
+ }
713
+ const cancelToken = { cancelled: false };
714
+ this.clawtipActivationWaitCancelToken = cancelToken;
715
+ const waitForActivation = this.config.clawtipActivationWaiter || waitForClawtipActivationConfirmation;
716
+ this.clawtipActivationWait = waitForActivation({
717
+ clawtipId,
718
+ inspectWalletConfig: () => inspectOpenClawWalletConfig(this.config.clawtipHomeDir),
719
+ isCancelled: () => cancelToken.cancelled,
720
+ cancel: () => undefined
721
+ })
722
+ .then((walletRegistered) => {
723
+ if (cancelToken.cancelled) {
724
+ return;
725
+ }
726
+ if (!walletRegistered) {
727
+ logger.info("control.payment.clawtip.activation_wait.pending", "ClawTip activation wait ended before wallet registration");
728
+ return;
729
+ }
730
+ const walletConfig = inspectOpenClawWalletConfig(this.config.clawtipHomeDir);
731
+ const payment = this.tokenStore.getPayment("clawtip");
732
+ if (!payment || payment.method !== "clawtip") {
733
+ return;
734
+ }
735
+ this.tokenStore.savePayment({
736
+ ...payment,
737
+ enabled: walletConfig.exists,
738
+ config: {
739
+ ...(payment.config ?? {}),
740
+ walletConfigPath: walletConfig.expectedPath,
741
+ walletConfigPresent: walletConfig.exists,
742
+ activationCompletedBy: walletConfig.exists
743
+ ? "wallet-config"
744
+ : readConfigString(payment.config, "activationCompletedBy") ?? "pending-wallet-scan"
745
+ }
746
+ });
747
+ logger.info("control.payment.clawtip.activation_wait.completed", "ClawTip activation wait completed", {
748
+ walletRegistered,
749
+ walletConfigPresent: walletConfig.exists
750
+ });
751
+ })
752
+ .catch((error: unknown) => {
753
+ const errorMessage = error instanceof Error ? error.message : String(error);
754
+ logger.warn("control.payment.clawtip.activation_wait.failed", "ClawTip activation wait failed", { errorMessage });
755
+ })
756
+ .finally(() => {
757
+ if (this.clawtipActivationWaitCancelToken === cancelToken) {
758
+ this.clawtipActivationWaitCancelToken = undefined;
759
+ this.clawtipActivationWait = undefined;
760
+ }
761
+ });
762
+ }
763
+
764
+ private clawtipRechargeQr(): ClawtipQrResponse {
765
+ const payment = this.tokenStore.getPayment("clawtip");
766
+ const resourceUrl = readConfigString(payment?.config, "resourceUrl");
767
+ const orderNo = readConfigString(payment?.config, "orderNo") || "clawtip-recharge";
768
+ const mediaPath = path.join(this.clawtipStaticDir(), CLAWTIP_RECHARGE_QR_FILE);
769
+ if (!fs.existsSync(mediaPath)) {
770
+ throw new Error(`ClawTip fixed recharge QR image is missing: ${mediaPath}`);
771
+ }
772
+ const walletConfig = inspectOpenClawWalletConfig(this.config.clawtipHomeDir);
773
+ return {
774
+ ok: true,
775
+ kind: "recharge",
776
+ method: "clawtip",
777
+ orderNo,
778
+ qrImageUrl: this.clawtipPublicUrl(CLAWTIP_RECHARGE_QR_FILE),
779
+ sourceImagePath: mediaPath,
780
+ staticImagePath: mediaPath,
781
+ resourceUrl,
782
+ walletConfigPath: walletConfig.expectedPath,
783
+ walletConfigPresent: walletConfig.exists,
784
+ requiresWalletAuth: false,
785
+ payCredentialWritten: readConfigBoolean(payment?.config, "payCredentialWritten")
786
+ };
787
+ }
788
+
360
789
  // v1.2 §18.9: stale-cache fallback. The buyer remembers the last
361
790
  // successfully fetched registry document and reuses it when the
362
791
  // bootstrap returns 413 (`X-TokenBuddy-Registry-Too-Large: 1`). This
@@ -397,6 +826,7 @@ export class TokenbuddyDaemon {
397
826
  }
398
827
 
399
828
  private runtimeSummary() {
829
+ this.refreshSellerRoutingConfig();
400
830
  return {
401
831
  status: "running",
402
832
  pid: process.pid,
@@ -502,7 +932,7 @@ export class TokenbuddyDaemon {
502
932
  return payments.find((payment) => payment.isDefault)?.method || payments.find((payment) => payment.method === "mock")?.method;
503
933
  }
504
934
 
505
- private async selectSellerRoutes(endpoint: string, modelId: string): Promise<SellerRoute[]> {
935
+ private async selectSellerRoutes(endpoint: string, modelId: string): Promise<{ routes: SellerRoute[]; plan: SellerRoutePlan; paymentMethod: string }> {
506
936
  const protocol = this.endpointProtocol(endpoint);
507
937
  if (!protocol) {
508
938
  throw new Error(`unsupported proxy endpoint: ${endpoint}`);
@@ -515,12 +945,14 @@ export class TokenbuddyDaemon {
515
945
  // v1.2: registry is the source of truth for routing. We rebuild the
516
946
  // model-index once per request (cheap; index lookup is in-memory) so
517
947
  // the response always reflects the latest seller list. The previous
518
- // "fetchSellerManifest per candidate" path is removed in favor of
948
+ // "fetchSellerManifest per request" path is removed in favor of
519
949
  // pulling `models` directly off the registry entries.
520
950
  const registry = await this.fetchRegistry();
521
951
 
522
- const routing = this.sellerRouting;
952
+ const routing = resolveSellerRoutingForModel(this.refreshSellerRoutingConfig(), modelId);
523
953
  const registrySellers = reorderDefaultSellerFirst(registry.sellers, registry.defaultSeller);
954
+ this.sellerPool.ensureRegistrySellers(registrySellers);
955
+ this.scheduleLazyPrewarmIfNeeded(modelId, protocol, paymentMethod);
524
956
  const poolById = new Map(this.sellerPool.snapshot().map((entry) => [entry.sellerId, entry]));
525
957
  const planned = planSellerRouteSet({
526
958
  modelId,
@@ -533,8 +965,12 @@ export class TokenbuddyDaemon {
533
965
  sellerId: entry.sellerId,
534
966
  healthScore: entry.healthScore,
535
967
  avgLatencyMs: entry.avgLatencyMs,
536
- circuit: entry.circuit
537
- }))
968
+ ttftMs: entry.ttftMs,
969
+ avgInferenceMs: entry.avgInferenceMs,
970
+ circuit: entry.circuit,
971
+ capacityBlockedUntil: entry.capacityBlockedUntil
972
+ })),
973
+ now: Date.now()
538
974
  });
539
975
 
540
976
  if (planned.routes.length === 0) {
@@ -564,7 +1000,122 @@ export class TokenbuddyDaemon {
564
1000
  sellerCount: routes.length,
565
1001
  sellers: routes.map((route) => route.seller.id)
566
1002
  });
567
- return routes;
1003
+ return { routes, plan: planned, paymentMethod };
1004
+ }
1005
+
1006
+ private refreshSellerRoutingConfig(): BuyerSellerRoutingConfig {
1007
+ const storedRouting = this.tokenStore.getDaemonRuntimeConfig<unknown>(ROUTING_CONFIG_KEY)
1008
+ ?.config;
1009
+ const nextRouting = mergeSellerRoutingConfig(
1010
+ storedRouting,
1011
+ this.config.sellerRouting
1012
+ );
1013
+
1014
+ if (!sameSellerRouting(this.sellerRouting, nextRouting)) {
1015
+ const previous = this.sellerRouting;
1016
+ this.sellerRouting = nextRouting;
1017
+ this.selectionMode = selectionModeForRouting(nextRouting);
1018
+ this.selectedSellerId = selectedSellerIdForRouting(nextRouting);
1019
+ logger.info("routing.config.reloaded", "seller routing config reloaded", {
1020
+ previousMode: previous.mode,
1021
+ previousScorer: previous.scorer,
1022
+ sellerRoutingMode: nextRouting.mode,
1023
+ sellerRoutingScorer: nextRouting.scorer,
1024
+ selectedSellerId: this.selectedSellerId
1025
+ });
1026
+ void this.runRoutingPrewarmSweep(nextRouting);
1027
+ }
1028
+
1029
+ return this.sellerRouting;
1030
+ }
1031
+
1032
+ private async runRoutingPrewarmSweep(routing: BuyerSellerRoutingConfig): Promise<void> {
1033
+ const focusSet = this.resolveFocusSet();
1034
+ const routingPrewarmKey = `${routingKey(routing)}\u0001${focusSet.join("\u0001")}`;
1035
+ if (focusSet.length === 0) {
1036
+ logger.info("prewarm.routing.skipped", "no focus set configured after routing reload; relying on lazy prewarms", {
1037
+ sellerRoutingMode: routing.mode,
1038
+ sellerRoutingScorer: routing.scorer
1039
+ });
1040
+ return;
1041
+ }
1042
+ if (this.lastRoutingPrewarmKey === routingPrewarmKey) {
1043
+ return;
1044
+ }
1045
+ this.lastRoutingPrewarmKey = routingPrewarmKey;
1046
+ logger.info("prewarm.routing.scheduled", "routing reload prewarm sweep scheduled", {
1047
+ sellerRoutingMode: routing.mode,
1048
+ sellerRoutingScorer: routing.scorer,
1049
+ focusSetSize: focusSet.length,
1050
+ focusSet: focusSet.slice(0, 20)
1051
+ });
1052
+ try {
1053
+ await this.fetchRegistry();
1054
+ const paymentMethod = this.defaultPaymentMethod();
1055
+ for (const modelId of focusSet) {
1056
+ this.schedulePrewarmForModel({
1057
+ modelId,
1058
+ reason: "explicit",
1059
+ protocol: this.resolvePrewarmProtocol(modelId, paymentMethod),
1060
+ paymentMethod
1061
+ });
1062
+ }
1063
+ } catch (err) {
1064
+ logger.warn("prewarm.routing.failed", "routing reload prewarm sweep failed", {
1065
+ sellerRoutingMode: routing.mode,
1066
+ sellerRoutingScorer: routing.scorer,
1067
+ errorMessage: err instanceof Error ? err.message : String(err)
1068
+ });
1069
+ }
1070
+ }
1071
+
1072
+ private scheduleLazyPrewarmIfNeeded(modelId: string, protocol: string, paymentMethod: string): void {
1073
+ const freshness = this.prewarmCache.freshness(modelId, protocol, paymentMethod);
1074
+ if (freshness.present && !freshness.expired) {
1075
+ return;
1076
+ }
1077
+ const key = prewarmKey(modelId, protocol, paymentMethod);
1078
+ if (this.lazyPrewarmKeys.has(key)) {
1079
+ return;
1080
+ }
1081
+ this.lazyPrewarmKeys.add(key);
1082
+ logger.info("prewarm.lazy.scheduled", "lazy prewarm scheduled for requested model", {
1083
+ modelId,
1084
+ protocol,
1085
+ paymentMethod,
1086
+ freshnessState: freshness.state
1087
+ });
1088
+ this.schedulePrewarmForModel({
1089
+ modelId,
1090
+ reason: "lazy",
1091
+ protocol,
1092
+ paymentMethod
1093
+ }).finally(() => {
1094
+ this.lazyPrewarmKeys.delete(key);
1095
+ });
1096
+ }
1097
+
1098
+ private schedulePrewarmForModel(input: {
1099
+ modelId: string;
1100
+ reason: PrewarmReason;
1101
+ protocol?: string;
1102
+ paymentMethod?: string;
1103
+ }): Promise<unknown> {
1104
+ if (!input.protocol || !input.paymentMethod) {
1105
+ logger.warn("prewarm.schedule.skipped", "prewarm schedule skipped because protocol or payment method is missing", {
1106
+ modelId: input.modelId,
1107
+ reason: input.reason,
1108
+ protocol: input.protocol,
1109
+ paymentMethod: input.paymentMethod
1110
+ });
1111
+ return Promise.resolve();
1112
+ }
1113
+ return this.prewarmScheduler.schedulePrewarm({
1114
+ modelId: input.modelId,
1115
+ reason: input.reason,
1116
+ protocol: input.protocol,
1117
+ paymentMethod: input.paymentMethod
1118
+ });
568
1119
  }
569
1120
 
570
1121
  private failoverErrorMessage(error: unknown): string {
@@ -580,13 +1131,16 @@ export class TokenbuddyDaemon {
580
1131
  * caller side because it short-circuits the failure path with a
581
1132
  * re-purchase.
582
1133
  */
583
- private classifyFailureStatus(status: number): FailureKind {
1134
+ private classifyFailureStatus(status: number, bodyText?: string): FailureKind {
584
1135
  if (status === 401 || status === 403) {
585
1136
  return "auth_invalid";
586
1137
  }
587
1138
  if (status === 402) {
588
1139
  return "insufficient_funds";
589
1140
  }
1141
+ if (status === 429 && isBusyCapacityErrorBody(bodyText)) {
1142
+ return "busy_capacity";
1143
+ }
590
1144
  if (status === 400 || status === 404 || status === 422) {
591
1145
  return "hard_4xx";
592
1146
  }
@@ -770,7 +1324,16 @@ export class TokenbuddyDaemon {
770
1324
  usage: UsageSummary,
771
1325
  settlement: SellerSettlementSummary | undefined,
772
1326
  prompt: string | undefined,
773
- response?: string
1327
+ response?: string,
1328
+ extras?: {
1329
+ ttftMs?: number;
1330
+ fallbackCount?: number;
1331
+ routeReason?: string;
1332
+ falloverChain?: string[];
1333
+ upstreamStatus?: string;
1334
+ durationMs?: number;
1335
+ paymentMethod?: string;
1336
+ }
774
1337
  ): void {
775
1338
  if (settlement) {
776
1339
  this.tokenStore.reconcileTokenBalance({
@@ -800,7 +1363,14 @@ export class TokenbuddyDaemon {
800
1363
  balanceSnapshotMicros: settlement?.remainingCreditMicros,
801
1364
  balanceSource: settlement ? "seller_authoritative" : "estimated",
802
1365
  prompt,
803
- response
1366
+ response,
1367
+ ttftMs: extras?.ttftMs,
1368
+ fallbackCount: extras?.fallbackCount,
1369
+ routeReason: extras?.routeReason,
1370
+ falloverChain: extras?.falloverChain,
1371
+ upstreamStatus: extras?.upstreamStatus,
1372
+ durationMs: extras?.durationMs,
1373
+ paymentMethod: extras?.paymentMethod
804
1374
  });
805
1375
  logger.info("inference.ledger.recorded", "safe inference ledger recorded", {
806
1376
  requestId: settlement?.requestId || requestId,
@@ -815,7 +1385,14 @@ export class TokenbuddyDaemon {
815
1385
  promptTokens: usage.promptTokens,
816
1386
  completionTokens: usage.completionTokens,
817
1387
  balanceSnapshotMicros: settlement?.remainingCreditMicros,
818
- balanceSource: settlement ? "seller_authoritative" : "estimated"
1388
+ balanceSource: settlement ? "seller_authoritative" : "estimated",
1389
+ ttftMs: extras?.ttftMs,
1390
+ fallbackCount: extras?.fallbackCount,
1391
+ routeReason: extras?.routeReason,
1392
+ falloverChain: extras?.falloverChain,
1393
+ upstreamStatus: extras?.upstreamStatus,
1394
+ durationMs: extras?.durationMs,
1395
+ paymentMethod: extras?.paymentMethod
819
1396
  });
820
1397
  }
821
1398
 
@@ -1316,6 +1893,10 @@ export class TokenbuddyDaemon {
1316
1893
 
1317
1894
  private async forwardProxyRequest(endpoint: string, req: Request, res: Response): Promise<void> {
1318
1895
  const startedAt = Date.now();
1896
+ let firstByteAt: number | null = null;
1897
+ const markFirstByte = (): void => {
1898
+ if (firstByteAt === null) firstByteAt = Date.now();
1899
+ };
1319
1900
  const body = req.body || {};
1320
1901
  const { requestedModelId, resolvedModelId } = this.resolveRouteModelId(endpoint, body);
1321
1902
  const modelId = resolvedModelId;
@@ -1328,7 +1909,12 @@ export class TokenbuddyDaemon {
1328
1909
  }
1329
1910
 
1330
1911
  try {
1331
- const routes = await this.selectSellerRoutes(endpoint, modelId);
1912
+ const { routes, plan, paymentMethod } = await this.selectSellerRoutes(endpoint, modelId);
1913
+ const upstreamStatusFromHeaders = (h: Headers): string | undefined => {
1914
+ const raw = h.get("x-tokenbuddy-upstream-status") || h.get("x-upstream-status");
1915
+ if (!raw) return undefined;
1916
+ return raw === "healthy" || raw === "degraded" || raw === "unhealthy" || raw === "unknown" ? raw : "unknown";
1917
+ };
1332
1918
  let lastError: unknown;
1333
1919
 
1334
1920
  for (let routeIndex = 0; routeIndex < routes.length; routeIndex += 1) {
@@ -1336,6 +1922,7 @@ export class TokenbuddyDaemon {
1336
1922
  const sellerKey = route.seller.id;
1337
1923
  logger.info("route.selected", "seller route selected", {
1338
1924
  sellerKey,
1925
+ sellerId: sellerKey,
1339
1926
  model: modelId,
1340
1927
  endpoint,
1341
1928
  protocol: route.protocol,
@@ -1493,7 +2080,7 @@ export class TokenbuddyDaemon {
1493
2080
  status: upstreamResponse.status,
1494
2081
  durationMs: Date.now() - startedAt
1495
2082
  });
1496
- const kind: FailureKind = this.classifyFailureStatus(upstreamResponse.status);
2083
+ const kind: FailureKind = this.classifyFailureStatus(upstreamResponse.status, errorBody);
1497
2084
  const decision = this.routeFailover.decide(
1498
2085
  {
1499
2086
  sellerId: sellerKey,
@@ -1568,6 +2155,7 @@ export class TokenbuddyDaemon {
1568
2155
  // 缺 event: 行)由卖方修,buyer 不兜底。
1569
2156
  const sellerChunk = settlementExtractor.push(chunk);
1570
2157
  if (sellerChunk.length > 0) {
2158
+ markFirstByte();
1571
2159
  res.write(sellerChunk);
1572
2160
  }
1573
2161
  }
@@ -1579,11 +2167,13 @@ export class TokenbuddyDaemon {
1579
2167
  if (decoderTail.length > 0) {
1580
2168
  const sellerTail = settlementExtractor.push(decoderTail);
1581
2169
  if (sellerTail.length > 0) {
2170
+ markFirstByte();
1582
2171
  res.write(sellerTail);
1583
2172
  }
1584
2173
  }
1585
2174
  const settlementTrailing = settlementExtractor.finish();
1586
2175
  if (settlementTrailing.downstream.length > 0) {
2176
+ markFirstByte();
1587
2177
  res.write(settlementTrailing.downstream);
1588
2178
  }
1589
2179
  res.end();
@@ -1593,12 +2183,23 @@ export class TokenbuddyDaemon {
1593
2183
  requestId,
1594
2184
  { promptTokens: 0, completionTokens: 0, billedMicros: Math.max(1, bytes) },
1595
2185
  this.parseSellerSettlementSummary(upstreamResponse.headers) ?? settlementTrailing.settlement ?? settlementExtractor.current(),
1596
- this.inferPromptForHash(body)
2186
+ this.inferPromptForHash(body),
2187
+ undefined,
2188
+ {
2189
+ ttftMs: firstByteAt ? firstByteAt - startedAt : undefined,
2190
+ fallbackCount: routeIndex,
2191
+ routeReason: plan.reason,
2192
+ falloverChain: routes.slice(0, routeIndex + 1).map((r) => r.seller.id),
2193
+ upstreamStatus: upstreamStatusFromHeaders(upstreamResponse.headers),
2194
+ durationMs: Date.now() - startedAt,
2195
+ paymentMethod
2196
+ }
1597
2197
  );
1598
2198
  return;
1599
2199
  }
1600
2200
 
1601
2201
  const responseBody = await upstreamResponse.text();
2202
+ markFirstByte();
1602
2203
  res.send(responseBody);
1603
2204
  const usage = this.readUsage(responseBody);
1604
2205
  this.recordReconciledInference(
@@ -1608,7 +2209,16 @@ export class TokenbuddyDaemon {
1608
2209
  usage,
1609
2210
  this.parseSellerSettlementSummary(upstreamResponse.headers),
1610
2211
  this.inferPromptForHash(body),
1611
- responseBody
2212
+ responseBody,
2213
+ {
2214
+ ttftMs: firstByteAt ? firstByteAt - startedAt : undefined,
2215
+ fallbackCount: routeIndex,
2216
+ routeReason: plan.reason,
2217
+ falloverChain: routes.slice(0, routeIndex + 1).map((r) => r.seller.id),
2218
+ upstreamStatus: upstreamStatusFromHeaders(upstreamResponse.headers),
2219
+ durationMs: Date.now() - startedAt,
2220
+ paymentMethod
2221
+ }
1612
2222
  );
1613
2223
  return;
1614
2224
  } catch (routeError: unknown) {
@@ -1710,10 +2320,42 @@ export class TokenbuddyDaemon {
1710
2320
  controlApp.get("/payments", (req, res) => {
1711
2321
  logger.info("control.payments.requested", "control payments requested", {});
1712
2322
  res.status(200).json({
1713
- payments: this.tokenStore.listPayments()
2323
+ payments: this.tokenStore.listPayments().map((payment) => withLiveClawtipWalletState(payment, this.config.clawtipHomeDir))
1714
2324
  });
1715
2325
  });
1716
2326
 
2327
+ controlApp.post("/payments/clawtip/activate", async (req, res) => {
2328
+ try {
2329
+ const qr = await this.startClawtipActivationQr();
2330
+ logger.info("control.payment.clawtip.activate_qr.created", "ClawTip activation QR copied for tb-ui", {
2331
+ orderNo: qr.orderNo,
2332
+ qrImageUrl: qr.qrImageUrl,
2333
+ walletConfigPresent: qr.walletConfigPresent
2334
+ });
2335
+ res.status(200).json(qr);
2336
+ } catch (error: unknown) {
2337
+ const errorMessage = error instanceof Error ? error.message : String(error);
2338
+ logger.warn("control.payment.clawtip.activate_qr.failed", "ClawTip activation QR failed", { errorMessage });
2339
+ res.status(500).json({ error: { code: "clawtip_activate_qr_failed", message: errorMessage } });
2340
+ }
2341
+ });
2342
+
2343
+ controlApp.post("/payments/clawtip/recharge", (req, res) => {
2344
+ try {
2345
+ const qr = this.clawtipRechargeQr();
2346
+ logger.info("control.payment.clawtip.recharge_qr.created", "ClawTip fixed recharge QR served for tb-ui", {
2347
+ orderNo: qr.orderNo,
2348
+ qrImageUrl: qr.qrImageUrl,
2349
+ walletConfigPresent: qr.walletConfigPresent
2350
+ });
2351
+ res.status(200).json(qr);
2352
+ } catch (error: unknown) {
2353
+ const errorMessage = error instanceof Error ? error.message : String(error);
2354
+ logger.warn("control.payment.clawtip.recharge_qr.failed", "ClawTip recharge QR failed", { errorMessage });
2355
+ res.status(500).json({ error: { code: "clawtip_recharge_qr_failed", message: errorMessage } });
2356
+ }
2357
+ });
2358
+
1717
2359
  controlApp.get("/ledger/purchases", (req, res) => {
1718
2360
  logger.info("control.ledger.requested", "control purchase ledger requested", {
1719
2361
  ledger: "purchases"
@@ -1855,6 +2497,41 @@ export class TokenbuddyDaemon {
1855
2497
  }
1856
2498
  });
1857
2499
 
2500
+ controlApp.get("/providers/status", (_req, res) => {
2501
+ try {
2502
+ const providerStatuses = detectProviders({ home: this.config.providerHomeDir }).map(clientToolStatusFromProvider);
2503
+ const clients = [
2504
+ ...providerStatuses,
2505
+ buildCustomClientToolStatus(this.activeProxyPort()),
2506
+ ];
2507
+ const configuredCount = clients.filter((client) => client.configured).length;
2508
+ const detectedCount = clients.filter((client) => client.detected && client.status !== "manual").length;
2509
+ logger.info("provider.status.requested", "provider status requested", {
2510
+ clientCount: clients.length,
2511
+ configuredCount,
2512
+ detectedCount
2513
+ });
2514
+ res.status(200).json({
2515
+ clients,
2516
+ summary: {
2517
+ configuredCount,
2518
+ detectedCount,
2519
+ totalCount: clients.length,
2520
+ installCommand: "tb init"
2521
+ }
2522
+ });
2523
+ } catch (error: unknown) {
2524
+ const errorMessage = error instanceof Error ? error.message : String(error);
2525
+ logger.warn("provider.status.failed", "provider status failed", { errorMessage });
2526
+ res.status(400).json({
2527
+ error: {
2528
+ code: "provider_status_failed",
2529
+ message: errorMessage
2530
+ }
2531
+ });
2532
+ }
2533
+ });
2534
+
1858
2535
  controlApp.post("/providers/install/preview", (req, res) => {
1859
2536
  try {
1860
2537
  const changes = previewProviderInstall({
@@ -1932,6 +2609,172 @@ export class TokenbuddyDaemon {
1932
2609
  }
1933
2610
  });
1934
2611
 
2612
+ // ─────────────────────────────────────────────────────────────────
2613
+ // tb-ui v1: 控制平面写端点(PR-0)
2614
+ // 所有端点都热生效,不需重启 daemon。改完 store 即下次推理生效。
2615
+ // ─────────────────────────────────────────────────────────────────
2616
+
2617
+ // 1) GET /routing/strategy — 读当前路由策略 + 来源
2618
+ controlApp.get("/routing/strategy", (req, res) => {
2619
+ try {
2620
+ const stored = this.tokenStore.getDaemonRuntimeConfig<unknown>(ROUTING_CONFIG_KEY)?.config;
2621
+ const current = this.refreshSellerRoutingConfig();
2622
+ const source: "store" | "config" | "default" =
2623
+ stored !== undefined ? "store" : this.config.sellerRouting ? "config" : "default";
2624
+ logger.info("routing.strategy.read", "routing strategy read", {
2625
+ source,
2626
+ mode: current.mode,
2627
+ scorer: current.scorer
2628
+ });
2629
+ res.status(200).json({ strategy: current, source });
2630
+ } catch (error: unknown) {
2631
+ const errorMessage = error instanceof Error ? error.message : String(error);
2632
+ logger.warn("routing.strategy.read_failed", "routing strategy read failed", { errorMessage });
2633
+ res.status(500).json({ error: { code: "routing_strategy_read_failed", message: errorMessage } });
2634
+ }
2635
+ });
2636
+
2637
+ // 2) GET /routing/preview — 算「假如改完会怎样」,不改 state
2638
+ // query: modelId? protocol? paymentMethod? mode? scorer? sellerId? sellerIds?(逗号分隔)
2639
+ controlApp.get("/routing/preview", (req, res) => {
2640
+ try {
2641
+ const override = buildRoutingConfigFromQuery(req.query);
2642
+ const result = this.buildRoutingPreview({
2643
+ modelId: typeof req.query.modelId === "string" ? req.query.modelId : undefined,
2644
+ protocol: typeof req.query.protocol === "string" ? req.query.protocol : undefined,
2645
+ paymentMethod: typeof req.query.paymentMethod === "string" ? req.query.paymentMethod : undefined,
2646
+ routing: override ?? undefined
2647
+ });
2648
+ if ("error" in result.plan) {
2649
+ res.status(409).json({
2650
+ error: { code: result.plan.error, message: `cannot preview routing: ${result.plan.error}` },
2651
+ modelId: result.modelId,
2652
+ protocol: result.protocol,
2653
+ paymentMethod: result.paymentMethod
2654
+ });
2655
+ return;
2656
+ }
2657
+ res.status(200).json({
2658
+ modelId: result.modelId,
2659
+ protocol: result.protocol,
2660
+ paymentMethod: result.paymentMethod,
2661
+ plan: result.plan
2662
+ });
2663
+ } catch (error: unknown) {
2664
+ const errorMessage = error instanceof Error ? error.message : String(error);
2665
+ logger.warn("routing.preview.failed", "routing preview failed", { errorMessage });
2666
+ res.status(400).json({ error: { code: "routing_preview_failed", message: errorMessage } });
2667
+ }
2668
+ });
2669
+
2670
+ // 3) PUT /routing/strategy — 写策略 + 热更新 + 返回 preview
2671
+ controlApp.put("/routing/strategy", (req, res) => {
2672
+ try {
2673
+ const body = (req.body ?? {}) as Record<string, unknown>;
2674
+ const normalized = normalizeSellerRoutingConfig(body);
2675
+ // 必填字段再次校验(normalize 会回退 default,但 PUT 必须显式)
2676
+ assertSellerRoutingConfig(normalized);
2677
+ this.tokenStore.saveDaemonRuntimeConfig(ROUTING_CONFIG_KEY, normalized);
2678
+ const current = this.refreshSellerRoutingConfig();
2679
+ logger.info("routing.strategy.applied", "routing strategy applied", {
2680
+ mode: current.mode,
2681
+ scorer: current.scorer,
2682
+ sellerId: current.sellerId,
2683
+ sellerIds: current.sellerIds
2684
+ });
2685
+ const preview = this.buildRoutingPreview({ routing: current });
2686
+ const previewPayload = "error" in preview.plan
2687
+ ? { error: preview.plan.error }
2688
+ : {
2689
+ modelId: preview.modelId,
2690
+ protocol: preview.protocol,
2691
+ paymentMethod: preview.paymentMethod,
2692
+ ...preview.plan
2693
+ };
2694
+ res.status(200).json({ applied: true, strategy: current, preview: previewPayload });
2695
+ } catch (error: unknown) {
2696
+ const errorMessage = error instanceof Error ? error.message : String(error);
2697
+ logger.warn("routing.strategy.apply_failed", "routing strategy apply failed", { errorMessage });
2698
+ res.status(400).json({ error: { code: "routing_strategy_apply_failed", message: errorMessage } });
2699
+ }
2700
+ });
2701
+
2702
+ // 4) PUT /prewarm/focus-set — 设置 explicit focus set(覆盖 config.warmupModels / env)
2703
+ // body: { models: ["claude-3-5-sonnet", "gpt-4o"], clear?: false }
2704
+ // clear=true 时 models 数组可省略;表示回退 env / historical
2705
+ controlApp.put("/prewarm/focus-set", (req, res) => {
2706
+ try {
2707
+ const body = (req.body ?? {}) as Record<string, unknown>;
2708
+ const clear = body.clear === true;
2709
+ if (clear) {
2710
+ const result = this.applyFocusSet(null);
2711
+ res.status(200).json({ ok: true, ...result });
2712
+ return;
2713
+ }
2714
+ if (!Array.isArray(body.models)) {
2715
+ res.status(400).json({
2716
+ error: { code: "invalid_focus_set", message: "focus-set body must have a string[] `models` field, or `clear: true`" }
2717
+ });
2718
+ return;
2719
+ }
2720
+ const models = body.models
2721
+ .filter((m): m is string => typeof m === "string")
2722
+ .map((m) => m.trim())
2723
+ .filter(Boolean);
2724
+ const result = this.applyFocusSet(models);
2725
+ res.status(200).json({ ok: true, ...result });
2726
+ } catch (error: unknown) {
2727
+ const errorMessage = error instanceof Error ? error.message : String(error);
2728
+ logger.warn("focus_set.apply_failed", "focus set apply failed", { errorMessage });
2729
+ res.status(400).json({ error: { code: "focus_set_apply_failed", message: errorMessage } });
2730
+ }
2731
+ });
2732
+
2733
+ // 5) POST /daemon/restart — 优雅重启 tb-proxyd(调子进程 `tb daemon restart`)
2734
+ // 现有 CLI 子命令(cli.ts:1129)。detached 模式让子进程独立于 daemon 生命周期。
2735
+ controlApp.post("/daemon/restart", (req, res) => {
2736
+ try {
2737
+ const child = spawn("tb", ["daemon", "restart"], {
2738
+ detached: true,
2739
+ stdio: "ignore"
2740
+ });
2741
+ child.unref();
2742
+ logger.info("daemon.restart.scheduled", "daemon restart scheduled via tb CLI");
2743
+ res.status(202).json({ ok: true, message: "restart scheduled" });
2744
+ } catch (error: unknown) {
2745
+ const errorMessage = error instanceof Error ? error.message : String(error);
2746
+ logger.warn("daemon.restart.failed", "daemon restart failed", { errorMessage });
2747
+ res.status(500).json({ error: { code: "daemon_restart_failed", message: errorMessage } });
2748
+ }
2749
+ });
2750
+
2751
+ this.ensureClawtipStaticAssets();
2752
+ controlApp.use(CLAWTIP_STATIC_ROUTE, express.static(this.clawtipStaticDir(), { index: false, fallthrough: false }));
2753
+
2754
+ // ────────────────────────────────────────────────────────────
2755
+ // tb-ui v1: 静态托管 SPA(dist 由 `npm run build --workspace tb-ui` 生成)
2756
+ // 必须在所有 API 路由**之后**才挂载,这样:
2757
+ // - `/health` `/ledger/purchases` 等 API 路径仍由上面 17+ 个端点处理
2758
+ // - 真实静态文件(`/index.html` `/assets/index-abc.js` 等)由 express.static 服务
2759
+ // - 未匹配路径(`/overview` `/routing` 等 React Router 路径)走 SPA fallback 回 index.html
2760
+ // ────────────────────────────────────────────────────────────
2761
+ const uiDir = resolveUiDir();
2762
+ if (uiDir && fs.existsSync(uiDir) && fs.existsSync(path.join(uiDir, "index.html"))) {
2763
+ controlApp.use(express.static(uiDir, { index: "index.html", fallthrough: true }));
2764
+ // SPA fallback: React Router 用 BrowserRouter(history mode),客户端路径如
2765
+ // /overview /routing /ledger 不对应任何文件,必须回 index.html 让 React Router 接管。
2766
+ // Express 已经按注册顺序匹配了所有 17+ 个 API 路由,这里只接住"什么都没匹配"的 GET。
2767
+ controlApp.get(/^(?!\/(?:health|status|payments|sellers|models|ledger|routing|prewarm|v1\.2|daemon|providers|static)\/).*/, (_req, res) => {
2768
+ res.sendFile(path.join(uiDir, "index.html"));
2769
+ });
2770
+ logger.info("ui.static.attached", "tb-ui dist attached to control plane SPA fallback", { uiDir });
2771
+ } else {
2772
+ logger.warn("ui.static.missing", "tb-ui dist not found; only control plane API will be served", {
2773
+ uiDir,
2774
+ hint: "run `npm run build --workspace tb-ui` then restart"
2775
+ });
2776
+ }
2777
+
1935
2778
  this.controlServer = controlApp.listen(this.config.controlPort);
1936
2779
 
1937
2780
  // 2. Proxy Plane Server (17821)
@@ -1999,9 +2842,13 @@ export class TokenbuddyDaemon {
1999
2842
  /**
2000
2843
  * v1.2 §18.4: build the focus set from the explicit config, the env
2001
2844
  * override, and the historical usage in the buyer store. The order of
2002
- * precedence: explicit config > env > historical > empty.
2845
+ * precedence: explicit `currentFocusSet` (set via `PUT /prewarm/focus-set`)
2846
+ * > explicit config > env > historical > empty.
2003
2847
  */
2004
2848
  private resolveFocusSet(): string[] {
2849
+ if (this.currentFocusSet !== null) {
2850
+ return this.currentFocusSet;
2851
+ }
2005
2852
  const explicit = this.config.warmupModels ?? [];
2006
2853
  if (explicit.length > 0) {
2007
2854
  return explicit;
@@ -2014,6 +2861,93 @@ export class TokenbuddyDaemon {
2014
2861
  return this.tokenStore.recentModels(7, 5);
2015
2862
  }
2016
2863
 
2864
+ /**
2865
+ * tb-ui v1 `PUT /prewarm/focus-set` 调用的统一入口。`models === null`
2866
+ * 表示「清除 explicit focus set,回退 env/historical」。
2867
+ * 写 store + 触发 `runRoutingPrewarmSweep`,**热生效**(不需重启 daemon)。
2868
+ */
2869
+ public applyFocusSet(models: string[] | null): { focusSet: string[]; source: "explicit" | "env" | "historical" | "empty" } {
2870
+ if (models === null) {
2871
+ this.tokenStore.removeDaemonRuntimeConfig(FOCUS_SET_CONFIG_KEY);
2872
+ this.currentFocusSet = null;
2873
+ } else {
2874
+ const deduped = Array.from(new Set(models.map((m) => m.trim()).filter(Boolean)));
2875
+ this.tokenStore.saveDaemonRuntimeConfig(FOCUS_SET_CONFIG_KEY, { models: deduped });
2876
+ this.currentFocusSet = deduped;
2877
+ }
2878
+ // 触发路由重读 + 预热 sweep(如果当前 routing 已是焦点集合的依赖)。
2879
+ this.refreshSellerRoutingConfig();
2880
+ const focusSet = this.resolveFocusSet();
2881
+ const source: "explicit" | "env" | "historical" | "empty" =
2882
+ this.currentFocusSet !== null
2883
+ ? "explicit"
2884
+ : (this.config.warmupModels?.length ?? 0) > 0
2885
+ ? "explicit"
2886
+ : (process.env.TB_BUYER_WARMUP_MODELS?.trim() ?? "").length > 0
2887
+ ? "env"
2888
+ : focusSet.length > 0
2889
+ ? "historical"
2890
+ : "empty";
2891
+ logger.info("focus_set.applied", "explicit focus set applied", {
2892
+ source,
2893
+ focusSetSize: focusSet.length,
2894
+ focusSet: focusSet.slice(0, 20)
2895
+ });
2896
+ return { focusSet, source };
2897
+ }
2898
+
2899
+ /**
2900
+ * tb-ui v1 `GET /routing/preview` 和 `PUT /routing/strategy` 复用的 preview 计算。
2901
+ * 接受任意 routing 覆盖(来自 request body)算「假如改成这个,路由会是啥」。
2902
+ * 不修改任何内部 state,**纯函数式**。
2903
+ */
2904
+ public buildRoutingPreview(input: {
2905
+ modelId?: string;
2906
+ protocol?: string;
2907
+ paymentMethod?: string;
2908
+ routing?: Partial<BuyerSellerRoutingConfig>;
2909
+ }): { modelId: string; protocol: string; paymentMethod: string; plan: SellerRoutePlan | { error: string } } {
2910
+ const registry = this.lastRegistrySnapshot;
2911
+ const focusFirst = this.resolveFocusSet()[0];
2912
+ const registryFirst = registry?.sellers[0]?.models?.[0];
2913
+ const modelId = input.modelId?.trim() || focusFirst || registryFirst || "";
2914
+ const protocol = input.protocol?.trim() || "chat_completions";
2915
+ const paymentMethod = input.paymentMethod?.trim() || this.defaultPaymentMethod() || "clawtip";
2916
+ if (!modelId) {
2917
+ return { modelId, protocol, paymentMethod, plan: { error: "no_focus_model_available" } };
2918
+ }
2919
+ if (!registry) {
2920
+ return { modelId, protocol, paymentMethod, plan: { error: "registry_not_loaded" } };
2921
+ }
2922
+ const current = this.refreshSellerRoutingConfig();
2923
+ const routing: BuyerSellerRoutingConfig = input.routing
2924
+ ? mergeSellerRoutingConfig(current, input.routing)
2925
+ : current;
2926
+ const resolvedRouting = resolveSellerRoutingForModel(routing, modelId);
2927
+ const registrySellers = reorderDefaultSellerFirst(registry.sellers, registry.defaultSeller);
2928
+ this.sellerPool.ensureRegistrySellers(registrySellers);
2929
+ const poolById = new Map(this.sellerPool.snapshot().map((entry) => [entry.sellerId, entry]));
2930
+ const plan = planSellerRouteSet({
2931
+ modelId,
2932
+ protocol,
2933
+ paymentMethod,
2934
+ registrySellers,
2935
+ routing: resolvedRouting,
2936
+ prewarmCandidates: this.prewarmCache.get(modelId, protocol, paymentMethod)?.candidates,
2937
+ sellerMetrics: Array.from(poolById.values()).map((entry) => ({
2938
+ sellerId: entry.sellerId,
2939
+ healthScore: entry.healthScore,
2940
+ avgLatencyMs: entry.avgLatencyMs,
2941
+ ttftMs: entry.ttftMs,
2942
+ avgInferenceMs: entry.avgInferenceMs,
2943
+ circuit: entry.circuit,
2944
+ capacityBlockedUntil: entry.capacityBlockedUntil
2945
+ })),
2946
+ now: Date.now()
2947
+ });
2948
+ return { modelId, protocol, paymentMethod, plan };
2949
+ }
2950
+
2017
2951
  private async runStartupPrewarmSweep(): Promise<void> {
2018
2952
  const focusSet = this.resolveFocusSet();
2019
2953
  if (focusSet.length === 0) {
@@ -2029,7 +2963,7 @@ export class TokenbuddyDaemon {
2029
2963
  await this.prewarmScheduler.runStartupPrewarm(
2030
2964
  focusSet.map((modelId) => ({
2031
2965
  modelId,
2032
- protocol: this.resolvePrewarmProtocol(modelId)
2966
+ protocol: this.resolvePrewarmProtocol(modelId, this.defaultPaymentMethod())
2033
2967
  }))
2034
2968
  );
2035
2969
  } catch (err) {
@@ -2039,9 +2973,9 @@ export class TokenbuddyDaemon {
2039
2973
  }
2040
2974
  }
2041
2975
 
2042
- private resolvePrewarmProtocol(modelId: string): string | undefined {
2976
+ private resolvePrewarmProtocol(modelId: string, paymentMethod = "clawtip"): string | undefined {
2043
2977
  for (const protocol of ["chat_completions", "messages", "responses"]) {
2044
- if (this.modelIndex.sellersFor(modelId, { protocol, paymentMethod: "clawtip" }).length > 0) {
2978
+ if (this.modelIndex.sellersFor(modelId, { protocol, paymentMethod }).length > 0) {
2045
2979
  return protocol;
2046
2980
  }
2047
2981
  }
@@ -2049,9 +2983,209 @@ export class TokenbuddyDaemon {
2049
2983
  }
2050
2984
 
2051
2985
  public stop() {
2986
+ if (this.clawtipActivationWaitCancelToken) {
2987
+ this.clawtipActivationWaitCancelToken.cancelled = true;
2988
+ }
2052
2989
  if (this.controlServer) this.controlServer.close();
2053
2990
  if (this.proxyServer) this.proxyServer.close();
2054
2991
  void this.prewarmScheduler.stop();
2055
2992
  this.tokenStore.close();
2056
2993
  }
2994
+
2995
+ /**
2996
+ * @internal — test-only seam to inject a registry snapshot without
2997
+ * hitting the network. Used by `tests/control-plane-ui-endpoints.test.ts`
2998
+ * to drive `buildRoutingPreview` deterministically. Production code
2999
+ * must NOT call this; the real `fetchRegistry()` populates the snapshot.
3000
+ */
3001
+ public setLastRegistrySnapshotForTest(snapshot: SellerRegistryDocument | null): void {
3002
+ this.lastRegistrySnapshot = snapshot;
3003
+ }
3004
+ }
3005
+
3006
+ function selectionModeForRouting(routing: BuyerSellerRoutingConfig): "auto" | "manual" {
3007
+ return routing.mode === "fullAuto" ? "auto" : "manual";
3008
+ }
3009
+
3010
+ interface ClawtipQrResponse {
3011
+ ok: true;
3012
+ kind: "activate" | "recharge";
3013
+ method: "clawtip";
3014
+ orderNo?: string;
3015
+ amountFen?: number;
3016
+ qrImageUrl: string;
3017
+ sourceImagePath: string;
3018
+ staticImagePath: string;
3019
+ authUrl?: string;
3020
+ clawtipId?: string;
3021
+ orderFile?: string;
3022
+ resourceUrl?: string;
3023
+ walletConfigPath: string;
3024
+ walletConfigPresent: boolean;
3025
+ requiresWalletAuth: boolean;
3026
+ payCredentialWritten: boolean;
3027
+ }
3028
+
3029
+ function withLiveClawtipWalletState(payment: PaymentConfig, home?: string): PaymentConfig {
3030
+ if (payment.method !== "clawtip") {
3031
+ return payment;
3032
+ }
3033
+ const walletConfig = inspectOpenClawWalletConfig(home);
3034
+ return {
3035
+ ...payment,
3036
+ enabled: payment.enabled && walletConfig.exists,
3037
+ config: {
3038
+ ...(payment.config ?? {}),
3039
+ walletConfigPath: walletConfig.expectedPath,
3040
+ walletConfigPresent: walletConfig.exists,
3041
+ nearbyWalletConfigPaths: walletConfig.alternatePaths
3042
+ }
3043
+ };
3044
+ }
3045
+
3046
+ function normalizeClawtipActivationPayment(bootstrap: ClawtipBootstrapResponse): ClawtipBootstrapPayment {
3047
+ if (!bootstrap.payment?.orderNo || !bootstrap.payment.indicator || !bootstrap.payment.resourceUrl) {
3048
+ throw new Error("ClawTip bootstrap response missing orderNo, indicator, or resourceUrl.");
3049
+ }
3050
+ return {
3051
+ orderNo: bootstrap.payment.orderNo,
3052
+ amountFen: bootstrap.payment.amountFen ?? bootstrap.activationFeeFen ?? 1,
3053
+ payTo: bootstrap.payment.payTo,
3054
+ encryptedData: bootstrap.payment.encryptedData,
3055
+ indicator: bootstrap.payment.indicator,
3056
+ slug: bootstrap.payment.slug,
3057
+ skillId: bootstrap.payment.skillId,
3058
+ description: bootstrap.payment.description,
3059
+ resourceUrl: bootstrap.payment.resourceUrl,
3060
+ };
3061
+ }
3062
+
3063
+ function safeQrExtension(filePath: string): ".png" | ".jpg" | ".jpeg" | ".webp" {
3064
+ const extension = path.extname(filePath).toLowerCase();
3065
+ if (extension === ".jpg" || extension === ".jpeg" || extension === ".webp") {
3066
+ return extension;
3067
+ }
3068
+ return ".png";
3069
+ }
3070
+
3071
+ function safeStaticFileSegment(value: string): string {
3072
+ return value.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "clawtip";
3073
+ }
3074
+
3075
+ function readConfigString(config: Record<string, unknown> | undefined, key: string): string | undefined {
3076
+ const value = config?.[key];
3077
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
3078
+ }
3079
+
3080
+ function readConfigBoolean(config: Record<string, unknown> | undefined, key: string): boolean {
3081
+ return config?.[key] === true;
3082
+ }
3083
+
3084
+ function selectedSellerIdForRouting(routing: BuyerSellerRoutingConfig): string | undefined {
3085
+ return routing.mode === "fixed" ? routing.sellerId : undefined;
3086
+ }
3087
+
3088
+ function routingKey(routing: BuyerSellerRoutingConfig): string {
3089
+ const fixedByModel = Object.entries(routing.fixedByModel ?? {})
3090
+ .sort(([left], [right]) => left.localeCompare(right))
3091
+ .map(([modelId, sellerId]) => `${modelId}:${sellerId}`);
3092
+ return [
3093
+ routing.mode,
3094
+ routing.scorer,
3095
+ routing.sellerId ?? "",
3096
+ ...(routing.sellerIds ?? []),
3097
+ ...fixedByModel
3098
+ ].join("\u0001");
3099
+ }
3100
+
3101
+ /**
3102
+ * 从 query string 构造 `BuyerSellerRoutingConfig` override(用于 GET /routing/preview)。
3103
+ * 接受 `mode` / `scorer` / `sellerId` / `sellerIds`(逗号分隔)。
3104
+ * 任一字段缺失返回 `undefined`,调用方走「用当前 routing」分支。
3105
+ * mode / scorer 非法抛 400,由端点 handler 捕获。
3106
+ */
3107
+ function buildRoutingConfigFromQuery(query: Record<string, unknown> | Request["query"]): Partial<BuyerSellerRoutingConfig> | undefined {
3108
+ const mode = typeof query.mode === "string" ? query.mode.trim() : "";
3109
+ const scorer = typeof query.scorer === "string" ? query.scorer.trim() : "";
3110
+ const sellerId = typeof query.sellerId === "string" ? query.sellerId.trim() : "";
3111
+ const sellerIdsRaw = typeof query.sellerIds === "string" ? query.sellerIds.trim() : "";
3112
+ const fixedByModelRaw = typeof query.fixedByModel === "string" ? query.fixedByModel.trim() : "";
3113
+ if (!mode && !scorer && !sellerId && !sellerIdsRaw && !fixedByModelRaw) {
3114
+ return undefined;
3115
+ }
3116
+ const override: Partial<BuyerSellerRoutingConfig> = {};
3117
+ if (mode) {
3118
+ if (mode !== "fixed" && mode !== "fixedSet" && mode !== "fullAuto") {
3119
+ throw new Error("mode must be fixed, fixedSet, or fullAuto");
3120
+ }
3121
+ override.mode = mode;
3122
+ }
3123
+ if (scorer) {
3124
+ if (scorer !== "speed" && scorer !== "discount" && scorer !== "balanced") {
3125
+ throw new Error("scorer must be speed, discount, or balanced");
3126
+ }
3127
+ override.scorer = scorer;
3128
+ }
3129
+ if (sellerId) {
3130
+ override.sellerId = sellerId;
3131
+ }
3132
+ if (sellerIdsRaw) {
3133
+ override.sellerIds = parseSellerIdList(sellerIdsRaw);
3134
+ }
3135
+ if (fixedByModelRaw) {
3136
+ override.fixedByModel = parseFixedByModel(fixedByModelRaw);
3137
+ }
3138
+ return override;
3139
+ }
3140
+
3141
+ function sameSellerRouting(a: BuyerSellerRoutingConfig, b: BuyerSellerRoutingConfig): boolean {
3142
+ return a.mode === b.mode
3143
+ && a.scorer === b.scorer
3144
+ && optionalStringEqual(a.sellerId, b.sellerId)
3145
+ && stringArraysEqual(a.sellerIds ?? [], b.sellerIds ?? [])
3146
+ && fixedByModelEqual(a.fixedByModel ?? {}, b.fixedByModel ?? {});
3147
+ }
3148
+
3149
+ function optionalStringEqual(a: string | undefined, b: string | undefined): boolean {
3150
+ return (a ?? "") === (b ?? "");
3151
+ }
3152
+
3153
+ function stringArraysEqual(a: string[], b: string[]): boolean {
3154
+ if (a.length !== b.length) {
3155
+ return false;
3156
+ }
3157
+ return a.every((entry, index) => entry === b[index]);
3158
+ }
3159
+
3160
+ function resolveSellerRoutingForModel(routing: BuyerSellerRoutingConfig, modelId: string): BuyerSellerRoutingConfig {
3161
+ if (routing.mode !== "fixed") {
3162
+ return routing;
3163
+ }
3164
+ const fixedSellerId = routing.fixedByModel?.[modelId]?.trim() || routing.sellerId;
3165
+ return {
3166
+ mode: "fixed",
3167
+ scorer: routing.scorer,
3168
+ sellerId: fixedSellerId,
3169
+ fixedByModel: routing.fixedByModel
3170
+ };
3171
+ }
3172
+
3173
+ function parseFixedByModel(value: string): Record<string, string> {
3174
+ const entries = value
3175
+ .split(",")
3176
+ .map((entry) => entry.split(":"))
3177
+ .filter((parts): parts is [string, string] => parts.length === 2)
3178
+ .map(([modelId, sellerId]) => [modelId.trim(), sellerId.trim()] as const)
3179
+ .filter(([modelId, sellerId]) => modelId.length > 0 && sellerId.length > 0);
3180
+ return Object.fromEntries(entries);
3181
+ }
3182
+
3183
+ function fixedByModelEqual(a: Record<string, string>, b: Record<string, string>): boolean {
3184
+ const aEntries = Object.entries(a).sort(([left], [right]) => left.localeCompare(right));
3185
+ const bEntries = Object.entries(b).sort(([left], [right]) => left.localeCompare(right));
3186
+ return aEntries.length === bEntries.length
3187
+ && aEntries.every(([modelId, sellerId], index) => {
3188
+ const [otherModelId, otherSellerId] = bEntries[index] ?? [];
3189
+ return modelId === otherModelId && sellerId === otherSellerId;
3190
+ });
2057
3191
  }