@tokenbuddy/tokenbuddy 1.0.11 → 1.0.13

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 (135) hide show
  1. package/dist/src/buyer-store.d.ts +61 -0
  2. package/dist/src/buyer-store.d.ts.map +1 -1
  3. package/dist/src/buyer-store.js +12 -0
  4. package/dist/src/buyer-store.js.map +1 -1
  5. package/dist/src/cli.d.ts +47 -0
  6. package/dist/src/cli.d.ts.map +1 -1
  7. package/dist/src/cli.js +287 -63
  8. package/dist/src/cli.js.map +1 -1
  9. package/dist/src/credit-tracker.d.ts +26 -0
  10. package/dist/src/credit-tracker.d.ts.map +1 -1
  11. package/dist/src/credit-tracker.js +8 -0
  12. package/dist/src/credit-tracker.js.map +1 -1
  13. package/dist/src/daemon.d.ts +29 -3
  14. package/dist/src/daemon.d.ts.map +1 -1
  15. package/dist/src/daemon.js +292 -65
  16. package/dist/src/daemon.js.map +1 -1
  17. package/dist/src/doctor-clawtip-wallet.d.ts +25 -0
  18. package/dist/src/doctor-clawtip-wallet.d.ts.map +1 -1
  19. package/dist/src/doctor-clawtip-wallet.js +13 -0
  20. package/dist/src/doctor-clawtip-wallet.js.map +1 -1
  21. package/dist/src/doctor-diagnostics.d.ts +63 -0
  22. package/dist/src/doctor-diagnostics.d.ts.map +1 -1
  23. package/dist/src/doctor-diagnostics.js +39 -1
  24. package/dist/src/doctor-diagnostics.js.map +1 -1
  25. package/dist/src/index.d.ts +4 -0
  26. package/dist/src/index.d.ts.map +1 -1
  27. package/dist/src/index.js +4 -0
  28. package/dist/src/index.js.map +1 -1
  29. package/dist/src/init-clawtip-activation.d.ts +103 -0
  30. package/dist/src/init-clawtip-activation.d.ts.map +1 -1
  31. package/dist/src/init-clawtip-activation.js +60 -0
  32. package/dist/src/init-clawtip-activation.js.map +1 -1
  33. package/dist/src/init-payment-options.d.ts +124 -0
  34. package/dist/src/init-payment-options.d.ts.map +1 -1
  35. package/dist/src/init-payment-options.js +68 -0
  36. package/dist/src/init-payment-options.js.map +1 -1
  37. package/dist/src/model-index.d.ts +9 -0
  38. package/dist/src/model-index.d.ts.map +1 -1
  39. package/dist/src/model-index.js.map +1 -1
  40. package/dist/src/prewarm-cache.d.ts +89 -0
  41. package/dist/src/prewarm-cache.d.ts.map +1 -1
  42. package/dist/src/prewarm-cache.js +14 -1
  43. package/dist/src/prewarm-cache.js.map +1 -1
  44. package/dist/src/prewarm-scheduler.d.ts +62 -3
  45. package/dist/src/prewarm-scheduler.d.ts.map +1 -1
  46. package/dist/src/prewarm-scheduler.js +39 -8
  47. package/dist/src/prewarm-scheduler.js.map +1 -1
  48. package/dist/src/provider-install.d.ts +89 -3
  49. package/dist/src/provider-install.d.ts.map +1 -1
  50. package/dist/src/provider-install.js +77 -17
  51. package/dist/src/provider-install.js.map +1 -1
  52. package/dist/src/route-failover.d.ts +48 -0
  53. package/dist/src/route-failover.d.ts.map +1 -1
  54. package/dist/src/route-failover.js.map +1 -1
  55. package/dist/src/seller-catalog.d.ts +158 -10
  56. package/dist/src/seller-catalog.d.ts.map +1 -1
  57. package/dist/src/seller-catalog.js +79 -5
  58. package/dist/src/seller-catalog.js.map +1 -1
  59. package/dist/src/seller-metadata-cache.d.ts +29 -0
  60. package/dist/src/seller-metadata-cache.d.ts.map +1 -0
  61. package/dist/src/seller-metadata-cache.js +71 -0
  62. package/dist/src/seller-metadata-cache.js.map +1 -0
  63. package/dist/src/seller-pool.d.ts +71 -0
  64. package/dist/src/seller-pool.d.ts.map +1 -1
  65. package/dist/src/seller-pool.js +6 -1
  66. package/dist/src/seller-pool.js.map +1 -1
  67. package/dist/src/seller-route-planner.d.ts +118 -0
  68. package/dist/src/seller-route-planner.d.ts.map +1 -0
  69. package/dist/src/seller-route-planner.js +160 -0
  70. package/dist/src/seller-route-planner.js.map +1 -0
  71. package/dist/src/seller-routing-config.d.ts +69 -0
  72. package/dist/src/seller-routing-config.d.ts.map +1 -0
  73. package/dist/src/seller-routing-config.js +164 -0
  74. package/dist/src/seller-routing-config.js.map +1 -0
  75. package/dist/src/seller-routing-strategy.d.ts +118 -0
  76. package/dist/src/seller-routing-strategy.d.ts.map +1 -0
  77. package/dist/src/seller-routing-strategy.js +183 -0
  78. package/dist/src/seller-routing-strategy.js.map +1 -0
  79. package/dist/src/stream-failover.d.ts +23 -0
  80. package/dist/src/stream-failover.d.ts.map +1 -1
  81. package/dist/src/stream-failover.js +4 -0
  82. package/dist/src/stream-failover.js.map +1 -1
  83. package/dist/src/tb-proxyd.js +7 -21
  84. package/dist/src/tb-proxyd.js.map +1 -1
  85. package/dist/src/terminal-detect.d.ts +51 -0
  86. package/dist/src/terminal-detect.d.ts.map +1 -1
  87. package/dist/src/terminal-detect.js +42 -0
  88. package/dist/src/terminal-detect.js.map +1 -1
  89. package/dist/src/terminal-image.d.ts +41 -0
  90. package/dist/src/terminal-image.d.ts.map +1 -1
  91. package/dist/src/terminal-image.js +15 -0
  92. package/dist/src/terminal-image.js.map +1 -1
  93. package/package.json +1 -1
  94. package/src/buyer-store.ts +61 -0
  95. package/src/cli.ts +330 -68
  96. package/src/credit-tracker.ts +26 -0
  97. package/src/daemon.ts +363 -72
  98. package/src/doctor-clawtip-wallet.ts +25 -0
  99. package/src/doctor-diagnostics.ts +63 -1
  100. package/src/index.ts +4 -0
  101. package/src/init-clawtip-activation.ts +103 -0
  102. package/src/init-payment-options.ts +124 -0
  103. package/src/model-index.ts +9 -0
  104. package/src/prewarm-cache.ts +99 -1
  105. package/src/prewarm-scheduler.ts +97 -12
  106. package/src/provider-install.ts +125 -25
  107. package/src/route-failover.ts +48 -0
  108. package/src/seller-catalog.ts +158 -12
  109. package/src/seller-metadata-cache.ts +91 -0
  110. package/src/seller-pool.ts +77 -1
  111. package/src/seller-route-planner.ts +323 -0
  112. package/src/seller-routing-config.ts +198 -0
  113. package/src/seller-routing-strategy.ts +316 -0
  114. package/src/stream-failover.ts +23 -0
  115. package/src/tb-proxyd.ts +7 -23
  116. package/src/terminal-detect.ts +51 -0
  117. package/src/terminal-image.ts +41 -0
  118. package/tests/cli-routing.test.ts +287 -0
  119. package/tests/daemon-classify.test.ts +431 -0
  120. package/tests/daemon-roles.test.ts +92 -0
  121. package/tests/seller-catalog-utilities.test.ts +70 -0
  122. package/tests/seller-metadata-cache.test.ts +89 -0
  123. package/tests/seller-route-planner.test.ts +150 -0
  124. package/tests/seller-routing-config.test.ts +111 -0
  125. package/tests/seller-routing-strategy.test.ts +166 -0
  126. package/tests/tokenbuddy.test.ts +447 -33
  127. /package/{src → tests}/credit-tracker.test.ts +0 -0
  128. /package/{src → tests}/model-index.test.ts +0 -0
  129. /package/{src → tests}/prewarm-cache.test.ts +0 -0
  130. /package/{src → tests}/prewarm-scheduler.test.ts +0 -0
  131. /package/{src → tests}/route-failover.test.ts +0 -0
  132. /package/{src → tests}/seller-catalog-413.test.ts +0 -0
  133. /package/{src → tests}/seller-pool.test.ts +0 -0
  134. /package/{src → tests}/stream-failover.test.ts +0 -0
  135. /package/{src → tests}/thousand-seller.test.ts +0 -0
package/src/cli.ts CHANGED
@@ -3,7 +3,7 @@ import * as p from "@clack/prompts";
3
3
  import * as fs from "fs";
4
4
  import * as path from "path";
5
5
  import * as os from "os";
6
- import { execSync, spawn } from "child_process";
6
+ import { execFileSync, spawn } from "child_process";
7
7
  import Table from "cli-table3";
8
8
  import { BuyerStore, PaymentConfig } from "./buyer-store.js";
9
9
  import {
@@ -25,8 +25,16 @@ import {
25
25
  filterCatalogBySeller,
26
26
  type ModelCatalogEntry,
27
27
  type SellerCatalogResult,
28
- type SellerRoutingPreference,
29
28
  } from "./seller-catalog.js";
29
+ import {
30
+ assertSellerRoutingConfig,
31
+ defaultSellerRoutingConfig,
32
+ normalizeSellerRoutingConfig,
33
+ parseSellerIdList,
34
+ ROUTING_CONFIG_KEY,
35
+ type BuyerSellerRoutingConfig,
36
+ } from "./seller-routing-config.js";
37
+ import type { SellerRoutingMode, SellerRoutingScorer } from "./seller-routing-strategy.js";
30
38
  import {
31
39
  collectDoctorDiagnostics,
32
40
  collectDoctorModelsSummary,
@@ -60,6 +68,11 @@ import qrcode from "qrcode-terminal";
60
68
 
61
69
  const CONTROL_PORT = 17820;
62
70
  const PROXY_PORT = 17821;
71
+ const TOKENBUDDY_LAUNCHD_LABEL = "com.tokenbuddy.proxyd";
72
+ const STALE_TOKENBUDDY_LAUNCHD_LABELS = [
73
+ "com.tokenbuddy.tb-proxyd",
74
+ "homebrew.mxcl.tokenbuddy",
75
+ ] as const;
63
76
  const logger = createModuleLogger("tokenbuddy-cli");
64
77
  const SUPPORTED_PAYMENT_METHODS = ["mock", "clawtip"] as const;
65
78
  type SupportedPaymentMethod = typeof SUPPORTED_PAYMENT_METHODS[number];
@@ -208,6 +221,107 @@ function tbProxydScriptPath(): string {
208
221
  return path.resolve(currentModuleDir(), "./tb-proxyd.js");
209
222
  }
210
223
 
224
+ /**
225
+ * `buildLaunchdPlistContent` 的输入。
226
+ * 用于生成 macOS launchd plist,把 `tb-proxyd` 注册成 LaunchAgent。
227
+ */
228
+ export interface LaunchdPlistOptions {
229
+ label: string;
230
+ nodePath: string;
231
+ scriptPath: string;
232
+ stdoutPath: string;
233
+ stderrPath: string;
234
+ controlPort: number;
235
+ proxyPort: number;
236
+ sellerRegistryUrl: string;
237
+ clawtipProofCommand?: string;
238
+ clawtipProofTimeoutMs?: number;
239
+ }
240
+
241
+ function escapeXmlText(value: string): string {
242
+ return value
243
+ .replace(/&/g, "&")
244
+ .replace(/</g, "&lt;")
245
+ .replace(/>/g, "&gt;");
246
+ }
247
+
248
+ /**
249
+ * 构造 macOS launchd plist 的 XML 文本,用于 `launchctl load` 注册 `tb-proxyd`。
250
+ * 已转义所有插值字段,避免 XML 注入;env 块只输出非空项。
251
+ *
252
+ * @param options plist 字段
253
+ * @returns 完整的 plist XML 字符串
254
+ */
255
+ export function buildLaunchdPlistContent(options: LaunchdPlistOptions): string {
256
+ const env: Record<string, string> = {
257
+ TB_PROXYD_CONTROL_PORT: String(options.controlPort),
258
+ TB_PROXYD_PROXY_PORT: String(options.proxyPort),
259
+ TB_PROXYD_SELLER_REGISTRY_URL: options.sellerRegistryUrl,
260
+ };
261
+ if (options.clawtipProofCommand?.trim()) {
262
+ env.TB_PROXYD_CLAWTIP_PROOF_COMMAND = options.clawtipProofCommand.trim();
263
+ }
264
+ if (options.clawtipProofTimeoutMs !== undefined) {
265
+ env.TB_PROXYD_CLAWTIP_PROOF_TIMEOUT_MS = String(options.clawtipProofTimeoutMs);
266
+ }
267
+ const envEntries = Object.entries(env)
268
+ .map(([key, value]) => ` <key>${escapeXmlText(key)}</key>\n <string>${escapeXmlText(value)}</string>`)
269
+ .join("\n");
270
+
271
+ return `<?xml version="1.0" encoding="UTF-8"?>
272
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
273
+ <plist version="1.0">
274
+ <dict>
275
+ <key>Label</key>
276
+ <string>${escapeXmlText(options.label)}</string>
277
+ <key>ProgramArguments</key>
278
+ <array>
279
+ <string>${escapeXmlText(options.nodePath)}</string>
280
+ <string>${escapeXmlText(options.scriptPath)}</string>
281
+ </array>
282
+ <key>EnvironmentVariables</key>
283
+ <dict>
284
+ ${envEntries}
285
+ </dict>
286
+ <key>RunAtLoad</key>
287
+ <true/>
288
+ <key>KeepAlive</key>
289
+ <true/>
290
+ <key>StandardOutPath</key>
291
+ <string>${escapeXmlText(options.stdoutPath)}</string>
292
+ <key>StandardErrorPath</key>
293
+ <string>${escapeXmlText(options.stderrPath)}</string>
294
+ </dict>
295
+ </plist>`;
296
+ }
297
+
298
+ function launchdUserDomain(): string {
299
+ if (typeof process.getuid === "function") {
300
+ return `gui/${process.getuid()}`;
301
+ }
302
+ return "gui/501";
303
+ }
304
+
305
+ function runLaunchctl(args: string[], ignoreFailure = false): void {
306
+ try {
307
+ execFileSync("launchctl", args, { stdio: "ignore" });
308
+ } catch (error) {
309
+ if (!ignoreFailure) {
310
+ throw error;
311
+ }
312
+ }
313
+ }
314
+
315
+ function installLaunchAgent(plistPath: string, label: string): void {
316
+ const domain = launchdUserDomain();
317
+ for (const staleLabel of STALE_TOKENBUDDY_LAUNCHD_LABELS) {
318
+ runLaunchctl(["bootout", `${domain}/${staleLabel}`], true);
319
+ }
320
+ runLaunchctl(["bootout", `${domain}/${label}`], true);
321
+ runLaunchctl(["bootstrap", domain, plistPath]);
322
+ runLaunchctl(["kickstart", "-k", `${domain}/${label}`]);
323
+ }
324
+
211
325
  async function repairDaemon(controlPort: number): Promise<{ repair: DaemonRepairResult; probe: DaemonProbeResult }> {
212
326
  const existing = await probeDaemonStatus(controlPort);
213
327
  if (existing.running) {
@@ -272,7 +386,7 @@ function rootActionName(command: Command): string {
272
386
 
273
387
  function commandRequiresDaemon(command: Command): boolean {
274
388
  const rootName = rootActionName(command);
275
- return rootName !== "doctor" && rootName !== "init";
389
+ return rootName !== "doctor" && rootName !== "init" && rootName !== "routing";
276
390
  }
277
391
 
278
392
  async function enforceDaemonGate(command: Command): Promise<void> {
@@ -358,6 +472,14 @@ function printPaymentList(payments: PaymentConfig[], asJson: boolean): void {
358
472
  console.log(table.toString());
359
473
  }
360
474
 
475
+ /**
476
+ * 调 wallet-bootstrap 的 `/payments/clawtip/bootstrap` 端点,拿到激活支付参数。
477
+ * 校验:HTTP 200、订单字段齐全、`payTo` 不是占位符。
478
+ *
479
+ * @param bootstrapUrl wallet-bootstrap 服务 base URL
480
+ * @returns bootstrap 响应(含 `payment` 字段)
481
+ * @throws Error 任何校验失败
482
+ */
361
483
  export async function fetchClawtipBootstrap(bootstrapUrl: string): Promise<ClawtipBootstrapResponse> {
362
484
  const response = await fetch(`${bootstrapUrl.replace(/\/+$/, "")}/payments/clawtip/bootstrap`, {
363
485
  method: "POST",
@@ -384,6 +506,15 @@ export async function fetchClawtipBootstrap(bootstrapUrl: string): Promise<Clawt
384
506
  return body;
385
507
  }
386
508
 
509
+ /**
510
+ * 修正 Clawtip bootstrap 返回的 `resourceUrl`。
511
+ * 早期 bootstrap 返回 `/registry/sellers` 这种占位 URL,把它替换成当前 bootstrap URL 的 path,
512
+ * 让 buyer 正确指向 wallet-bootstrap。
513
+ *
514
+ * @param bootstrapUrl wallet-bootstrap base URL
515
+ * @param resourceUrl bootstrap 响应中的 `resourceUrl` 字段
516
+ * @returns 修正后的 resource URL(无法解析时返回原值)
517
+ */
387
518
  export function normalizeClawtipBootstrapResourceUrl(bootstrapUrl: string, resourceUrl: string): string {
388
519
  try {
389
520
  const bootstrap = new URL(bootstrapUrl);
@@ -442,64 +573,160 @@ function stableModelChoices(models: ModelCatalogEntry[]): SelectOption[] {
442
573
  });
443
574
  }
444
575
 
445
- async function promptSellerRoutingPreference(catalog: SellerCatalogResult): Promise<SellerRoutingPreference> {
576
+ async function promptSellerRoutingPreference(catalog: SellerCatalogResult): Promise<BuyerSellerRoutingConfig> {
446
577
  const healthySellers = catalog.sellers.filter((seller) => seller.status === "ok");
447
578
  const mode = await p.select({
448
579
  message: "Choose seller routing mode for tb-proxyd:",
449
580
  options: [
450
581
  {
451
- value: "auto",
452
- label: "Auto",
453
- hint: "Automatically choose a compatible seller based on the requested model.",
582
+ value: "fullAuto",
583
+ label: "Full Auto",
584
+ hint: "Use all compatible sellers and rank them by the selected scorer.",
454
585
  },
455
586
  {
456
587
  value: "fixed",
457
588
  label: "Fixed Seller",
458
589
  hint: "Pin tb-proxyd to one seller and only use models from that seller.",
459
590
  },
591
+ {
592
+ value: "fixedSet",
593
+ label: "Fixed Seller Set",
594
+ hint: "Use only a selected seller pool and rank within that pool.",
595
+ },
460
596
  ],
461
- }) as SellerRoutingPreference["mode"] | symbol;
597
+ }) as SellerRoutingMode | symbol;
462
598
 
463
599
  if (typeof mode !== "string") {
464
600
  throw new Error("seller routing selection was cancelled");
465
601
  }
466
- if (mode === "auto") {
467
- return { mode };
468
- }
469
-
470
- if (healthySellers.length === 0) {
602
+ if ((mode === "fixed" || mode === "fixedSet") && healthySellers.length === 0) {
471
603
  throw new Error("no healthy sellers available for fixed routing");
472
604
  }
605
+ const scorer = await promptSellerRoutingScorer();
606
+ if (mode === "fullAuto") {
607
+ return { mode, scorer };
608
+ }
473
609
 
474
- const sellerId = await p.select({
610
+ if (mode === "fixed") {
611
+ const sellerId = await p.select({
475
612
  message: "Choose the seller to pin tb-proxyd to:",
476
- options: healthySellers.map((seller) => ({
477
- value: seller.id,
478
- label: seller.name ? `${seller.name} (${seller.id})` : seller.id,
479
- hint: [
480
- seller.discountRatio != null ? `discount x${seller.discountRatio}` : null,
481
- seller.modelCount != null ? `${seller.modelCount} models` : null,
482
- seller.supportedProtocols?.length ? seller.supportedProtocols.join(",") : null,
483
- seller.paymentMethods?.length ? seller.paymentMethods.join(",") : null,
484
- ]
485
- .filter(Boolean)
486
- .join(" · ") || seller.url,
487
- })),
488
- }) as string | symbol;
613
+ options: sellerChoices(healthySellers),
614
+ }) as string | symbol;
489
615
 
490
- if (typeof sellerId !== "string") {
491
- throw new Error("fixed seller selection was cancelled");
616
+ if (typeof sellerId !== "string") {
617
+ throw new Error("fixed seller selection was cancelled");
618
+ }
619
+ return {
620
+ mode,
621
+ sellerId,
622
+ scorer,
623
+ };
624
+ }
625
+
626
+ const sellerIds = await p.multiselect({
627
+ message: "Choose the seller pool for fixedSet routing:",
628
+ options: sellerChoices(healthySellers),
629
+ required: true
630
+ }) as string[] | symbol;
631
+
632
+ if (!Array.isArray(sellerIds)) {
633
+ throw new Error("fixedSet seller selection was cancelled");
492
634
  }
493
635
  return {
494
636
  mode,
495
- sellerId,
637
+ sellerIds,
638
+ scorer,
496
639
  };
497
640
  }
498
641
 
642
+ async function promptSellerRoutingScorer(): Promise<SellerRoutingScorer> {
643
+ const scorer = await p.select({
644
+ message: "Choose seller ranking preference:",
645
+ options: [
646
+ { value: "balanced", label: "Balanced", hint: "Balance health, latency, and discount." },
647
+ { value: "speed", label: "Speed", hint: "Prefer healthier, faster sellers." },
648
+ { value: "discount", label: "Discount", hint: "Prefer lower discount ratio first." },
649
+ ],
650
+ }) as SellerRoutingScorer | symbol;
651
+ if (typeof scorer !== "string") {
652
+ throw new Error("seller scorer selection was cancelled");
653
+ }
654
+ return scorer;
655
+ }
656
+
657
+ function sellerChoices(sellers: SellerCatalogResult["sellers"]): SelectOption[] {
658
+ return sellers.map((seller) => ({
659
+ value: seller.id,
660
+ label: seller.name ? `${seller.name} (${seller.id})` : seller.id,
661
+ hint: [
662
+ seller.discountRatio != null ? `discount x${seller.discountRatio}` : null,
663
+ seller.modelCount != null ? `${seller.modelCount} models` : null,
664
+ seller.supportedProtocols?.length ? seller.supportedProtocols.join(",") : null,
665
+ seller.paymentMethods?.length ? seller.paymentMethods.join(",") : null,
666
+ ]
667
+ .filter(Boolean)
668
+ .join(" · ") || seller.url,
669
+ }));
670
+ }
671
+
672
+ function buildRoutingConfigFromOptions(
673
+ modeValue: string,
674
+ options: { seller?: string; sellerSet?: string; scorer?: string }
675
+ ): BuyerSellerRoutingConfig {
676
+ const mode = parseRoutingModeValue(modeValue);
677
+ const scorer = parseRoutingScorerValue(options.scorer || "balanced");
678
+ if (mode === "fixed") {
679
+ return {
680
+ mode,
681
+ sellerId: options.seller?.trim(),
682
+ scorer
683
+ };
684
+ }
685
+ if (mode === "fixedSet") {
686
+ return {
687
+ mode,
688
+ sellerIds: parseSellerIdList(options.sellerSet || ""),
689
+ scorer
690
+ };
691
+ }
692
+ return {
693
+ mode,
694
+ scorer
695
+ };
696
+ }
697
+
698
+ function parseRoutingModeValue(value: string): SellerRoutingMode {
699
+ if (value === "fixed" || value === "fixedSet" || value === "fullAuto") {
700
+ return value;
701
+ }
702
+ throw new Error("routing mode must be fixed, fixedSet, or fullAuto");
703
+ }
704
+
705
+ function parseRoutingScorerValue(value: string): SellerRoutingScorer {
706
+ if (value === "balanced" || value === "speed" || value === "discount") {
707
+ return value;
708
+ }
709
+ throw new Error("routing scorer must be balanced, speed, or discount");
710
+ }
711
+
712
+ function printRoutingConfig(config: BuyerSellerRoutingConfig, updatedAt?: string): void {
713
+ console.log("=== TokenBuddy Seller Routing ===");
714
+ console.log(`Mode: ${config.mode}`);
715
+ console.log(`Scorer: ${config.scorer}`);
716
+ if (config.mode === "fixed") {
717
+ console.log(`Seller: ${config.sellerId || "(not configured)"}`);
718
+ }
719
+ if (config.mode === "fixedSet") {
720
+ console.log(`Seller Set: ${config.sellerIds?.join(",") || "(empty)"}`);
721
+ }
722
+ if (updatedAt) {
723
+ console.log(`Updated: ${updatedAt}`);
724
+ }
725
+ }
726
+
499
727
  async function promptSingleModelSelection(
500
728
  providerId: ProviderId,
501
729
  models: ModelCatalogEntry[],
502
- sellerRouting: SellerRoutingPreference,
503
730
  ): Promise<SingleModelProviderRuntimeConfig> {
504
731
  const protocolPreference = getProviderProtocolPreference(providerId);
505
732
  const protocolFiltered = protocolPreference
@@ -528,12 +755,10 @@ async function promptSingleModelSelection(
528
755
  throw new Error(`default model selection was cancelled for ${providerId}`);
529
756
  }
530
757
 
531
- const selectedEntry = protocolFiltered.find((entry) => entry.id === selectedModel);
532
758
  return {
533
759
  selectionKind: "single-model",
534
760
  protocolPreference,
535
761
  defaultModel: selectedModel,
536
- sellerId: sellerRouting.mode === "fixed" ? selectedEntry?.sellerId : undefined,
537
762
  };
538
763
  }
539
764
 
@@ -641,10 +866,12 @@ async function promptClaudeCodeModelSelection(
641
866
  async function promptProviderSelections(
642
867
  providerIds: ProviderId[],
643
868
  catalog: SellerCatalogResult,
644
- sellerRouting: SellerRoutingPreference,
869
+ sellerRouting: BuyerSellerRoutingConfig,
645
870
  ): Promise<ProviderSelections> {
646
871
  const baseModels = sellerRouting.mode === "fixed"
647
872
  ? filterCatalogBySeller(catalog.models, sellerRouting.sellerId)
873
+ : sellerRouting.mode === "fixedSet"
874
+ ? catalog.models.filter((model) => sellerRouting.sellerIds?.includes(model.sellerId))
648
875
  : catalog.models;
649
876
 
650
877
  const selections: ProviderSelections = {};
@@ -657,12 +884,17 @@ async function promptProviderSelections(
657
884
  selections[providerId] = await promptSingleModelSelection(
658
885
  providerId,
659
886
  baseModels,
660
- sellerRouting,
661
887
  );
662
888
  }
663
889
  return selections;
664
890
  }
665
891
 
892
+ /**
893
+ * 构造 commander program,绑定所有 `tb` 子命令(doctor / init / config / provider 等)。
894
+ * 在 `preAction` 钩子里做 daemon gate:非 daemon-only 命令跳过,其余命令需要本地 `tb-proxyd` 在跑。
895
+ *
896
+ * @returns 配置完整的 commander Command 实例
897
+ */
666
898
  export function buildCli(): Command {
667
899
  const program = new Command();
668
900
  program
@@ -972,6 +1204,46 @@ export function buildCli(): Command {
972
1204
  }
973
1205
  });
974
1206
 
1207
+ // 3. tb routing
1208
+ const routing = program.command("routing").description("Manage buyer-side seller routing strategy");
1209
+
1210
+ routing
1211
+ .command("show")
1212
+ .description("Show the configured seller routing strategy")
1213
+ .option("--json", "Output routing config as JSON")
1214
+ .action(async (options: { json?: boolean }) => {
1215
+ const store = openBuyerStore();
1216
+ try {
1217
+ const record = store.getDaemonRuntimeConfig<unknown>(ROUTING_CONFIG_KEY);
1218
+ const config = record ? normalizeSellerRoutingConfig(record.config) : defaultSellerRoutingConfig();
1219
+ if (options.json) {
1220
+ console.log(JSON.stringify({ routing: config, updatedAt: record?.updatedAt }, null, 2));
1221
+ return;
1222
+ }
1223
+ printRoutingConfig(config, record?.updatedAt);
1224
+ } finally {
1225
+ store.close();
1226
+ }
1227
+ });
1228
+
1229
+ routing
1230
+ .command("set <mode>")
1231
+ .description("Set seller routing strategy: fullAuto, fixed, or fixedSet")
1232
+ .option("--seller <id>", "Seller id for fixed routing")
1233
+ .option("--seller-set <ids>", "Comma-separated seller ids for fixedSet routing")
1234
+ .option("--scorer <scorer>", "Ranking scorer: balanced, speed, or discount", "balanced")
1235
+ .action(async (mode: string, options: { seller?: string; sellerSet?: string; scorer?: string }) => {
1236
+ const config = buildRoutingConfigFromOptions(mode, options);
1237
+ assertSellerRoutingConfig(config);
1238
+ const store = openBuyerStore();
1239
+ try {
1240
+ store.saveDaemonRuntimeConfig(ROUTING_CONFIG_KEY, config);
1241
+ } finally {
1242
+ store.close();
1243
+ }
1244
+ printRoutingConfig(config);
1245
+ });
1246
+
975
1247
  // 3. tb models
976
1248
  program
977
1249
  .command("models")
@@ -1068,7 +1340,7 @@ export function buildCli(): Command {
1068
1340
 
1069
1341
  if (selectedProviders.length > 0) {
1070
1342
  spinner.start("Fetching seller-backed model catalog...");
1071
- const proxyUrl = `http://127.0.0.1:${PROXY_PORT}`;
1343
+ const proxyUrl = `http://127.0.0.1:${configuredProxyPort()}`;
1072
1344
  const registryUrl = sellerRegistryUrlForInit();
1073
1345
  let catalog: SellerCatalogResult;
1074
1346
  try {
@@ -1100,11 +1372,11 @@ export function buildCli(): Command {
1100
1372
  spinner.start("Configuring proxy routing in selected terminals...");
1101
1373
  const store = openBuyerStore();
1102
1374
  try {
1375
+ store.saveDaemonRuntimeConfig(ROUTING_CONFIG_KEY, sellerRouting);
1103
1376
  applyProviderInstall({
1104
1377
  providers: providerIds,
1105
1378
  proxyUrl,
1106
1379
  providerSelections,
1107
- sellerRouting,
1108
1380
  }, store);
1109
1381
  } finally {
1110
1382
  store.close();
@@ -1381,40 +1653,30 @@ export function buildCli(): Command {
1381
1653
  const plistDir = path.join(home, "Library", "LaunchAgents");
1382
1654
  if (!fs.existsSync(plistDir)) fs.mkdirSync(plistDir, { recursive: true });
1383
1655
 
1384
- const plistPath = path.join(plistDir, "com.tokenbuddy.proxyd.plist");
1385
-
1386
- // Resolve exact executable absolute path
1387
- const nodePath = execSync("which node", { encoding: "utf8" }).trim();
1656
+ const plistPath = path.join(plistDir, `${TOKENBUDDY_LAUNCHD_LABEL}.plist`);
1657
+ const controlPort = configuredControlPort();
1658
+ const proxyPort = configuredProxyPort();
1659
+ const sellerRegistryUrl = sellerRegistryUrlForInit();
1660
+
1661
+ const nodePath = process.execPath;
1388
1662
  const scriptPath = tbProxydScriptPath();
1389
-
1390
- const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
1391
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1392
- <plist version="1.0">
1393
- <dict>
1394
- <key>Label</key>
1395
- <string>com.tokenbuddy.proxyd</string>
1396
- <key>ProgramArguments</key>
1397
- <array>
1398
- <string>${nodePath}</string>
1399
- <string>${scriptPath}</string>
1400
- </array>
1401
- <key>RunAtLoad</key>
1402
- <true/>
1403
- <key>KeepAlive</key>
1404
- <true/>
1405
- <key>StandardOutPath</key>
1406
- <string>${path.join(home, ".tokenbuddy-store", "tb-proxyd.stdout.log")}</string>
1407
- <key>StandardErrorPath</key>
1408
- <string>${path.join(home, ".tokenbuddy-store", "tb-proxyd.stderr.log")}</string>
1409
- </dict>
1410
- </plist>`;
1663
+ const plistContent = buildLaunchdPlistContent({
1664
+ label: TOKENBUDDY_LAUNCHD_LABEL,
1665
+ nodePath,
1666
+ scriptPath,
1667
+ stdoutPath: path.join(home, ".tokenbuddy-store", "tb-proxyd.stdout.log"),
1668
+ stderrPath: path.join(home, ".tokenbuddy-store", "tb-proxyd.stderr.log"),
1669
+ controlPort,
1670
+ proxyPort,
1671
+ sellerRegistryUrl,
1672
+ clawtipProofCommand: process.env.TB_PROXYD_CLAWTIP_PROOF_COMMAND,
1673
+ clawtipProofTimeoutMs: process.env.TB_PROXYD_CLAWTIP_PROOF_TIMEOUT_MS
1674
+ ? Number(process.env.TB_PROXYD_CLAWTIP_PROOF_TIMEOUT_MS)
1675
+ : undefined,
1676
+ });
1411
1677
  fs.writeFileSync(plistPath, plistContent, "utf8");
1412
1678
 
1413
- // Load the LaunchAgent
1414
- try {
1415
- execSync(`launchctl unload ${plistPath}`, { stdio: "ignore" });
1416
- } catch {}
1417
- execSync(`launchctl load ${plistPath}`);
1679
+ installLaunchAgent(plistPath, TOKENBUDDY_LAUNCHD_LABEL);
1418
1680
  spinner.stop("LaunchAgent daemon successfully registered and started! 🚀");
1419
1681
  setupSummaryLines.push("Background tb-proxyd launchd service installed.");
1420
1682
  } catch (err: any) {
@@ -24,10 +24,17 @@ export const DEFAULT_FRESH_PURCHASE_THRESHOLD = 0.5;
24
24
  */
25
25
  export const DEFAULT_PURCHASE_BUDGET_PER_MINUTE = 3;
26
26
 
27
+ /**
28
+ * `CreditTracker` 构造选项。
29
+ */
27
30
  export interface CreditTrackerOptions {
31
+ /** "新鲜购买" 判定窗口(毫秒),默认 30s */
28
32
  freshPurchaseWindowMs?: number;
33
+ /** "新鲜购买" 剩余比例阈值,默认 0.5(剩余 ≥ 50% 才算 fresh) */
29
34
  freshPurchaseThreshold?: number;
35
+ /** 单 session 每分钟自动购买上限,默认 3 */
30
36
  purchaseBudgetPerMinute?: number;
37
+ /** 时间源,默认 `Date.now`;测试可注入 */
31
38
  now?: () => number;
32
39
  }
33
40
 
@@ -51,6 +58,14 @@ interface SellerCredit {
51
58
  * The tracker is process-local: a buyer restart resets the session
52
59
  * counters. See §17.11.4 for the rationale.
53
60
  */
61
+ /**
62
+ * buyer-driven-fallback 设计的"余额保护"决策器。
63
+ * 跟踪每个 seller 的余额、最近购买时间、累计浪费 micros,回答:
64
+ * - "该 seller 是否还在 fresh-purchase window 内?"
65
+ * - "本 session 是否还能自动购买?"
66
+ *
67
+ * 仅进程内状态(buyer 重启会重置 session 计数器;详见 §17.11.4)。
68
+ */
54
69
  export class CreditTracker {
55
70
  private readonly freshPurchaseWindowMs: number;
56
71
  private readonly freshPurchaseThreshold: number;
@@ -252,13 +267,24 @@ export class CreditTracker {
252
267
  }
253
268
  }
254
269
 
270
+ /**
271
+ * `CreditTracker.summary()` 的返回结构,对应 design doc 的 "Credit Usage" 块。
272
+ * `tb doctor` 直接消费此结构展示给用户。
273
+ */
255
274
  export interface CreditSummary {
275
+ /** 自 tracker 创建以来累计浪费的 micros(含 failover 触发时的余额残留) */
256
276
  totalWastedMicros: number;
277
+ /** 上次 `resetDoctorRunCounter()` 之后又新产生的浪费 micros(doctor 显示 delta) */
257
278
  wastedSinceLastDoctorRun: number;
279
+ /** 过去 60 秒内自动购买次数 */
258
280
  purchasesInLastMinute: number;
281
+ /** 每分钟自动购买上限 */
259
282
  purchaseBudgetPerMinute: number;
283
+ /** 当前 fresh-purchase 窗口长度(毫秒) */
260
284
  freshPurchaseWindowMs: number;
285
+ /** 当前 fresh-purchase 比例阈值 */
261
286
  freshPurchaseThreshold: number;
287
+ /** 每个 seller 的余额快照 */
262
288
  perSeller: Array<{
263
289
  sellerId: string;
264
290
  currentBalanceMicros: number;