@tokenbuddy/tokenbuddy 1.0.12 → 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 -19
  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 -27
  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 +446 -34
  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/dist/src/cli.js 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 } from "./buyer-store.js";
9
9
  import { applyProviderInstall, detectProviders, getProviderModelSelectionKind, getProviderProtocolPreference, } from "./provider-install.js";
@@ -11,6 +11,7 @@ import { createModuleLogger } from "@tokenbuddy/logging";
11
11
  import * as crypto from "crypto";
12
12
  import { fileURLToPath } from "url";
13
13
  import { discoverSellerBackedModels, filterCatalogByProtocol, filterCatalogBySeller, } from "./seller-catalog.js";
14
+ import { assertSellerRoutingConfig, defaultSellerRoutingConfig, normalizeSellerRoutingConfig, parseSellerIdList, ROUTING_CONFIG_KEY, } from "./seller-routing-config.js";
14
15
  import { collectDoctorDiagnostics, collectDoctorModelsSummary, printDoctorProviders, printDoctorModelsSummary, readDoctorProviders, renderDoctorDiagnosticsProgressively, } from "./doctor-diagnostics.js";
15
16
  import { buildInitSuccessMessage, buildInitTerminalSelectionState, buildInstalledTerminalMessage, INIT_PAYMENT_OPTIONS, inspectClawtipWalletReadiness, inspectOpenClawWalletConfig, noteInitComingSoonPayments, OTHER_TERMINAL_OPTION, validateInitTerminalSelection, } from "./init-payment-options.js";
16
17
  import { checkOpenClawRuntime, readClawtipPayCredential, startClawtipWalletBootstrap, waitForClawtipActivationConfirmation, } from "./init-clawtip-activation.js";
@@ -19,6 +20,11 @@ import { displayTerminalImage } from "./terminal-image.js";
19
20
  import qrcode from "qrcode-terminal";
20
21
  const CONTROL_PORT = 17820;
21
22
  const PROXY_PORT = 17821;
23
+ const TOKENBUDDY_LAUNCHD_LABEL = "com.tokenbuddy.proxyd";
24
+ const STALE_TOKENBUDDY_LAUNCHD_LABELS = [
25
+ "com.tokenbuddy.tb-proxyd",
26
+ "homebrew.mxcl.tokenbuddy",
27
+ ];
22
28
  const logger = createModuleLogger("tokenbuddy-cli");
23
29
  const SUPPORTED_PAYMENT_METHODS = ["mock", "clawtip"];
24
30
  function isSupportedPaymentMethod(method) {
@@ -101,6 +107,85 @@ function defaultProxydLogPath(kind) {
101
107
  function tbProxydScriptPath() {
102
108
  return path.resolve(currentModuleDir(), "./tb-proxyd.js");
103
109
  }
110
+ function escapeXmlText(value) {
111
+ return value
112
+ .replace(/&/g, "&")
113
+ .replace(/</g, "&lt;")
114
+ .replace(/>/g, "&gt;");
115
+ }
116
+ /**
117
+ * 构造 macOS launchd plist 的 XML 文本,用于 `launchctl load` 注册 `tb-proxyd`。
118
+ * 已转义所有插值字段,避免 XML 注入;env 块只输出非空项。
119
+ *
120
+ * @param options plist 字段
121
+ * @returns 完整的 plist XML 字符串
122
+ */
123
+ export function buildLaunchdPlistContent(options) {
124
+ const env = {
125
+ TB_PROXYD_CONTROL_PORT: String(options.controlPort),
126
+ TB_PROXYD_PROXY_PORT: String(options.proxyPort),
127
+ TB_PROXYD_SELLER_REGISTRY_URL: options.sellerRegistryUrl,
128
+ };
129
+ if (options.clawtipProofCommand?.trim()) {
130
+ env.TB_PROXYD_CLAWTIP_PROOF_COMMAND = options.clawtipProofCommand.trim();
131
+ }
132
+ if (options.clawtipProofTimeoutMs !== undefined) {
133
+ env.TB_PROXYD_CLAWTIP_PROOF_TIMEOUT_MS = String(options.clawtipProofTimeoutMs);
134
+ }
135
+ const envEntries = Object.entries(env)
136
+ .map(([key, value]) => ` <key>${escapeXmlText(key)}</key>\n <string>${escapeXmlText(value)}</string>`)
137
+ .join("\n");
138
+ return `<?xml version="1.0" encoding="UTF-8"?>
139
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
140
+ <plist version="1.0">
141
+ <dict>
142
+ <key>Label</key>
143
+ <string>${escapeXmlText(options.label)}</string>
144
+ <key>ProgramArguments</key>
145
+ <array>
146
+ <string>${escapeXmlText(options.nodePath)}</string>
147
+ <string>${escapeXmlText(options.scriptPath)}</string>
148
+ </array>
149
+ <key>EnvironmentVariables</key>
150
+ <dict>
151
+ ${envEntries}
152
+ </dict>
153
+ <key>RunAtLoad</key>
154
+ <true/>
155
+ <key>KeepAlive</key>
156
+ <true/>
157
+ <key>StandardOutPath</key>
158
+ <string>${escapeXmlText(options.stdoutPath)}</string>
159
+ <key>StandardErrorPath</key>
160
+ <string>${escapeXmlText(options.stderrPath)}</string>
161
+ </dict>
162
+ </plist>`;
163
+ }
164
+ function launchdUserDomain() {
165
+ if (typeof process.getuid === "function") {
166
+ return `gui/${process.getuid()}`;
167
+ }
168
+ return "gui/501";
169
+ }
170
+ function runLaunchctl(args, ignoreFailure = false) {
171
+ try {
172
+ execFileSync("launchctl", args, { stdio: "ignore" });
173
+ }
174
+ catch (error) {
175
+ if (!ignoreFailure) {
176
+ throw error;
177
+ }
178
+ }
179
+ }
180
+ function installLaunchAgent(plistPath, label) {
181
+ const domain = launchdUserDomain();
182
+ for (const staleLabel of STALE_TOKENBUDDY_LAUNCHD_LABELS) {
183
+ runLaunchctl(["bootout", `${domain}/${staleLabel}`], true);
184
+ }
185
+ runLaunchctl(["bootout", `${domain}/${label}`], true);
186
+ runLaunchctl(["bootstrap", domain, plistPath]);
187
+ runLaunchctl(["kickstart", "-k", `${domain}/${label}`]);
188
+ }
104
189
  async function repairDaemon(controlPort) {
105
190
  const existing = await probeDaemonStatus(controlPort);
106
191
  if (existing.running) {
@@ -159,7 +244,7 @@ function rootActionName(command) {
159
244
  }
160
245
  function commandRequiresDaemon(command) {
161
246
  const rootName = rootActionName(command);
162
- return rootName !== "doctor" && rootName !== "init";
247
+ return rootName !== "doctor" && rootName !== "init" && rootName !== "routing";
163
248
  }
164
249
  async function enforceDaemonGate(command) {
165
250
  if (!commandRequiresDaemon(command)) {
@@ -236,6 +321,14 @@ function printPaymentList(payments, asJson) {
236
321
  console.log("=== TokenBuddy Payment Methods ===");
237
322
  console.log(table.toString());
238
323
  }
324
+ /**
325
+ * 调 wallet-bootstrap 的 `/payments/clawtip/bootstrap` 端点,拿到激活支付参数。
326
+ * 校验:HTTP 200、订单字段齐全、`payTo` 不是占位符。
327
+ *
328
+ * @param bootstrapUrl wallet-bootstrap 服务 base URL
329
+ * @returns bootstrap 响应(含 `payment` 字段)
330
+ * @throws Error 任何校验失败
331
+ */
239
332
  export async function fetchClawtipBootstrap(bootstrapUrl) {
240
333
  const response = await fetch(`${bootstrapUrl.replace(/\/+$/, "")}/payments/clawtip/bootstrap`, {
241
334
  method: "POST",
@@ -259,6 +352,15 @@ export async function fetchClawtipBootstrap(bootstrapUrl) {
259
352
  body.payment.resourceUrl = normalizeClawtipBootstrapResourceUrl(bootstrapUrl, body.payment.resourceUrl);
260
353
  return body;
261
354
  }
355
+ /**
356
+ * 修正 Clawtip bootstrap 返回的 `resourceUrl`。
357
+ * 早期 bootstrap 返回 `/registry/sellers` 这种占位 URL,把它替换成当前 bootstrap URL 的 path,
358
+ * 让 buyer 正确指向 wallet-bootstrap。
359
+ *
360
+ * @param bootstrapUrl wallet-bootstrap base URL
361
+ * @param resourceUrl bootstrap 响应中的 `resourceUrl` 字段
362
+ * @returns 修正后的 resource URL(无法解析时返回原值)
363
+ */
262
364
  export function normalizeClawtipBootstrapResourceUrl(bootstrapUrl, resourceUrl) {
263
365
  try {
264
366
  const bootstrap = new URL(bootstrapUrl);
@@ -318,50 +420,137 @@ async function promptSellerRoutingPreference(catalog) {
318
420
  message: "Choose seller routing mode for tb-proxyd:",
319
421
  options: [
320
422
  {
321
- value: "auto",
322
- label: "Auto",
323
- hint: "Automatically choose a compatible seller based on the requested model.",
423
+ value: "fullAuto",
424
+ label: "Full Auto",
425
+ hint: "Use all compatible sellers and rank them by the selected scorer.",
324
426
  },
325
427
  {
326
428
  value: "fixed",
327
429
  label: "Fixed Seller",
328
430
  hint: "Pin tb-proxyd to one seller and only use models from that seller.",
329
431
  },
432
+ {
433
+ value: "fixedSet",
434
+ label: "Fixed Seller Set",
435
+ hint: "Use only a selected seller pool and rank within that pool.",
436
+ },
330
437
  ],
331
438
  });
332
439
  if (typeof mode !== "string") {
333
440
  throw new Error("seller routing selection was cancelled");
334
441
  }
335
- if (mode === "auto") {
336
- return { mode };
337
- }
338
- if (healthySellers.length === 0) {
442
+ if ((mode === "fixed" || mode === "fixedSet") && healthySellers.length === 0) {
339
443
  throw new Error("no healthy sellers available for fixed routing");
340
444
  }
341
- const sellerId = await p.select({
342
- message: "Choose the seller to pin tb-proxyd to:",
343
- options: healthySellers.map((seller) => ({
344
- value: seller.id,
345
- label: seller.name ? `${seller.name} (${seller.id})` : seller.id,
346
- hint: [
347
- seller.discountRatio != null ? `discount x${seller.discountRatio}` : null,
348
- seller.modelCount != null ? `${seller.modelCount} models` : null,
349
- seller.supportedProtocols?.length ? seller.supportedProtocols.join(",") : null,
350
- seller.paymentMethods?.length ? seller.paymentMethods.join(",") : null,
351
- ]
352
- .filter(Boolean)
353
- .join(" · ") || seller.url,
354
- })),
445
+ const scorer = await promptSellerRoutingScorer();
446
+ if (mode === "fullAuto") {
447
+ return { mode, scorer };
448
+ }
449
+ if (mode === "fixed") {
450
+ const sellerId = await p.select({
451
+ message: "Choose the seller to pin tb-proxyd to:",
452
+ options: sellerChoices(healthySellers),
453
+ });
454
+ if (typeof sellerId !== "string") {
455
+ throw new Error("fixed seller selection was cancelled");
456
+ }
457
+ return {
458
+ mode,
459
+ sellerId,
460
+ scorer,
461
+ };
462
+ }
463
+ const sellerIds = await p.multiselect({
464
+ message: "Choose the seller pool for fixedSet routing:",
465
+ options: sellerChoices(healthySellers),
466
+ required: true
467
+ });
468
+ if (!Array.isArray(sellerIds)) {
469
+ throw new Error("fixedSet seller selection was cancelled");
470
+ }
471
+ return {
472
+ mode,
473
+ sellerIds,
474
+ scorer,
475
+ };
476
+ }
477
+ async function promptSellerRoutingScorer() {
478
+ const scorer = await p.select({
479
+ message: "Choose seller ranking preference:",
480
+ options: [
481
+ { value: "balanced", label: "Balanced", hint: "Balance health, latency, and discount." },
482
+ { value: "speed", label: "Speed", hint: "Prefer healthier, faster sellers." },
483
+ { value: "discount", label: "Discount", hint: "Prefer lower discount ratio first." },
484
+ ],
355
485
  });
356
- if (typeof sellerId !== "string") {
357
- throw new Error("fixed seller selection was cancelled");
486
+ if (typeof scorer !== "string") {
487
+ throw new Error("seller scorer selection was cancelled");
488
+ }
489
+ return scorer;
490
+ }
491
+ function sellerChoices(sellers) {
492
+ return sellers.map((seller) => ({
493
+ value: seller.id,
494
+ label: seller.name ? `${seller.name} (${seller.id})` : seller.id,
495
+ hint: [
496
+ seller.discountRatio != null ? `discount x${seller.discountRatio}` : null,
497
+ seller.modelCount != null ? `${seller.modelCount} models` : null,
498
+ seller.supportedProtocols?.length ? seller.supportedProtocols.join(",") : null,
499
+ seller.paymentMethods?.length ? seller.paymentMethods.join(",") : null,
500
+ ]
501
+ .filter(Boolean)
502
+ .join(" · ") || seller.url,
503
+ }));
504
+ }
505
+ function buildRoutingConfigFromOptions(modeValue, options) {
506
+ const mode = parseRoutingModeValue(modeValue);
507
+ const scorer = parseRoutingScorerValue(options.scorer || "balanced");
508
+ if (mode === "fixed") {
509
+ return {
510
+ mode,
511
+ sellerId: options.seller?.trim(),
512
+ scorer
513
+ };
514
+ }
515
+ if (mode === "fixedSet") {
516
+ return {
517
+ mode,
518
+ sellerIds: parseSellerIdList(options.sellerSet || ""),
519
+ scorer
520
+ };
358
521
  }
359
522
  return {
360
523
  mode,
361
- sellerId,
524
+ scorer
362
525
  };
363
526
  }
364
- async function promptSingleModelSelection(providerId, models, sellerRouting) {
527
+ function parseRoutingModeValue(value) {
528
+ if (value === "fixed" || value === "fixedSet" || value === "fullAuto") {
529
+ return value;
530
+ }
531
+ throw new Error("routing mode must be fixed, fixedSet, or fullAuto");
532
+ }
533
+ function parseRoutingScorerValue(value) {
534
+ if (value === "balanced" || value === "speed" || value === "discount") {
535
+ return value;
536
+ }
537
+ throw new Error("routing scorer must be balanced, speed, or discount");
538
+ }
539
+ function printRoutingConfig(config, updatedAt) {
540
+ console.log("=== TokenBuddy Seller Routing ===");
541
+ console.log(`Mode: ${config.mode}`);
542
+ console.log(`Scorer: ${config.scorer}`);
543
+ if (config.mode === "fixed") {
544
+ console.log(`Seller: ${config.sellerId || "(not configured)"}`);
545
+ }
546
+ if (config.mode === "fixedSet") {
547
+ console.log(`Seller Set: ${config.sellerIds?.join(",") || "(empty)"}`);
548
+ }
549
+ if (updatedAt) {
550
+ console.log(`Updated: ${updatedAt}`);
551
+ }
552
+ }
553
+ async function promptSingleModelSelection(providerId, models) {
365
554
  const protocolPreference = getProviderProtocolPreference(providerId);
366
555
  const protocolFiltered = protocolPreference
367
556
  ? filterCatalogByProtocol(models, protocolPreference)
@@ -385,12 +574,10 @@ async function promptSingleModelSelection(providerId, models, sellerRouting) {
385
574
  if (typeof selectedModel !== "string") {
386
575
  throw new Error(`default model selection was cancelled for ${providerId}`);
387
576
  }
388
- const selectedEntry = protocolFiltered.find((entry) => entry.id === selectedModel);
389
577
  return {
390
578
  selectionKind: "single-model",
391
579
  protocolPreference,
392
580
  defaultModel: selectedModel,
393
- sellerId: sellerRouting.mode === "fixed" ? selectedEntry?.sellerId : undefined,
394
581
  };
395
582
  }
396
583
  function defaultClaudeDisplayName(modelId) {
@@ -484,7 +671,9 @@ async function promptClaudeCodeModelSelection(models) {
484
671
  async function promptProviderSelections(providerIds, catalog, sellerRouting) {
485
672
  const baseModels = sellerRouting.mode === "fixed"
486
673
  ? filterCatalogBySeller(catalog.models, sellerRouting.sellerId)
487
- : catalog.models;
674
+ : sellerRouting.mode === "fixedSet"
675
+ ? catalog.models.filter((model) => sellerRouting.sellerIds?.includes(model.sellerId))
676
+ : catalog.models;
488
677
  const selections = {};
489
678
  for (const providerId of providerIds) {
490
679
  const selectionKind = getProviderModelSelectionKind(providerId);
@@ -492,10 +681,16 @@ async function promptProviderSelections(providerIds, catalog, sellerRouting) {
492
681
  selections[providerId] = await promptClaudeCodeModelSelection(baseModels);
493
682
  continue;
494
683
  }
495
- selections[providerId] = await promptSingleModelSelection(providerId, baseModels, sellerRouting);
684
+ selections[providerId] = await promptSingleModelSelection(providerId, baseModels);
496
685
  }
497
686
  return selections;
498
687
  }
688
+ /**
689
+ * 构造 commander program,绑定所有 `tb` 子命令(doctor / init / config / provider 等)。
690
+ * 在 `preAction` 钩子里做 daemon gate:非 daemon-only 命令跳过,其余命令需要本地 `tb-proxyd` 在跑。
691
+ *
692
+ * @returns 配置完整的 commander Command 实例
693
+ */
499
694
  export function buildCli() {
500
695
  const program = new Command();
501
696
  program
@@ -789,6 +984,45 @@ export function buildCli() {
789
984
  store.close();
790
985
  }
791
986
  });
987
+ // 3. tb routing
988
+ const routing = program.command("routing").description("Manage buyer-side seller routing strategy");
989
+ routing
990
+ .command("show")
991
+ .description("Show the configured seller routing strategy")
992
+ .option("--json", "Output routing config as JSON")
993
+ .action(async (options) => {
994
+ const store = openBuyerStore();
995
+ try {
996
+ const record = store.getDaemonRuntimeConfig(ROUTING_CONFIG_KEY);
997
+ const config = record ? normalizeSellerRoutingConfig(record.config) : defaultSellerRoutingConfig();
998
+ if (options.json) {
999
+ console.log(JSON.stringify({ routing: config, updatedAt: record?.updatedAt }, null, 2));
1000
+ return;
1001
+ }
1002
+ printRoutingConfig(config, record?.updatedAt);
1003
+ }
1004
+ finally {
1005
+ store.close();
1006
+ }
1007
+ });
1008
+ routing
1009
+ .command("set <mode>")
1010
+ .description("Set seller routing strategy: fullAuto, fixed, or fixedSet")
1011
+ .option("--seller <id>", "Seller id for fixed routing")
1012
+ .option("--seller-set <ids>", "Comma-separated seller ids for fixedSet routing")
1013
+ .option("--scorer <scorer>", "Ranking scorer: balanced, speed, or discount", "balanced")
1014
+ .action(async (mode, options) => {
1015
+ const config = buildRoutingConfigFromOptions(mode, options);
1016
+ assertSellerRoutingConfig(config);
1017
+ const store = openBuyerStore();
1018
+ try {
1019
+ store.saveDaemonRuntimeConfig(ROUTING_CONFIG_KEY, config);
1020
+ }
1021
+ finally {
1022
+ store.close();
1023
+ }
1024
+ printRoutingConfig(config);
1025
+ });
792
1026
  // 3. tb models
793
1027
  program
794
1028
  .command("models")
@@ -874,7 +1108,7 @@ export function buildCli() {
874
1108
  }
875
1109
  if (selectedProviders.length > 0) {
876
1110
  spinner.start("Fetching seller-backed model catalog...");
877
- const proxyUrl = `http://127.0.0.1:${PROXY_PORT}`;
1111
+ const proxyUrl = `http://127.0.0.1:${configuredProxyPort()}`;
878
1112
  const registryUrl = sellerRegistryUrlForInit();
879
1113
  let catalog;
880
1114
  try {
@@ -900,11 +1134,11 @@ export function buildCli() {
900
1134
  spinner.start("Configuring proxy routing in selected terminals...");
901
1135
  const store = openBuyerStore();
902
1136
  try {
1137
+ store.saveDaemonRuntimeConfig(ROUTING_CONFIG_KEY, sellerRouting);
903
1138
  applyProviderInstall({
904
1139
  providers: providerIds,
905
1140
  proxyUrl,
906
1141
  providerSelections,
907
- sellerRouting,
908
1142
  }, store);
909
1143
  }
910
1144
  finally {
@@ -1147,38 +1381,28 @@ export function buildCli() {
1147
1381
  const plistDir = path.join(home, "Library", "LaunchAgents");
1148
1382
  if (!fs.existsSync(plistDir))
1149
1383
  fs.mkdirSync(plistDir, { recursive: true });
1150
- const plistPath = path.join(plistDir, "com.tokenbuddy.proxyd.plist");
1151
- // Resolve exact executable absolute path
1152
- const nodePath = execSync("which node", { encoding: "utf8" }).trim();
1384
+ const plistPath = path.join(plistDir, `${TOKENBUDDY_LAUNCHD_LABEL}.plist`);
1385
+ const controlPort = configuredControlPort();
1386
+ const proxyPort = configuredProxyPort();
1387
+ const sellerRegistryUrl = sellerRegistryUrlForInit();
1388
+ const nodePath = process.execPath;
1153
1389
  const scriptPath = tbProxydScriptPath();
1154
- const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
1155
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1156
- <plist version="1.0">
1157
- <dict>
1158
- <key>Label</key>
1159
- <string>com.tokenbuddy.proxyd</string>
1160
- <key>ProgramArguments</key>
1161
- <array>
1162
- <string>${nodePath}</string>
1163
- <string>${scriptPath}</string>
1164
- </array>
1165
- <key>RunAtLoad</key>
1166
- <true/>
1167
- <key>KeepAlive</key>
1168
- <true/>
1169
- <key>StandardOutPath</key>
1170
- <string>${path.join(home, ".tokenbuddy-store", "tb-proxyd.stdout.log")}</string>
1171
- <key>StandardErrorPath</key>
1172
- <string>${path.join(home, ".tokenbuddy-store", "tb-proxyd.stderr.log")}</string>
1173
- </dict>
1174
- </plist>`;
1390
+ const plistContent = buildLaunchdPlistContent({
1391
+ label: TOKENBUDDY_LAUNCHD_LABEL,
1392
+ nodePath,
1393
+ scriptPath,
1394
+ stdoutPath: path.join(home, ".tokenbuddy-store", "tb-proxyd.stdout.log"),
1395
+ stderrPath: path.join(home, ".tokenbuddy-store", "tb-proxyd.stderr.log"),
1396
+ controlPort,
1397
+ proxyPort,
1398
+ sellerRegistryUrl,
1399
+ clawtipProofCommand: process.env.TB_PROXYD_CLAWTIP_PROOF_COMMAND,
1400
+ clawtipProofTimeoutMs: process.env.TB_PROXYD_CLAWTIP_PROOF_TIMEOUT_MS
1401
+ ? Number(process.env.TB_PROXYD_CLAWTIP_PROOF_TIMEOUT_MS)
1402
+ : undefined,
1403
+ });
1175
1404
  fs.writeFileSync(plistPath, plistContent, "utf8");
1176
- // Load the LaunchAgent
1177
- try {
1178
- execSync(`launchctl unload ${plistPath}`, { stdio: "ignore" });
1179
- }
1180
- catch { }
1181
- execSync(`launchctl load ${plistPath}`);
1405
+ installLaunchAgent(plistPath, TOKENBUDDY_LAUNCHD_LABEL);
1182
1406
  spinner.stop("LaunchAgent daemon successfully registered and started! 🚀");
1183
1407
  setupSummaryLines.push("Background tb-proxyd launchd service installed.");
1184
1408
  }