@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
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,95 @@ 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
+ interface ClientToolStatus {
68
+ id: string;
69
+ name: string;
70
+ status: ProviderCandidate["status"] | "manual";
71
+ detected: boolean;
72
+ configured: boolean;
73
+ configPath?: string;
74
+ commandName?: string;
75
+ reason: string;
76
+ manualConfig?: {
77
+ openaiBaseUrl: string;
78
+ anthropicBaseUrl: string;
79
+ apiKey: string;
80
+ };
81
+ }
82
+
83
+ function clientToolStatusFromProvider(provider: ProviderCandidate): ClientToolStatus {
84
+ return {
85
+ id: provider.id,
86
+ name: provider.name,
87
+ status: provider.status,
88
+ detected: provider.detected,
89
+ configured: provider.configured,
90
+ configPath: provider.configPath,
91
+ commandName: provider.commandName,
92
+ reason: provider.reason,
93
+ };
94
+ }
95
+
96
+ function buildCustomClientToolStatus(proxyPort: number): ClientToolStatus {
97
+ const openaiBaseUrl = `http://127.0.0.1:${proxyPort}/v1`;
98
+ const anthropicBaseUrl = `http://127.0.0.1:${proxyPort}`;
99
+ return {
100
+ id: "custom",
101
+ name: "Custom client",
102
+ status: "manual",
103
+ detected: true,
104
+ configured: false,
105
+ reason: `OpenAI-compatible: ${openaiBaseUrl} · Anthropic-compatible: ${anthropicBaseUrl}`,
106
+ manualConfig: {
107
+ openaiBaseUrl,
108
+ anthropicBaseUrl,
109
+ apiKey: "TOKENBUDDY_PROXY",
110
+ },
111
+ };
112
+ }
113
+
114
+ /**
115
+ * 解析 `tb-ui` 构建产物目录(daemon 静态托管 SPA 用)。
116
+ * 优先级:env TB_UI_DIR > require.resolve("tb-ui") > 相对路径猜
117
+ * 找不到时记录 warning 仍允许 daemon 启动(纯 API 模式);静态请求会 404。
118
+ */
119
+ function resolveUiDir(): string | undefined {
120
+ if (process.env.TB_UI_DIR) return process.env.TB_UI_DIR;
121
+ try {
122
+ // require.resolve 在 npm workspaces 装好时能找到 tb-ui/package.json
123
+ const pkgPath = require.resolve("tb-ui/package.json");
124
+ return path.join(path.dirname(pkgPath), "dist");
125
+ } catch {
126
+ // fallback: monorepo 假设 daemon dist 跟 tb-ui/dist 在同 root
127
+ // 用 __filename (jest cjs 编译下可用,生产 ESM 也兼容) 推回 src 再到 root
128
+ const here = typeof __filename !== "undefined" ? path.dirname(__filename) : process.cwd();
129
+ return path.resolve(here, "../../tb-ui/dist");
130
+ }
131
+ }
43
132
 
44
133
  /**
45
134
  * buyer 端守护进程(`tb-proxyd`)的配置。
@@ -56,6 +145,21 @@ export interface DaemonConfig {
56
145
  sellerRegistryUrl: string;
57
146
  /** 路由策略覆盖(与 `TB_SELLER_ROUTING_*` env 合并) */
58
147
  sellerRouting?: BuyerSellerRoutingConfig;
148
+ /** test-only override for ClawTip bootstrap payload fetch. */
149
+ clawtipBootstrapFetcher?: (bootstrapUrl: string) => Promise<ClawtipBootstrapResponse>;
150
+ /** test-only override for the existing tb init ClawTip activation runner. */
151
+ clawtipWalletBootstrapStarter?: (
152
+ payment: ClawtipBootstrapPayment,
153
+ options?: { home?: string }
154
+ ) => Promise<{ orderFile: string; parsedOutput: ParsedClawtipOutput; payCredential?: string }>;
155
+ /** test-only override for waiting on the existing tb init ClawTip registration loop. */
156
+ clawtipActivationWaiter?: (options?: WaitForClawtipActivationOptions) => Promise<boolean>;
157
+ /** test-only override for bundled ClawTip static assets; false disables bundled assets. */
158
+ clawtipBundledStaticDir?: string | false;
159
+ /** test-only home override for OpenClaw wallet and QR discovery. */
160
+ clawtipHomeDir?: string;
161
+ /** test-only home override for provider/client configuration detection. */
162
+ providerHomeDir?: string;
59
163
  /**
60
164
  * v1.2 §18.4 预热 focus-set 覆盖。
61
165
  * 缺省时由 BuyerStore 历史模型使用情况 + `TB_BUYER_WARMUP_MODELS` env 推导。
@@ -94,6 +198,24 @@ interface ProxyBodySummary {
94
198
  temperaturePresent?: boolean;
95
199
  }
96
200
 
201
+ interface SellerHealthBody {
202
+ upstream?: {
203
+ status?: unknown;
204
+ lastErrorClass?: unknown;
205
+ last_error_class?: unknown;
206
+ };
207
+ capacity?: {
208
+ activeConnections?: unknown;
209
+ active_connections?: unknown;
210
+ maxConnections?: unknown;
211
+ max_connections?: unknown;
212
+ queueDepth?: unknown;
213
+ queue_depth?: unknown;
214
+ maxQueueDepth?: unknown;
215
+ max_queue_depth?: unknown;
216
+ };
217
+ }
218
+
97
219
  interface SellerSettlementSummary {
98
220
  requestId: string;
99
221
  settledMicros: number;
@@ -227,6 +349,60 @@ function summarizeProxyBody(body: unknown): ProxyBodySummary {
227
349
  };
228
350
  }
229
351
 
352
+ function finiteNumber(value: unknown): number | undefined {
353
+ if (typeof value === "number" && Number.isFinite(value)) {
354
+ return value;
355
+ }
356
+ if (typeof value === "string" && value.trim().length > 0) {
357
+ const parsed = Number(value);
358
+ return Number.isFinite(parsed) ? parsed : undefined;
359
+ }
360
+ return undefined;
361
+ }
362
+
363
+ function readErrorCode(bodyText: string): string | undefined {
364
+ try {
365
+ const parsed = JSON.parse(bodyText) as { error?: { code?: unknown }; code?: unknown };
366
+ const code = parsed.error?.code ?? parsed.code;
367
+ return typeof code === "string" ? code : undefined;
368
+ } catch {
369
+ return undefined;
370
+ }
371
+ }
372
+
373
+ function isBusyCapacityErrorBody(bodyText: string | undefined): boolean {
374
+ if (!bodyText) {
375
+ return false;
376
+ }
377
+ return readErrorCode(bodyText) === ErrorCode.BusyCapacity;
378
+ }
379
+
380
+ function capacityBlockedUntilFromHealth(body: SellerHealthBody, now: number): number | undefined {
381
+ const capacity = body.capacity;
382
+ if (!capacity) {
383
+ return undefined;
384
+ }
385
+ const activeConnections = finiteNumber(capacity.activeConnections ?? capacity.active_connections);
386
+ const maxConnections = finiteNumber(capacity.maxConnections ?? capacity.max_connections);
387
+ const queueDepth = finiteNumber(capacity.queueDepth ?? capacity.queue_depth);
388
+ const maxQueueDepth = finiteNumber(capacity.maxQueueDepth ?? capacity.max_queue_depth);
389
+ if (
390
+ activeConnections === undefined ||
391
+ maxConnections === undefined ||
392
+ queueDepth === undefined ||
393
+ maxQueueDepth === undefined
394
+ ) {
395
+ return undefined;
396
+ }
397
+ if (maxConnections <= 0) {
398
+ return undefined;
399
+ }
400
+ const connectionsFull = activeConnections >= maxConnections;
401
+ const queueUnavailable = maxQueueDepth <= 0;
402
+ const queueFull = queueUnavailable || queueDepth >= maxQueueDepth;
403
+ return connectionsFull && queueFull ? now + SELLER_CAPACITY_BLOCK_MS : undefined;
404
+ }
405
+
230
406
  function reorderDefaultSellerFirst(sellers: RegistrySeller[], defaultSellerId: string | undefined): RegistrySeller[] {
231
407
  if (!defaultSellerId) {
232
408
  return sellers;
@@ -275,6 +451,15 @@ export class TokenbuddyDaemon {
275
451
  private selectionMode: "auto" | "manual";
276
452
  private selectedSellerId?: string;
277
453
  private sellerRouting: BuyerSellerRoutingConfig;
454
+ private lastRoutingPrewarmKey?: string;
455
+ private readonly lazyPrewarmKeys = new Set<string>();
456
+ private clawtipActivationWait?: Promise<void>;
457
+ private clawtipActivationWaitCancelToken?: { cancelled: boolean };
458
+ /**
459
+ * tb-ui v1 控制平面 `PUT /prewarm/focus-set` 写入的 explicit focus set。
460
+ * 优先级最高;`null` 表示回退到 env / historical(与 `resolveFocusSet()` 原行为一致)。
461
+ */
462
+ private currentFocusSet: string[] | null = null;
278
463
 
279
464
  private activePurchases = new Map<string, Promise<string>>();
280
465
 
@@ -302,13 +487,20 @@ export class TokenbuddyDaemon {
302
487
  this.tokenStore = new BuyerStore({ dbPath: config.dbPath });
303
488
  const storedRouting = this.tokenStore.getDaemonRuntimeConfig<unknown>(ROUTING_CONFIG_KEY)
304
489
  ?.config;
490
+ const storedFocusSet = this.tokenStore.getDaemonRuntimeConfig<{ models?: string[] }>(FOCUS_SET_CONFIG_KEY)
491
+ ?.config;
305
492
  this.config = config;
306
493
  this.sellerRouting = mergeSellerRoutingConfig(
307
494
  storedRouting,
308
495
  config.sellerRouting
309
496
  );
310
- this.selectionMode = this.sellerRouting.mode === "fullAuto" ? "auto" : "manual";
311
- this.selectedSellerId = this.sellerRouting.mode === "fixed" ? this.sellerRouting.sellerId : undefined;
497
+ this.selectionMode = selectionModeForRouting(this.sellerRouting);
498
+ this.selectedSellerId = selectedSellerIdForRouting(this.sellerRouting);
499
+ // tb-ui v1: explicit focus set 优先于 env / historical
500
+ if (storedFocusSet && Array.isArray(storedFocusSet.models)) {
501
+ const deduped = Array.from(new Set(storedFocusSet.models.map((m) => m.trim()).filter(Boolean)));
502
+ this.currentFocusSet = deduped.length > 0 ? deduped : null;
503
+ }
312
504
  // v1.2 §18.5: scheduler is created here (not in the field initializer)
313
505
  // because it needs the config-derived prober + idle interval.
314
506
  Object.assign(this, {
@@ -339,7 +531,22 @@ export class TokenbuddyDaemon {
339
531
  if (!res.ok) {
340
532
  return { ok: false, latencyMs: Date.now() - startedAt, httpStatus: res.status, errorMessage: `health returned ${res.status}` };
341
533
  }
342
- return { ok: true, latencyMs: Date.now() - startedAt, httpStatus: res.status };
534
+ const now = Date.now();
535
+ const body = await res.json() as SellerHealthBody;
536
+ const upstream = body.upstream;
537
+ const upstreamErrorClass = upstream?.lastErrorClass ?? upstream?.last_error_class;
538
+ return {
539
+ ok: true,
540
+ latencyMs: now - startedAt,
541
+ httpStatus: res.status,
542
+ upstreamStatus: typeof upstream?.status === "string"
543
+ ? upstream.status as "healthy" | "degraded" | "unhealthy" | "unknown"
544
+ : undefined,
545
+ upstreamErrorClass: typeof upstreamErrorClass === "string"
546
+ ? upstreamErrorClass
547
+ : undefined,
548
+ capacityBlockedUntil: capacityBlockedUntilFromHealth(body, now)
549
+ };
343
550
  } catch (err) {
344
551
  const message = err instanceof Error ? err.message : String(err);
345
552
  return { ok: false, latencyMs: 0, errorMessage: message };
@@ -357,6 +564,201 @@ export class TokenbuddyDaemon {
357
564
  return typeof address === "object" && address ? address.port : this.config.proxyPort;
358
565
  }
359
566
 
567
+ private clawtipStaticDir(): string {
568
+ return path.join(path.dirname(this.config.dbPath), "static", "clawtip");
569
+ }
570
+
571
+ private bundledClawtipStaticDir(): string | undefined {
572
+ if (this.config.clawtipBundledStaticDir === false) {
573
+ return undefined;
574
+ }
575
+ if (typeof this.config.clawtipBundledStaticDir === "string") {
576
+ return fs.existsSync(this.config.clawtipBundledStaticDir) ? this.config.clawtipBundledStaticDir : undefined;
577
+ }
578
+ const here = typeof __filename !== "undefined" ? path.dirname(__filename) : process.cwd();
579
+ const candidates = [
580
+ path.resolve(here, "../static/clawtip"),
581
+ path.resolve(here, "../../static/clawtip"),
582
+ path.resolve(process.cwd(), "packages/tokenbuddy-cli/static/clawtip")
583
+ ];
584
+ return candidates.find((candidate) => fs.existsSync(candidate));
585
+ }
586
+
587
+ private clawtipPublicUrl(fileName: string): string {
588
+ return `${CLAWTIP_STATIC_ROUTE}/${encodeURIComponent(fileName)}`;
589
+ }
590
+
591
+ private ensureClawtipStaticAssets(): void {
592
+ const outputDir = this.clawtipStaticDir();
593
+ fs.mkdirSync(outputDir, { recursive: true });
594
+ const rechargeOutputPath = path.join(outputDir, CLAWTIP_RECHARGE_QR_FILE);
595
+ if (fs.existsSync(rechargeOutputPath)) {
596
+ return;
597
+ }
598
+ const bundledDir = this.bundledClawtipStaticDir();
599
+ const rechargeSourcePath = bundledDir ? path.join(bundledDir, CLAWTIP_RECHARGE_QR_FILE) : undefined;
600
+ if (rechargeSourcePath && fs.existsSync(rechargeSourcePath)) {
601
+ fs.copyFileSync(rechargeSourcePath, rechargeOutputPath);
602
+ }
603
+ }
604
+
605
+ private copyClawtipQrToStatic(mediaPath: string, orderNo: string): { fileName: string; url: string; path: string } {
606
+ if (!fs.existsSync(mediaPath)) {
607
+ throw new Error(`ClawTip QR image does not exist: ${mediaPath}`);
608
+ }
609
+ const extension = safeQrExtension(mediaPath);
610
+ const fileName = `${safeStaticFileSegment(orderNo)}-${Date.now()}${extension}`;
611
+ const outputDir = this.clawtipStaticDir();
612
+ fs.mkdirSync(outputDir, { recursive: true });
613
+ const outputPath = path.join(outputDir, fileName);
614
+ fs.copyFileSync(mediaPath, outputPath);
615
+ return {
616
+ fileName,
617
+ path: outputPath,
618
+ url: this.clawtipPublicUrl(fileName)
619
+ };
620
+ }
621
+
622
+ private async startClawtipActivationQr(): Promise<ClawtipQrResponse> {
623
+ const bootstrapUrl = process.env.TOKENBUDDY_BOOTSTRAP_URL || "https://tb-wallet-bootstrap.fly.dev";
624
+ const fetchBootstrap = this.config.clawtipBootstrapFetcher || fetchClawtipBootstrap;
625
+ const bootstrap = await fetchBootstrap(bootstrapUrl);
626
+ const payment = normalizeClawtipActivationPayment(bootstrap);
627
+ const startBootstrap = this.config.clawtipWalletBootstrapStarter || startClawtipWalletBootstrap;
628
+ const activation = await startBootstrap(payment, { home: this.config.clawtipHomeDir });
629
+ if (!activation.parsedOutput.mediaPath) {
630
+ throw new Error("ClawTip activation did not return a QR image.");
631
+ }
632
+ const staticQr = this.copyClawtipQrToStatic(activation.parsedOutput.mediaPath, payment.orderNo);
633
+ const walletConfig = inspectOpenClawWalletConfig(this.config.clawtipHomeDir);
634
+ const existingPayment = this.tokenStore.getPayment("clawtip");
635
+ this.tokenStore.savePayment({
636
+ method: "clawtip",
637
+ enabled: walletConfig.exists,
638
+ isDefault: existingPayment?.isDefault ?? true,
639
+ config: {
640
+ ...(existingPayment?.config ?? {}),
641
+ bootstrapUrl,
642
+ orderNo: payment.orderNo,
643
+ amountFen: payment.amountFen,
644
+ indicator: payment.indicator,
645
+ slug: payment.slug,
646
+ skillId: payment.skillId,
647
+ description: payment.description,
648
+ resourceUrl: payment.resourceUrl,
649
+ activationOrderFile: activation.orderFile,
650
+ walletConfigPath: walletConfig.expectedPath,
651
+ walletConfigPresent: walletConfig.exists,
652
+ activationQrImagePath: activation.parsedOutput.mediaPath,
653
+ activationQrImageUrl: staticQr.url,
654
+ authUrl: activation.parsedOutput.authUrl,
655
+ clawtipId: activation.parsedOutput.clawtipId,
656
+ payCredentialWritten: Boolean(activation.payCredential),
657
+ activationCompletedBy: activation.payCredential
658
+ ? (walletConfig.exists ? "payCredential+wallet-config" : "payCredential")
659
+ : walletConfig.exists ? "wallet-config" : "pending-wallet-scan"
660
+ }
661
+ });
662
+ this.scheduleClawtipActivationWait(activation.parsedOutput.clawtipId);
663
+ return {
664
+ ok: true,
665
+ kind: "activate",
666
+ method: "clawtip",
667
+ orderNo: payment.orderNo,
668
+ amountFen: payment.amountFen,
669
+ qrImageUrl: staticQr.url,
670
+ sourceImagePath: activation.parsedOutput.mediaPath,
671
+ staticImagePath: staticQr.path,
672
+ authUrl: activation.parsedOutput.authUrl,
673
+ clawtipId: activation.parsedOutput.clawtipId,
674
+ orderFile: activation.orderFile,
675
+ walletConfigPath: walletConfig.expectedPath,
676
+ walletConfigPresent: walletConfig.exists,
677
+ requiresWalletAuth: activation.parsedOutput.requiresWalletAuth,
678
+ payCredentialWritten: Boolean(activation.payCredential)
679
+ };
680
+ }
681
+
682
+ private scheduleClawtipActivationWait(clawtipId?: string): void {
683
+ if (this.clawtipActivationWait) {
684
+ return;
685
+ }
686
+ const cancelToken = { cancelled: false };
687
+ this.clawtipActivationWaitCancelToken = cancelToken;
688
+ const waitForActivation = this.config.clawtipActivationWaiter || waitForClawtipActivationConfirmation;
689
+ this.clawtipActivationWait = waitForActivation({
690
+ clawtipId,
691
+ inspectWalletConfig: () => inspectOpenClawWalletConfig(this.config.clawtipHomeDir),
692
+ isCancelled: () => cancelToken.cancelled,
693
+ cancel: () => undefined
694
+ })
695
+ .then((walletRegistered) => {
696
+ if (cancelToken.cancelled) {
697
+ return;
698
+ }
699
+ if (!walletRegistered) {
700
+ logger.info("control.payment.clawtip.activation_wait.pending", "ClawTip activation wait ended before wallet registration");
701
+ return;
702
+ }
703
+ const walletConfig = inspectOpenClawWalletConfig(this.config.clawtipHomeDir);
704
+ const payment = this.tokenStore.getPayment("clawtip");
705
+ if (!payment || payment.method !== "clawtip") {
706
+ return;
707
+ }
708
+ this.tokenStore.savePayment({
709
+ ...payment,
710
+ enabled: walletConfig.exists,
711
+ config: {
712
+ ...(payment.config ?? {}),
713
+ walletConfigPath: walletConfig.expectedPath,
714
+ walletConfigPresent: walletConfig.exists,
715
+ activationCompletedBy: walletConfig.exists
716
+ ? "wallet-config"
717
+ : readConfigString(payment.config, "activationCompletedBy") ?? "pending-wallet-scan"
718
+ }
719
+ });
720
+ logger.info("control.payment.clawtip.activation_wait.completed", "ClawTip activation wait completed", {
721
+ walletRegistered,
722
+ walletConfigPresent: walletConfig.exists
723
+ });
724
+ })
725
+ .catch((error: unknown) => {
726
+ const errorMessage = error instanceof Error ? error.message : String(error);
727
+ logger.warn("control.payment.clawtip.activation_wait.failed", "ClawTip activation wait failed", { errorMessage });
728
+ })
729
+ .finally(() => {
730
+ if (this.clawtipActivationWaitCancelToken === cancelToken) {
731
+ this.clawtipActivationWaitCancelToken = undefined;
732
+ this.clawtipActivationWait = undefined;
733
+ }
734
+ });
735
+ }
736
+
737
+ private clawtipRechargeQr(): ClawtipQrResponse {
738
+ const payment = this.tokenStore.getPayment("clawtip");
739
+ const resourceUrl = readConfigString(payment?.config, "resourceUrl");
740
+ const orderNo = readConfigString(payment?.config, "orderNo") || "clawtip-recharge";
741
+ const mediaPath = path.join(this.clawtipStaticDir(), CLAWTIP_RECHARGE_QR_FILE);
742
+ if (!fs.existsSync(mediaPath)) {
743
+ throw new Error(`ClawTip fixed recharge QR image is missing: ${mediaPath}`);
744
+ }
745
+ const walletConfig = inspectOpenClawWalletConfig(this.config.clawtipHomeDir);
746
+ return {
747
+ ok: true,
748
+ kind: "recharge",
749
+ method: "clawtip",
750
+ orderNo,
751
+ qrImageUrl: this.clawtipPublicUrl(CLAWTIP_RECHARGE_QR_FILE),
752
+ sourceImagePath: mediaPath,
753
+ staticImagePath: mediaPath,
754
+ resourceUrl,
755
+ walletConfigPath: walletConfig.expectedPath,
756
+ walletConfigPresent: walletConfig.exists,
757
+ requiresWalletAuth: false,
758
+ payCredentialWritten: readConfigBoolean(payment?.config, "payCredentialWritten")
759
+ };
760
+ }
761
+
360
762
  // v1.2 §18.9: stale-cache fallback. The buyer remembers the last
361
763
  // successfully fetched registry document and reuses it when the
362
764
  // bootstrap returns 413 (`X-TokenBuddy-Registry-Too-Large: 1`). This
@@ -397,6 +799,7 @@ export class TokenbuddyDaemon {
397
799
  }
398
800
 
399
801
  private runtimeSummary() {
802
+ this.refreshSellerRoutingConfig();
400
803
  return {
401
804
  status: "running",
402
805
  pid: process.pid,
@@ -502,7 +905,7 @@ export class TokenbuddyDaemon {
502
905
  return payments.find((payment) => payment.isDefault)?.method || payments.find((payment) => payment.method === "mock")?.method;
503
906
  }
504
907
 
505
- private async selectSellerRoutes(endpoint: string, modelId: string): Promise<SellerRoute[]> {
908
+ private async selectSellerRoutes(endpoint: string, modelId: string): Promise<{ routes: SellerRoute[]; plan: SellerRoutePlan; paymentMethod: string }> {
506
909
  const protocol = this.endpointProtocol(endpoint);
507
910
  if (!protocol) {
508
911
  throw new Error(`unsupported proxy endpoint: ${endpoint}`);
@@ -515,12 +918,14 @@ export class TokenbuddyDaemon {
515
918
  // v1.2: registry is the source of truth for routing. We rebuild the
516
919
  // model-index once per request (cheap; index lookup is in-memory) so
517
920
  // the response always reflects the latest seller list. The previous
518
- // "fetchSellerManifest per candidate" path is removed in favor of
921
+ // "fetchSellerManifest per request" path is removed in favor of
519
922
  // pulling `models` directly off the registry entries.
520
923
  const registry = await this.fetchRegistry();
521
924
 
522
- const routing = this.sellerRouting;
925
+ const routing = resolveSellerRoutingForModel(this.refreshSellerRoutingConfig(), modelId);
523
926
  const registrySellers = reorderDefaultSellerFirst(registry.sellers, registry.defaultSeller);
927
+ this.sellerPool.ensureRegistrySellers(registrySellers);
928
+ this.scheduleLazyPrewarmIfNeeded(modelId, protocol, paymentMethod);
524
929
  const poolById = new Map(this.sellerPool.snapshot().map((entry) => [entry.sellerId, entry]));
525
930
  const planned = planSellerRouteSet({
526
931
  modelId,
@@ -533,8 +938,12 @@ export class TokenbuddyDaemon {
533
938
  sellerId: entry.sellerId,
534
939
  healthScore: entry.healthScore,
535
940
  avgLatencyMs: entry.avgLatencyMs,
536
- circuit: entry.circuit
537
- }))
941
+ ttftMs: entry.ttftMs,
942
+ avgInferenceMs: entry.avgInferenceMs,
943
+ circuit: entry.circuit,
944
+ capacityBlockedUntil: entry.capacityBlockedUntil
945
+ })),
946
+ now: Date.now()
538
947
  });
539
948
 
540
949
  if (planned.routes.length === 0) {
@@ -564,7 +973,122 @@ export class TokenbuddyDaemon {
564
973
  sellerCount: routes.length,
565
974
  sellers: routes.map((route) => route.seller.id)
566
975
  });
567
- return routes;
976
+ return { routes, plan: planned, paymentMethod };
977
+ }
978
+
979
+ private refreshSellerRoutingConfig(): BuyerSellerRoutingConfig {
980
+ const storedRouting = this.tokenStore.getDaemonRuntimeConfig<unknown>(ROUTING_CONFIG_KEY)
981
+ ?.config;
982
+ const nextRouting = mergeSellerRoutingConfig(
983
+ storedRouting,
984
+ this.config.sellerRouting
985
+ );
986
+
987
+ if (!sameSellerRouting(this.sellerRouting, nextRouting)) {
988
+ const previous = this.sellerRouting;
989
+ this.sellerRouting = nextRouting;
990
+ this.selectionMode = selectionModeForRouting(nextRouting);
991
+ this.selectedSellerId = selectedSellerIdForRouting(nextRouting);
992
+ logger.info("routing.config.reloaded", "seller routing config reloaded", {
993
+ previousMode: previous.mode,
994
+ previousScorer: previous.scorer,
995
+ sellerRoutingMode: nextRouting.mode,
996
+ sellerRoutingScorer: nextRouting.scorer,
997
+ selectedSellerId: this.selectedSellerId
998
+ });
999
+ void this.runRoutingPrewarmSweep(nextRouting);
1000
+ }
1001
+
1002
+ return this.sellerRouting;
1003
+ }
1004
+
1005
+ private async runRoutingPrewarmSweep(routing: BuyerSellerRoutingConfig): Promise<void> {
1006
+ const focusSet = this.resolveFocusSet();
1007
+ const routingPrewarmKey = `${routingKey(routing)}\u0001${focusSet.join("\u0001")}`;
1008
+ if (focusSet.length === 0) {
1009
+ logger.info("prewarm.routing.skipped", "no focus set configured after routing reload; relying on lazy prewarms", {
1010
+ sellerRoutingMode: routing.mode,
1011
+ sellerRoutingScorer: routing.scorer
1012
+ });
1013
+ return;
1014
+ }
1015
+ if (this.lastRoutingPrewarmKey === routingPrewarmKey) {
1016
+ return;
1017
+ }
1018
+ this.lastRoutingPrewarmKey = routingPrewarmKey;
1019
+ logger.info("prewarm.routing.scheduled", "routing reload prewarm sweep scheduled", {
1020
+ sellerRoutingMode: routing.mode,
1021
+ sellerRoutingScorer: routing.scorer,
1022
+ focusSetSize: focusSet.length,
1023
+ focusSet: focusSet.slice(0, 20)
1024
+ });
1025
+ try {
1026
+ await this.fetchRegistry();
1027
+ const paymentMethod = this.defaultPaymentMethod();
1028
+ for (const modelId of focusSet) {
1029
+ this.schedulePrewarmForModel({
1030
+ modelId,
1031
+ reason: "explicit",
1032
+ protocol: this.resolvePrewarmProtocol(modelId, paymentMethod),
1033
+ paymentMethod
1034
+ });
1035
+ }
1036
+ } catch (err) {
1037
+ logger.warn("prewarm.routing.failed", "routing reload prewarm sweep failed", {
1038
+ sellerRoutingMode: routing.mode,
1039
+ sellerRoutingScorer: routing.scorer,
1040
+ errorMessage: err instanceof Error ? err.message : String(err)
1041
+ });
1042
+ }
1043
+ }
1044
+
1045
+ private scheduleLazyPrewarmIfNeeded(modelId: string, protocol: string, paymentMethod: string): void {
1046
+ const freshness = this.prewarmCache.freshness(modelId, protocol, paymentMethod);
1047
+ if (freshness.present && !freshness.expired) {
1048
+ return;
1049
+ }
1050
+ const key = prewarmKey(modelId, protocol, paymentMethod);
1051
+ if (this.lazyPrewarmKeys.has(key)) {
1052
+ return;
1053
+ }
1054
+ this.lazyPrewarmKeys.add(key);
1055
+ logger.info("prewarm.lazy.scheduled", "lazy prewarm scheduled for requested model", {
1056
+ modelId,
1057
+ protocol,
1058
+ paymentMethod,
1059
+ freshnessState: freshness.state
1060
+ });
1061
+ this.schedulePrewarmForModel({
1062
+ modelId,
1063
+ reason: "lazy",
1064
+ protocol,
1065
+ paymentMethod
1066
+ }).finally(() => {
1067
+ this.lazyPrewarmKeys.delete(key);
1068
+ });
1069
+ }
1070
+
1071
+ private schedulePrewarmForModel(input: {
1072
+ modelId: string;
1073
+ reason: PrewarmReason;
1074
+ protocol?: string;
1075
+ paymentMethod?: string;
1076
+ }): Promise<unknown> {
1077
+ if (!input.protocol || !input.paymentMethod) {
1078
+ logger.warn("prewarm.schedule.skipped", "prewarm schedule skipped because protocol or payment method is missing", {
1079
+ modelId: input.modelId,
1080
+ reason: input.reason,
1081
+ protocol: input.protocol,
1082
+ paymentMethod: input.paymentMethod
1083
+ });
1084
+ return Promise.resolve();
1085
+ }
1086
+ return this.prewarmScheduler.schedulePrewarm({
1087
+ modelId: input.modelId,
1088
+ reason: input.reason,
1089
+ protocol: input.protocol,
1090
+ paymentMethod: input.paymentMethod
1091
+ });
568
1092
  }
569
1093
 
570
1094
  private failoverErrorMessage(error: unknown): string {
@@ -580,13 +1104,16 @@ export class TokenbuddyDaemon {
580
1104
  * caller side because it short-circuits the failure path with a
581
1105
  * re-purchase.
582
1106
  */
583
- private classifyFailureStatus(status: number): FailureKind {
1107
+ private classifyFailureStatus(status: number, bodyText?: string): FailureKind {
584
1108
  if (status === 401 || status === 403) {
585
1109
  return "auth_invalid";
586
1110
  }
587
1111
  if (status === 402) {
588
1112
  return "insufficient_funds";
589
1113
  }
1114
+ if (status === 429 && isBusyCapacityErrorBody(bodyText)) {
1115
+ return "busy_capacity";
1116
+ }
590
1117
  if (status === 400 || status === 404 || status === 422) {
591
1118
  return "hard_4xx";
592
1119
  }
@@ -770,7 +1297,16 @@ export class TokenbuddyDaemon {
770
1297
  usage: UsageSummary,
771
1298
  settlement: SellerSettlementSummary | undefined,
772
1299
  prompt: string | undefined,
773
- response?: string
1300
+ response?: string,
1301
+ extras?: {
1302
+ ttftMs?: number;
1303
+ fallbackCount?: number;
1304
+ routeReason?: string;
1305
+ falloverChain?: string[];
1306
+ upstreamStatus?: string;
1307
+ durationMs?: number;
1308
+ paymentMethod?: string;
1309
+ }
774
1310
  ): void {
775
1311
  if (settlement) {
776
1312
  this.tokenStore.reconcileTokenBalance({
@@ -800,7 +1336,14 @@ export class TokenbuddyDaemon {
800
1336
  balanceSnapshotMicros: settlement?.remainingCreditMicros,
801
1337
  balanceSource: settlement ? "seller_authoritative" : "estimated",
802
1338
  prompt,
803
- response
1339
+ response,
1340
+ ttftMs: extras?.ttftMs,
1341
+ fallbackCount: extras?.fallbackCount,
1342
+ routeReason: extras?.routeReason,
1343
+ falloverChain: extras?.falloverChain,
1344
+ upstreamStatus: extras?.upstreamStatus,
1345
+ durationMs: extras?.durationMs,
1346
+ paymentMethod: extras?.paymentMethod
804
1347
  });
805
1348
  logger.info("inference.ledger.recorded", "safe inference ledger recorded", {
806
1349
  requestId: settlement?.requestId || requestId,
@@ -815,7 +1358,14 @@ export class TokenbuddyDaemon {
815
1358
  promptTokens: usage.promptTokens,
816
1359
  completionTokens: usage.completionTokens,
817
1360
  balanceSnapshotMicros: settlement?.remainingCreditMicros,
818
- balanceSource: settlement ? "seller_authoritative" : "estimated"
1361
+ balanceSource: settlement ? "seller_authoritative" : "estimated",
1362
+ ttftMs: extras?.ttftMs,
1363
+ fallbackCount: extras?.fallbackCount,
1364
+ routeReason: extras?.routeReason,
1365
+ falloverChain: extras?.falloverChain,
1366
+ upstreamStatus: extras?.upstreamStatus,
1367
+ durationMs: extras?.durationMs,
1368
+ paymentMethod: extras?.paymentMethod
819
1369
  });
820
1370
  }
821
1371
 
@@ -1316,6 +1866,10 @@ export class TokenbuddyDaemon {
1316
1866
 
1317
1867
  private async forwardProxyRequest(endpoint: string, req: Request, res: Response): Promise<void> {
1318
1868
  const startedAt = Date.now();
1869
+ let firstByteAt: number | null = null;
1870
+ const markFirstByte = (): void => {
1871
+ if (firstByteAt === null) firstByteAt = Date.now();
1872
+ };
1319
1873
  const body = req.body || {};
1320
1874
  const { requestedModelId, resolvedModelId } = this.resolveRouteModelId(endpoint, body);
1321
1875
  const modelId = resolvedModelId;
@@ -1328,7 +1882,12 @@ export class TokenbuddyDaemon {
1328
1882
  }
1329
1883
 
1330
1884
  try {
1331
- const routes = await this.selectSellerRoutes(endpoint, modelId);
1885
+ const { routes, plan, paymentMethod } = await this.selectSellerRoutes(endpoint, modelId);
1886
+ const upstreamStatusFromHeaders = (h: Headers): string | undefined => {
1887
+ const raw = h.get("x-tokenbuddy-upstream-status") || h.get("x-upstream-status");
1888
+ if (!raw) return undefined;
1889
+ return raw === "healthy" || raw === "degraded" || raw === "unhealthy" || raw === "unknown" ? raw : "unknown";
1890
+ };
1332
1891
  let lastError: unknown;
1333
1892
 
1334
1893
  for (let routeIndex = 0; routeIndex < routes.length; routeIndex += 1) {
@@ -1336,6 +1895,7 @@ export class TokenbuddyDaemon {
1336
1895
  const sellerKey = route.seller.id;
1337
1896
  logger.info("route.selected", "seller route selected", {
1338
1897
  sellerKey,
1898
+ sellerId: sellerKey,
1339
1899
  model: modelId,
1340
1900
  endpoint,
1341
1901
  protocol: route.protocol,
@@ -1493,7 +2053,7 @@ export class TokenbuddyDaemon {
1493
2053
  status: upstreamResponse.status,
1494
2054
  durationMs: Date.now() - startedAt
1495
2055
  });
1496
- const kind: FailureKind = this.classifyFailureStatus(upstreamResponse.status);
2056
+ const kind: FailureKind = this.classifyFailureStatus(upstreamResponse.status, errorBody);
1497
2057
  const decision = this.routeFailover.decide(
1498
2058
  {
1499
2059
  sellerId: sellerKey,
@@ -1568,6 +2128,7 @@ export class TokenbuddyDaemon {
1568
2128
  // 缺 event: 行)由卖方修,buyer 不兜底。
1569
2129
  const sellerChunk = settlementExtractor.push(chunk);
1570
2130
  if (sellerChunk.length > 0) {
2131
+ markFirstByte();
1571
2132
  res.write(sellerChunk);
1572
2133
  }
1573
2134
  }
@@ -1579,11 +2140,13 @@ export class TokenbuddyDaemon {
1579
2140
  if (decoderTail.length > 0) {
1580
2141
  const sellerTail = settlementExtractor.push(decoderTail);
1581
2142
  if (sellerTail.length > 0) {
2143
+ markFirstByte();
1582
2144
  res.write(sellerTail);
1583
2145
  }
1584
2146
  }
1585
2147
  const settlementTrailing = settlementExtractor.finish();
1586
2148
  if (settlementTrailing.downstream.length > 0) {
2149
+ markFirstByte();
1587
2150
  res.write(settlementTrailing.downstream);
1588
2151
  }
1589
2152
  res.end();
@@ -1593,12 +2156,23 @@ export class TokenbuddyDaemon {
1593
2156
  requestId,
1594
2157
  { promptTokens: 0, completionTokens: 0, billedMicros: Math.max(1, bytes) },
1595
2158
  this.parseSellerSettlementSummary(upstreamResponse.headers) ?? settlementTrailing.settlement ?? settlementExtractor.current(),
1596
- this.inferPromptForHash(body)
2159
+ this.inferPromptForHash(body),
2160
+ undefined,
2161
+ {
2162
+ ttftMs: firstByteAt ? firstByteAt - startedAt : undefined,
2163
+ fallbackCount: routeIndex,
2164
+ routeReason: plan.reason,
2165
+ falloverChain: routes.slice(0, routeIndex + 1).map((r) => r.seller.id),
2166
+ upstreamStatus: upstreamStatusFromHeaders(upstreamResponse.headers),
2167
+ durationMs: Date.now() - startedAt,
2168
+ paymentMethod
2169
+ }
1597
2170
  );
1598
2171
  return;
1599
2172
  }
1600
2173
 
1601
2174
  const responseBody = await upstreamResponse.text();
2175
+ markFirstByte();
1602
2176
  res.send(responseBody);
1603
2177
  const usage = this.readUsage(responseBody);
1604
2178
  this.recordReconciledInference(
@@ -1608,7 +2182,16 @@ export class TokenbuddyDaemon {
1608
2182
  usage,
1609
2183
  this.parseSellerSettlementSummary(upstreamResponse.headers),
1610
2184
  this.inferPromptForHash(body),
1611
- responseBody
2185
+ responseBody,
2186
+ {
2187
+ ttftMs: firstByteAt ? firstByteAt - startedAt : undefined,
2188
+ fallbackCount: routeIndex,
2189
+ routeReason: plan.reason,
2190
+ falloverChain: routes.slice(0, routeIndex + 1).map((r) => r.seller.id),
2191
+ upstreamStatus: upstreamStatusFromHeaders(upstreamResponse.headers),
2192
+ durationMs: Date.now() - startedAt,
2193
+ paymentMethod
2194
+ }
1612
2195
  );
1613
2196
  return;
1614
2197
  } catch (routeError: unknown) {
@@ -1710,10 +2293,42 @@ export class TokenbuddyDaemon {
1710
2293
  controlApp.get("/payments", (req, res) => {
1711
2294
  logger.info("control.payments.requested", "control payments requested", {});
1712
2295
  res.status(200).json({
1713
- payments: this.tokenStore.listPayments()
2296
+ payments: this.tokenStore.listPayments().map((payment) => withLiveClawtipWalletState(payment, this.config.clawtipHomeDir))
1714
2297
  });
1715
2298
  });
1716
2299
 
2300
+ controlApp.post("/payments/clawtip/activate", async (req, res) => {
2301
+ try {
2302
+ const qr = await this.startClawtipActivationQr();
2303
+ logger.info("control.payment.clawtip.activate_qr.created", "ClawTip activation QR copied for tb-ui", {
2304
+ orderNo: qr.orderNo,
2305
+ qrImageUrl: qr.qrImageUrl,
2306
+ walletConfigPresent: qr.walletConfigPresent
2307
+ });
2308
+ res.status(200).json(qr);
2309
+ } catch (error: unknown) {
2310
+ const errorMessage = error instanceof Error ? error.message : String(error);
2311
+ logger.warn("control.payment.clawtip.activate_qr.failed", "ClawTip activation QR failed", { errorMessage });
2312
+ res.status(500).json({ error: { code: "clawtip_activate_qr_failed", message: errorMessage } });
2313
+ }
2314
+ });
2315
+
2316
+ controlApp.post("/payments/clawtip/recharge", (req, res) => {
2317
+ try {
2318
+ const qr = this.clawtipRechargeQr();
2319
+ logger.info("control.payment.clawtip.recharge_qr.created", "ClawTip fixed recharge QR served for tb-ui", {
2320
+ orderNo: qr.orderNo,
2321
+ qrImageUrl: qr.qrImageUrl,
2322
+ walletConfigPresent: qr.walletConfigPresent
2323
+ });
2324
+ res.status(200).json(qr);
2325
+ } catch (error: unknown) {
2326
+ const errorMessage = error instanceof Error ? error.message : String(error);
2327
+ logger.warn("control.payment.clawtip.recharge_qr.failed", "ClawTip recharge QR failed", { errorMessage });
2328
+ res.status(500).json({ error: { code: "clawtip_recharge_qr_failed", message: errorMessage } });
2329
+ }
2330
+ });
2331
+
1717
2332
  controlApp.get("/ledger/purchases", (req, res) => {
1718
2333
  logger.info("control.ledger.requested", "control purchase ledger requested", {
1719
2334
  ledger: "purchases"
@@ -1855,6 +2470,41 @@ export class TokenbuddyDaemon {
1855
2470
  }
1856
2471
  });
1857
2472
 
2473
+ controlApp.get("/providers/status", (_req, res) => {
2474
+ try {
2475
+ const providerStatuses = detectProviders({ home: this.config.providerHomeDir }).map(clientToolStatusFromProvider);
2476
+ const clients = [
2477
+ ...providerStatuses,
2478
+ buildCustomClientToolStatus(this.activeProxyPort()),
2479
+ ];
2480
+ const configuredCount = clients.filter((client) => client.configured).length;
2481
+ const detectedCount = clients.filter((client) => client.detected && client.status !== "manual").length;
2482
+ logger.info("provider.status.requested", "provider status requested", {
2483
+ clientCount: clients.length,
2484
+ configuredCount,
2485
+ detectedCount
2486
+ });
2487
+ res.status(200).json({
2488
+ clients,
2489
+ summary: {
2490
+ configuredCount,
2491
+ detectedCount,
2492
+ totalCount: clients.length,
2493
+ installCommand: "tb init"
2494
+ }
2495
+ });
2496
+ } catch (error: unknown) {
2497
+ const errorMessage = error instanceof Error ? error.message : String(error);
2498
+ logger.warn("provider.status.failed", "provider status failed", { errorMessage });
2499
+ res.status(400).json({
2500
+ error: {
2501
+ code: "provider_status_failed",
2502
+ message: errorMessage
2503
+ }
2504
+ });
2505
+ }
2506
+ });
2507
+
1858
2508
  controlApp.post("/providers/install/preview", (req, res) => {
1859
2509
  try {
1860
2510
  const changes = previewProviderInstall({
@@ -1932,6 +2582,172 @@ export class TokenbuddyDaemon {
1932
2582
  }
1933
2583
  });
1934
2584
 
2585
+ // ─────────────────────────────────────────────────────────────────
2586
+ // tb-ui v1: 控制平面写端点(PR-0)
2587
+ // 所有端点都热生效,不需重启 daemon。改完 store 即下次推理生效。
2588
+ // ─────────────────────────────────────────────────────────────────
2589
+
2590
+ // 1) GET /routing/strategy — 读当前路由策略 + 来源
2591
+ controlApp.get("/routing/strategy", (req, res) => {
2592
+ try {
2593
+ const stored = this.tokenStore.getDaemonRuntimeConfig<unknown>(ROUTING_CONFIG_KEY)?.config;
2594
+ const current = this.refreshSellerRoutingConfig();
2595
+ const source: "store" | "config" | "default" =
2596
+ stored !== undefined ? "store" : this.config.sellerRouting ? "config" : "default";
2597
+ logger.info("routing.strategy.read", "routing strategy read", {
2598
+ source,
2599
+ mode: current.mode,
2600
+ scorer: current.scorer
2601
+ });
2602
+ res.status(200).json({ strategy: current, source });
2603
+ } catch (error: unknown) {
2604
+ const errorMessage = error instanceof Error ? error.message : String(error);
2605
+ logger.warn("routing.strategy.read_failed", "routing strategy read failed", { errorMessage });
2606
+ res.status(500).json({ error: { code: "routing_strategy_read_failed", message: errorMessage } });
2607
+ }
2608
+ });
2609
+
2610
+ // 2) GET /routing/preview — 算「假如改完会怎样」,不改 state
2611
+ // query: modelId? protocol? paymentMethod? mode? scorer? sellerId? sellerIds?(逗号分隔)
2612
+ controlApp.get("/routing/preview", (req, res) => {
2613
+ try {
2614
+ const override = buildRoutingConfigFromQuery(req.query);
2615
+ const result = this.buildRoutingPreview({
2616
+ modelId: typeof req.query.modelId === "string" ? req.query.modelId : undefined,
2617
+ protocol: typeof req.query.protocol === "string" ? req.query.protocol : undefined,
2618
+ paymentMethod: typeof req.query.paymentMethod === "string" ? req.query.paymentMethod : undefined,
2619
+ routing: override ?? undefined
2620
+ });
2621
+ if ("error" in result.plan) {
2622
+ res.status(409).json({
2623
+ error: { code: result.plan.error, message: `cannot preview routing: ${result.plan.error}` },
2624
+ modelId: result.modelId,
2625
+ protocol: result.protocol,
2626
+ paymentMethod: result.paymentMethod
2627
+ });
2628
+ return;
2629
+ }
2630
+ res.status(200).json({
2631
+ modelId: result.modelId,
2632
+ protocol: result.protocol,
2633
+ paymentMethod: result.paymentMethod,
2634
+ plan: result.plan
2635
+ });
2636
+ } catch (error: unknown) {
2637
+ const errorMessage = error instanceof Error ? error.message : String(error);
2638
+ logger.warn("routing.preview.failed", "routing preview failed", { errorMessage });
2639
+ res.status(400).json({ error: { code: "routing_preview_failed", message: errorMessage } });
2640
+ }
2641
+ });
2642
+
2643
+ // 3) PUT /routing/strategy — 写策略 + 热更新 + 返回 preview
2644
+ controlApp.put("/routing/strategy", (req, res) => {
2645
+ try {
2646
+ const body = (req.body ?? {}) as Record<string, unknown>;
2647
+ const normalized = normalizeSellerRoutingConfig(body);
2648
+ // 必填字段再次校验(normalize 会回退 default,但 PUT 必须显式)
2649
+ assertSellerRoutingConfig(normalized);
2650
+ this.tokenStore.saveDaemonRuntimeConfig(ROUTING_CONFIG_KEY, normalized);
2651
+ const current = this.refreshSellerRoutingConfig();
2652
+ logger.info("routing.strategy.applied", "routing strategy applied", {
2653
+ mode: current.mode,
2654
+ scorer: current.scorer,
2655
+ sellerId: current.sellerId,
2656
+ sellerIds: current.sellerIds
2657
+ });
2658
+ const preview = this.buildRoutingPreview({ routing: current });
2659
+ const previewPayload = "error" in preview.plan
2660
+ ? { error: preview.plan.error }
2661
+ : {
2662
+ modelId: preview.modelId,
2663
+ protocol: preview.protocol,
2664
+ paymentMethod: preview.paymentMethod,
2665
+ ...preview.plan
2666
+ };
2667
+ res.status(200).json({ applied: true, strategy: current, preview: previewPayload });
2668
+ } catch (error: unknown) {
2669
+ const errorMessage = error instanceof Error ? error.message : String(error);
2670
+ logger.warn("routing.strategy.apply_failed", "routing strategy apply failed", { errorMessage });
2671
+ res.status(400).json({ error: { code: "routing_strategy_apply_failed", message: errorMessage } });
2672
+ }
2673
+ });
2674
+
2675
+ // 4) PUT /prewarm/focus-set — 设置 explicit focus set(覆盖 config.warmupModels / env)
2676
+ // body: { models: ["claude-3-5-sonnet", "gpt-4o"], clear?: false }
2677
+ // clear=true 时 models 数组可省略;表示回退 env / historical
2678
+ controlApp.put("/prewarm/focus-set", (req, res) => {
2679
+ try {
2680
+ const body = (req.body ?? {}) as Record<string, unknown>;
2681
+ const clear = body.clear === true;
2682
+ if (clear) {
2683
+ const result = this.applyFocusSet(null);
2684
+ res.status(200).json({ ok: true, ...result });
2685
+ return;
2686
+ }
2687
+ if (!Array.isArray(body.models)) {
2688
+ res.status(400).json({
2689
+ error: { code: "invalid_focus_set", message: "focus-set body must have a string[] `models` field, or `clear: true`" }
2690
+ });
2691
+ return;
2692
+ }
2693
+ const models = body.models
2694
+ .filter((m): m is string => typeof m === "string")
2695
+ .map((m) => m.trim())
2696
+ .filter(Boolean);
2697
+ const result = this.applyFocusSet(models);
2698
+ res.status(200).json({ ok: true, ...result });
2699
+ } catch (error: unknown) {
2700
+ const errorMessage = error instanceof Error ? error.message : String(error);
2701
+ logger.warn("focus_set.apply_failed", "focus set apply failed", { errorMessage });
2702
+ res.status(400).json({ error: { code: "focus_set_apply_failed", message: errorMessage } });
2703
+ }
2704
+ });
2705
+
2706
+ // 5) POST /daemon/restart — 优雅重启 tb-proxyd(调子进程 `tb daemon restart`)
2707
+ // 现有 CLI 子命令(cli.ts:1129)。detached 模式让子进程独立于 daemon 生命周期。
2708
+ controlApp.post("/daemon/restart", (req, res) => {
2709
+ try {
2710
+ const child = spawn("tb", ["daemon", "restart"], {
2711
+ detached: true,
2712
+ stdio: "ignore"
2713
+ });
2714
+ child.unref();
2715
+ logger.info("daemon.restart.scheduled", "daemon restart scheduled via tb CLI");
2716
+ res.status(202).json({ ok: true, message: "restart scheduled" });
2717
+ } catch (error: unknown) {
2718
+ const errorMessage = error instanceof Error ? error.message : String(error);
2719
+ logger.warn("daemon.restart.failed", "daemon restart failed", { errorMessage });
2720
+ res.status(500).json({ error: { code: "daemon_restart_failed", message: errorMessage } });
2721
+ }
2722
+ });
2723
+
2724
+ this.ensureClawtipStaticAssets();
2725
+ controlApp.use(CLAWTIP_STATIC_ROUTE, express.static(this.clawtipStaticDir(), { index: false, fallthrough: false }));
2726
+
2727
+ // ────────────────────────────────────────────────────────────
2728
+ // tb-ui v1: 静态托管 SPA(dist 由 `npm run build --workspace tb-ui` 生成)
2729
+ // 必须在所有 API 路由**之后**才挂载,这样:
2730
+ // - `/health` `/ledger/purchases` 等 API 路径仍由上面 17+ 个端点处理
2731
+ // - 真实静态文件(`/index.html` `/assets/index-abc.js` 等)由 express.static 服务
2732
+ // - 未匹配路径(`/overview` `/routing` 等 React Router 路径)走 SPA fallback 回 index.html
2733
+ // ────────────────────────────────────────────────────────────
2734
+ const uiDir = resolveUiDir();
2735
+ if (uiDir && fs.existsSync(uiDir) && fs.existsSync(path.join(uiDir, "index.html"))) {
2736
+ controlApp.use(express.static(uiDir, { index: "index.html", fallthrough: true }));
2737
+ // SPA fallback: React Router 用 BrowserRouter(history mode),客户端路径如
2738
+ // /overview /routing /ledger 不对应任何文件,必须回 index.html 让 React Router 接管。
2739
+ // Express 已经按注册顺序匹配了所有 17+ 个 API 路由,这里只接住"什么都没匹配"的 GET。
2740
+ controlApp.get(/^(?!\/(?:health|status|payments|sellers|models|ledger|routing|prewarm|v1\.2|daemon|providers|static)\/).*/, (_req, res) => {
2741
+ res.sendFile(path.join(uiDir, "index.html"));
2742
+ });
2743
+ logger.info("ui.static.attached", "tb-ui dist attached to control plane SPA fallback", { uiDir });
2744
+ } else {
2745
+ logger.warn("ui.static.missing", "tb-ui dist not found; only control plane API will be served", {
2746
+ uiDir,
2747
+ hint: "run `npm run build --workspace tb-ui` then restart"
2748
+ });
2749
+ }
2750
+
1935
2751
  this.controlServer = controlApp.listen(this.config.controlPort);
1936
2752
 
1937
2753
  // 2. Proxy Plane Server (17821)
@@ -1999,9 +2815,13 @@ export class TokenbuddyDaemon {
1999
2815
  /**
2000
2816
  * v1.2 §18.4: build the focus set from the explicit config, the env
2001
2817
  * override, and the historical usage in the buyer store. The order of
2002
- * precedence: explicit config > env > historical > empty.
2818
+ * precedence: explicit `currentFocusSet` (set via `PUT /prewarm/focus-set`)
2819
+ * > explicit config > env > historical > empty.
2003
2820
  */
2004
2821
  private resolveFocusSet(): string[] {
2822
+ if (this.currentFocusSet !== null) {
2823
+ return this.currentFocusSet;
2824
+ }
2005
2825
  const explicit = this.config.warmupModels ?? [];
2006
2826
  if (explicit.length > 0) {
2007
2827
  return explicit;
@@ -2014,6 +2834,93 @@ export class TokenbuddyDaemon {
2014
2834
  return this.tokenStore.recentModels(7, 5);
2015
2835
  }
2016
2836
 
2837
+ /**
2838
+ * tb-ui v1 `PUT /prewarm/focus-set` 调用的统一入口。`models === null`
2839
+ * 表示「清除 explicit focus set,回退 env/historical」。
2840
+ * 写 store + 触发 `runRoutingPrewarmSweep`,**热生效**(不需重启 daemon)。
2841
+ */
2842
+ public applyFocusSet(models: string[] | null): { focusSet: string[]; source: "explicit" | "env" | "historical" | "empty" } {
2843
+ if (models === null) {
2844
+ this.tokenStore.removeDaemonRuntimeConfig(FOCUS_SET_CONFIG_KEY);
2845
+ this.currentFocusSet = null;
2846
+ } else {
2847
+ const deduped = Array.from(new Set(models.map((m) => m.trim()).filter(Boolean)));
2848
+ this.tokenStore.saveDaemonRuntimeConfig(FOCUS_SET_CONFIG_KEY, { models: deduped });
2849
+ this.currentFocusSet = deduped;
2850
+ }
2851
+ // 触发路由重读 + 预热 sweep(如果当前 routing 已是焦点集合的依赖)。
2852
+ this.refreshSellerRoutingConfig();
2853
+ const focusSet = this.resolveFocusSet();
2854
+ const source: "explicit" | "env" | "historical" | "empty" =
2855
+ this.currentFocusSet !== null
2856
+ ? "explicit"
2857
+ : (this.config.warmupModels?.length ?? 0) > 0
2858
+ ? "explicit"
2859
+ : (process.env.TB_BUYER_WARMUP_MODELS?.trim() ?? "").length > 0
2860
+ ? "env"
2861
+ : focusSet.length > 0
2862
+ ? "historical"
2863
+ : "empty";
2864
+ logger.info("focus_set.applied", "explicit focus set applied", {
2865
+ source,
2866
+ focusSetSize: focusSet.length,
2867
+ focusSet: focusSet.slice(0, 20)
2868
+ });
2869
+ return { focusSet, source };
2870
+ }
2871
+
2872
+ /**
2873
+ * tb-ui v1 `GET /routing/preview` 和 `PUT /routing/strategy` 复用的 preview 计算。
2874
+ * 接受任意 routing 覆盖(来自 request body)算「假如改成这个,路由会是啥」。
2875
+ * 不修改任何内部 state,**纯函数式**。
2876
+ */
2877
+ public buildRoutingPreview(input: {
2878
+ modelId?: string;
2879
+ protocol?: string;
2880
+ paymentMethod?: string;
2881
+ routing?: Partial<BuyerSellerRoutingConfig>;
2882
+ }): { modelId: string; protocol: string; paymentMethod: string; plan: SellerRoutePlan | { error: string } } {
2883
+ const registry = this.lastRegistrySnapshot;
2884
+ const focusFirst = this.resolveFocusSet()[0];
2885
+ const registryFirst = registry?.sellers[0]?.models?.[0];
2886
+ const modelId = input.modelId?.trim() || focusFirst || registryFirst || "";
2887
+ const protocol = input.protocol?.trim() || "chat_completions";
2888
+ const paymentMethod = input.paymentMethod?.trim() || this.defaultPaymentMethod() || "clawtip";
2889
+ if (!modelId) {
2890
+ return { modelId, protocol, paymentMethod, plan: { error: "no_focus_model_available" } };
2891
+ }
2892
+ if (!registry) {
2893
+ return { modelId, protocol, paymentMethod, plan: { error: "registry_not_loaded" } };
2894
+ }
2895
+ const current = this.refreshSellerRoutingConfig();
2896
+ const routing: BuyerSellerRoutingConfig = input.routing
2897
+ ? mergeSellerRoutingConfig(current, input.routing)
2898
+ : current;
2899
+ const resolvedRouting = resolveSellerRoutingForModel(routing, modelId);
2900
+ const registrySellers = reorderDefaultSellerFirst(registry.sellers, registry.defaultSeller);
2901
+ this.sellerPool.ensureRegistrySellers(registrySellers);
2902
+ const poolById = new Map(this.sellerPool.snapshot().map((entry) => [entry.sellerId, entry]));
2903
+ const plan = planSellerRouteSet({
2904
+ modelId,
2905
+ protocol,
2906
+ paymentMethod,
2907
+ registrySellers,
2908
+ routing: resolvedRouting,
2909
+ prewarmCandidates: this.prewarmCache.get(modelId, protocol, paymentMethod)?.candidates,
2910
+ sellerMetrics: Array.from(poolById.values()).map((entry) => ({
2911
+ sellerId: entry.sellerId,
2912
+ healthScore: entry.healthScore,
2913
+ avgLatencyMs: entry.avgLatencyMs,
2914
+ ttftMs: entry.ttftMs,
2915
+ avgInferenceMs: entry.avgInferenceMs,
2916
+ circuit: entry.circuit,
2917
+ capacityBlockedUntil: entry.capacityBlockedUntil
2918
+ })),
2919
+ now: Date.now()
2920
+ });
2921
+ return { modelId, protocol, paymentMethod, plan };
2922
+ }
2923
+
2017
2924
  private async runStartupPrewarmSweep(): Promise<void> {
2018
2925
  const focusSet = this.resolveFocusSet();
2019
2926
  if (focusSet.length === 0) {
@@ -2029,7 +2936,7 @@ export class TokenbuddyDaemon {
2029
2936
  await this.prewarmScheduler.runStartupPrewarm(
2030
2937
  focusSet.map((modelId) => ({
2031
2938
  modelId,
2032
- protocol: this.resolvePrewarmProtocol(modelId)
2939
+ protocol: this.resolvePrewarmProtocol(modelId, this.defaultPaymentMethod())
2033
2940
  }))
2034
2941
  );
2035
2942
  } catch (err) {
@@ -2039,9 +2946,9 @@ export class TokenbuddyDaemon {
2039
2946
  }
2040
2947
  }
2041
2948
 
2042
- private resolvePrewarmProtocol(modelId: string): string | undefined {
2949
+ private resolvePrewarmProtocol(modelId: string, paymentMethod = "clawtip"): string | undefined {
2043
2950
  for (const protocol of ["chat_completions", "messages", "responses"]) {
2044
- if (this.modelIndex.sellersFor(modelId, { protocol, paymentMethod: "clawtip" }).length > 0) {
2951
+ if (this.modelIndex.sellersFor(modelId, { protocol, paymentMethod }).length > 0) {
2045
2952
  return protocol;
2046
2953
  }
2047
2954
  }
@@ -2049,9 +2956,209 @@ export class TokenbuddyDaemon {
2049
2956
  }
2050
2957
 
2051
2958
  public stop() {
2959
+ if (this.clawtipActivationWaitCancelToken) {
2960
+ this.clawtipActivationWaitCancelToken.cancelled = true;
2961
+ }
2052
2962
  if (this.controlServer) this.controlServer.close();
2053
2963
  if (this.proxyServer) this.proxyServer.close();
2054
2964
  void this.prewarmScheduler.stop();
2055
2965
  this.tokenStore.close();
2056
2966
  }
2967
+
2968
+ /**
2969
+ * @internal — test-only seam to inject a registry snapshot without
2970
+ * hitting the network. Used by `tests/control-plane-ui-endpoints.test.ts`
2971
+ * to drive `buildRoutingPreview` deterministically. Production code
2972
+ * must NOT call this; the real `fetchRegistry()` populates the snapshot.
2973
+ */
2974
+ public setLastRegistrySnapshotForTest(snapshot: SellerRegistryDocument | null): void {
2975
+ this.lastRegistrySnapshot = snapshot;
2976
+ }
2977
+ }
2978
+
2979
+ function selectionModeForRouting(routing: BuyerSellerRoutingConfig): "auto" | "manual" {
2980
+ return routing.mode === "fullAuto" ? "auto" : "manual";
2981
+ }
2982
+
2983
+ interface ClawtipQrResponse {
2984
+ ok: true;
2985
+ kind: "activate" | "recharge";
2986
+ method: "clawtip";
2987
+ orderNo?: string;
2988
+ amountFen?: number;
2989
+ qrImageUrl: string;
2990
+ sourceImagePath: string;
2991
+ staticImagePath: string;
2992
+ authUrl?: string;
2993
+ clawtipId?: string;
2994
+ orderFile?: string;
2995
+ resourceUrl?: string;
2996
+ walletConfigPath: string;
2997
+ walletConfigPresent: boolean;
2998
+ requiresWalletAuth: boolean;
2999
+ payCredentialWritten: boolean;
3000
+ }
3001
+
3002
+ function withLiveClawtipWalletState(payment: PaymentConfig, home?: string): PaymentConfig {
3003
+ if (payment.method !== "clawtip") {
3004
+ return payment;
3005
+ }
3006
+ const walletConfig = inspectOpenClawWalletConfig(home);
3007
+ return {
3008
+ ...payment,
3009
+ enabled: payment.enabled && walletConfig.exists,
3010
+ config: {
3011
+ ...(payment.config ?? {}),
3012
+ walletConfigPath: walletConfig.expectedPath,
3013
+ walletConfigPresent: walletConfig.exists,
3014
+ nearbyWalletConfigPaths: walletConfig.alternatePaths
3015
+ }
3016
+ };
3017
+ }
3018
+
3019
+ function normalizeClawtipActivationPayment(bootstrap: ClawtipBootstrapResponse): ClawtipBootstrapPayment {
3020
+ if (!bootstrap.payment?.orderNo || !bootstrap.payment.indicator || !bootstrap.payment.resourceUrl) {
3021
+ throw new Error("ClawTip bootstrap response missing orderNo, indicator, or resourceUrl.");
3022
+ }
3023
+ return {
3024
+ orderNo: bootstrap.payment.orderNo,
3025
+ amountFen: bootstrap.payment.amountFen ?? bootstrap.activationFeeFen ?? 1,
3026
+ payTo: bootstrap.payment.payTo,
3027
+ encryptedData: bootstrap.payment.encryptedData,
3028
+ indicator: bootstrap.payment.indicator,
3029
+ slug: bootstrap.payment.slug,
3030
+ skillId: bootstrap.payment.skillId,
3031
+ description: bootstrap.payment.description,
3032
+ resourceUrl: bootstrap.payment.resourceUrl,
3033
+ };
3034
+ }
3035
+
3036
+ function safeQrExtension(filePath: string): ".png" | ".jpg" | ".jpeg" | ".webp" {
3037
+ const extension = path.extname(filePath).toLowerCase();
3038
+ if (extension === ".jpg" || extension === ".jpeg" || extension === ".webp") {
3039
+ return extension;
3040
+ }
3041
+ return ".png";
3042
+ }
3043
+
3044
+ function safeStaticFileSegment(value: string): string {
3045
+ return value.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "clawtip";
3046
+ }
3047
+
3048
+ function readConfigString(config: Record<string, unknown> | undefined, key: string): string | undefined {
3049
+ const value = config?.[key];
3050
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
3051
+ }
3052
+
3053
+ function readConfigBoolean(config: Record<string, unknown> | undefined, key: string): boolean {
3054
+ return config?.[key] === true;
3055
+ }
3056
+
3057
+ function selectedSellerIdForRouting(routing: BuyerSellerRoutingConfig): string | undefined {
3058
+ return routing.mode === "fixed" ? routing.sellerId : undefined;
3059
+ }
3060
+
3061
+ function routingKey(routing: BuyerSellerRoutingConfig): string {
3062
+ const fixedByModel = Object.entries(routing.fixedByModel ?? {})
3063
+ .sort(([left], [right]) => left.localeCompare(right))
3064
+ .map(([modelId, sellerId]) => `${modelId}:${sellerId}`);
3065
+ return [
3066
+ routing.mode,
3067
+ routing.scorer,
3068
+ routing.sellerId ?? "",
3069
+ ...(routing.sellerIds ?? []),
3070
+ ...fixedByModel
3071
+ ].join("\u0001");
3072
+ }
3073
+
3074
+ /**
3075
+ * 从 query string 构造 `BuyerSellerRoutingConfig` override(用于 GET /routing/preview)。
3076
+ * 接受 `mode` / `scorer` / `sellerId` / `sellerIds`(逗号分隔)。
3077
+ * 任一字段缺失返回 `undefined`,调用方走「用当前 routing」分支。
3078
+ * mode / scorer 非法抛 400,由端点 handler 捕获。
3079
+ */
3080
+ function buildRoutingConfigFromQuery(query: Record<string, unknown> | Request["query"]): Partial<BuyerSellerRoutingConfig> | undefined {
3081
+ const mode = typeof query.mode === "string" ? query.mode.trim() : "";
3082
+ const scorer = typeof query.scorer === "string" ? query.scorer.trim() : "";
3083
+ const sellerId = typeof query.sellerId === "string" ? query.sellerId.trim() : "";
3084
+ const sellerIdsRaw = typeof query.sellerIds === "string" ? query.sellerIds.trim() : "";
3085
+ const fixedByModelRaw = typeof query.fixedByModel === "string" ? query.fixedByModel.trim() : "";
3086
+ if (!mode && !scorer && !sellerId && !sellerIdsRaw && !fixedByModelRaw) {
3087
+ return undefined;
3088
+ }
3089
+ const override: Partial<BuyerSellerRoutingConfig> = {};
3090
+ if (mode) {
3091
+ if (mode !== "fixed" && mode !== "fixedSet" && mode !== "fullAuto") {
3092
+ throw new Error("mode must be fixed, fixedSet, or fullAuto");
3093
+ }
3094
+ override.mode = mode;
3095
+ }
3096
+ if (scorer) {
3097
+ if (scorer !== "speed" && scorer !== "discount" && scorer !== "balanced") {
3098
+ throw new Error("scorer must be speed, discount, or balanced");
3099
+ }
3100
+ override.scorer = scorer;
3101
+ }
3102
+ if (sellerId) {
3103
+ override.sellerId = sellerId;
3104
+ }
3105
+ if (sellerIdsRaw) {
3106
+ override.sellerIds = parseSellerIdList(sellerIdsRaw);
3107
+ }
3108
+ if (fixedByModelRaw) {
3109
+ override.fixedByModel = parseFixedByModel(fixedByModelRaw);
3110
+ }
3111
+ return override;
3112
+ }
3113
+
3114
+ function sameSellerRouting(a: BuyerSellerRoutingConfig, b: BuyerSellerRoutingConfig): boolean {
3115
+ return a.mode === b.mode
3116
+ && a.scorer === b.scorer
3117
+ && optionalStringEqual(a.sellerId, b.sellerId)
3118
+ && stringArraysEqual(a.sellerIds ?? [], b.sellerIds ?? [])
3119
+ && fixedByModelEqual(a.fixedByModel ?? {}, b.fixedByModel ?? {});
3120
+ }
3121
+
3122
+ function optionalStringEqual(a: string | undefined, b: string | undefined): boolean {
3123
+ return (a ?? "") === (b ?? "");
3124
+ }
3125
+
3126
+ function stringArraysEqual(a: string[], b: string[]): boolean {
3127
+ if (a.length !== b.length) {
3128
+ return false;
3129
+ }
3130
+ return a.every((entry, index) => entry === b[index]);
3131
+ }
3132
+
3133
+ function resolveSellerRoutingForModel(routing: BuyerSellerRoutingConfig, modelId: string): BuyerSellerRoutingConfig {
3134
+ if (routing.mode !== "fixed") {
3135
+ return routing;
3136
+ }
3137
+ const fixedSellerId = routing.fixedByModel?.[modelId]?.trim() || routing.sellerId;
3138
+ return {
3139
+ mode: "fixed",
3140
+ scorer: routing.scorer,
3141
+ sellerId: fixedSellerId,
3142
+ fixedByModel: routing.fixedByModel
3143
+ };
3144
+ }
3145
+
3146
+ function parseFixedByModel(value: string): Record<string, string> {
3147
+ const entries = value
3148
+ .split(",")
3149
+ .map((entry) => entry.split(":"))
3150
+ .filter((parts): parts is [string, string] => parts.length === 2)
3151
+ .map(([modelId, sellerId]) => [modelId.trim(), sellerId.trim()] as const)
3152
+ .filter(([modelId, sellerId]) => modelId.length > 0 && sellerId.length > 0);
3153
+ return Object.fromEntries(entries);
3154
+ }
3155
+
3156
+ function fixedByModelEqual(a: Record<string, string>, b: Record<string, string>): boolean {
3157
+ const aEntries = Object.entries(a).sort(([left], [right]) => left.localeCompare(right));
3158
+ const bEntries = Object.entries(b).sort(([left], [right]) => left.localeCompare(right));
3159
+ return aEntries.length === bEntries.length
3160
+ && aEntries.every(([modelId, sellerId], index) => {
3161
+ const [otherModelId, otherSellerId] = bEntries[index] ?? [];
3162
+ return modelId === otherModelId && sellerId === otherSellerId;
3163
+ });
2057
3164
  }