@tokenbuddy/tokenbuddy 1.0.12 → 1.0.14
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.
- package/dist/src/buyer-store.d.ts +61 -0
- package/dist/src/buyer-store.d.ts.map +1 -1
- package/dist/src/buyer-store.js +12 -0
- package/dist/src/buyer-store.js.map +1 -1
- package/dist/src/cli.d.ts +47 -0
- package/dist/src/cli.d.ts.map +1 -1
- package/dist/src/cli.js +287 -63
- package/dist/src/cli.js.map +1 -1
- package/dist/src/credit-tracker.d.ts +26 -0
- package/dist/src/credit-tracker.d.ts.map +1 -1
- package/dist/src/credit-tracker.js +8 -0
- package/dist/src/credit-tracker.js.map +1 -1
- package/dist/src/daemon.d.ts +29 -3
- package/dist/src/daemon.d.ts.map +1 -1
- package/dist/src/daemon.js +292 -65
- package/dist/src/daemon.js.map +1 -1
- package/dist/src/doctor-clawtip-wallet.d.ts +25 -0
- package/dist/src/doctor-clawtip-wallet.d.ts.map +1 -1
- package/dist/src/doctor-clawtip-wallet.js +13 -0
- package/dist/src/doctor-clawtip-wallet.js.map +1 -1
- package/dist/src/doctor-diagnostics.d.ts +63 -0
- package/dist/src/doctor-diagnostics.d.ts.map +1 -1
- package/dist/src/doctor-diagnostics.js +39 -1
- package/dist/src/doctor-diagnostics.js.map +1 -1
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +4 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/init-clawtip-activation.d.ts +103 -0
- package/dist/src/init-clawtip-activation.d.ts.map +1 -1
- package/dist/src/init-clawtip-activation.js +60 -0
- package/dist/src/init-clawtip-activation.js.map +1 -1
- package/dist/src/init-payment-options.d.ts +124 -0
- package/dist/src/init-payment-options.d.ts.map +1 -1
- package/dist/src/init-payment-options.js +68 -0
- package/dist/src/init-payment-options.js.map +1 -1
- package/dist/src/model-index.d.ts +9 -0
- package/dist/src/model-index.d.ts.map +1 -1
- package/dist/src/model-index.js.map +1 -1
- package/dist/src/prewarm-cache.d.ts +89 -0
- package/dist/src/prewarm-cache.d.ts.map +1 -1
- package/dist/src/prewarm-cache.js +14 -1
- package/dist/src/prewarm-cache.js.map +1 -1
- package/dist/src/prewarm-scheduler.d.ts +62 -3
- package/dist/src/prewarm-scheduler.d.ts.map +1 -1
- package/dist/src/prewarm-scheduler.js +39 -8
- package/dist/src/prewarm-scheduler.js.map +1 -1
- package/dist/src/provider-install.d.ts +89 -3
- package/dist/src/provider-install.d.ts.map +1 -1
- package/dist/src/provider-install.js +77 -19
- package/dist/src/provider-install.js.map +1 -1
- package/dist/src/route-failover.d.ts +48 -0
- package/dist/src/route-failover.d.ts.map +1 -1
- package/dist/src/route-failover.js.map +1 -1
- package/dist/src/seller-catalog.d.ts +158 -10
- package/dist/src/seller-catalog.d.ts.map +1 -1
- package/dist/src/seller-catalog.js +79 -5
- package/dist/src/seller-catalog.js.map +1 -1
- package/dist/src/seller-metadata-cache.d.ts +29 -0
- package/dist/src/seller-metadata-cache.d.ts.map +1 -0
- package/dist/src/seller-metadata-cache.js +71 -0
- package/dist/src/seller-metadata-cache.js.map +1 -0
- package/dist/src/seller-pool.d.ts +71 -0
- package/dist/src/seller-pool.d.ts.map +1 -1
- package/dist/src/seller-pool.js +6 -1
- package/dist/src/seller-pool.js.map +1 -1
- package/dist/src/seller-route-planner.d.ts +118 -0
- package/dist/src/seller-route-planner.d.ts.map +1 -0
- package/dist/src/seller-route-planner.js +160 -0
- package/dist/src/seller-route-planner.js.map +1 -0
- package/dist/src/seller-routing-config.d.ts +69 -0
- package/dist/src/seller-routing-config.d.ts.map +1 -0
- package/dist/src/seller-routing-config.js +164 -0
- package/dist/src/seller-routing-config.js.map +1 -0
- package/dist/src/seller-routing-strategy.d.ts +118 -0
- package/dist/src/seller-routing-strategy.d.ts.map +1 -0
- package/dist/src/seller-routing-strategy.js +183 -0
- package/dist/src/seller-routing-strategy.js.map +1 -0
- package/dist/src/stream-failover.d.ts +23 -0
- package/dist/src/stream-failover.d.ts.map +1 -1
- package/dist/src/stream-failover.js +4 -0
- package/dist/src/stream-failover.js.map +1 -1
- package/dist/src/tb-proxyd.js +7 -21
- package/dist/src/tb-proxyd.js.map +1 -1
- package/dist/src/terminal-detect.d.ts +51 -0
- package/dist/src/terminal-detect.d.ts.map +1 -1
- package/dist/src/terminal-detect.js +42 -0
- package/dist/src/terminal-detect.js.map +1 -1
- package/dist/src/terminal-image.d.ts +41 -0
- package/dist/src/terminal-image.d.ts.map +1 -1
- package/dist/src/terminal-image.js +15 -0
- package/dist/src/terminal-image.js.map +1 -1
- package/package.json +1 -1
- package/src/buyer-store.ts +61 -0
- package/src/cli.ts +330 -68
- package/src/credit-tracker.ts +26 -0
- package/src/daemon.ts +363 -72
- package/src/doctor-clawtip-wallet.ts +25 -0
- package/src/doctor-diagnostics.ts +63 -1
- package/src/index.ts +4 -0
- package/src/init-clawtip-activation.ts +103 -0
- package/src/init-payment-options.ts +124 -0
- package/src/model-index.ts +9 -0
- package/src/prewarm-cache.ts +99 -1
- package/src/prewarm-scheduler.ts +97 -12
- package/src/provider-install.ts +125 -27
- package/src/route-failover.ts +48 -0
- package/src/seller-catalog.ts +158 -12
- package/src/seller-metadata-cache.ts +91 -0
- package/src/seller-pool.ts +77 -1
- package/src/seller-route-planner.ts +323 -0
- package/src/seller-routing-config.ts +198 -0
- package/src/seller-routing-strategy.ts +316 -0
- package/src/stream-failover.ts +23 -0
- package/src/tb-proxyd.ts +7 -23
- package/src/terminal-detect.ts +51 -0
- package/src/terminal-image.ts +41 -0
- package/tests/cli-routing.test.ts +287 -0
- package/tests/daemon-classify.test.ts +431 -0
- package/tests/daemon-roles.test.ts +92 -0
- package/tests/seller-catalog-utilities.test.ts +70 -0
- package/tests/seller-metadata-cache.test.ts +89 -0
- package/tests/seller-route-planner.test.ts +150 -0
- package/tests/seller-routing-config.test.ts +111 -0
- package/tests/seller-routing-strategy.test.ts +166 -0
- package/tests/tokenbuddy.test.ts +446 -34
- /package/{src → tests}/credit-tracker.test.ts +0 -0
- /package/{src → tests}/model-index.test.ts +0 -0
- /package/{src → tests}/prewarm-cache.test.ts +0 -0
- /package/{src → tests}/prewarm-scheduler.test.ts +0 -0
- /package/{src → tests}/route-failover.test.ts +0 -0
- /package/{src → tests}/seller-catalog-413.test.ts +0 -0
- /package/{src → tests}/seller-pool.test.ts +0 -0
- /package/{src → tests}/stream-failover.test.ts +0 -0
- /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 {
|
|
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, "<")
|
|
245
|
+
.replace(/>/g, ">");
|
|
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<
|
|
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: "
|
|
452
|
-
label: "Auto",
|
|
453
|
-
hint: "
|
|
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
|
|
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 === "
|
|
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
|
-
|
|
610
|
+
if (mode === "fixed") {
|
|
611
|
+
const sellerId = await p.select({
|
|
475
612
|
message: "Choose the seller to pin tb-proxyd to:",
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
|
|
491
|
-
|
|
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
|
-
|
|
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:
|
|
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:${
|
|
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,
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
const
|
|
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
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
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
|
-
|
|
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) {
|
package/src/credit-tracker.ts
CHANGED
|
@@ -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;
|