@tokenbuddy/tokenbuddy 1.0.5 → 1.0.7
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 +48 -1
- package/dist/src/buyer-store.d.ts.map +1 -1
- package/dist/src/buyer-store.js +144 -17
- package/dist/src/buyer-store.js.map +1 -1
- package/dist/src/cli.d.ts +17 -0
- package/dist/src/cli.d.ts.map +1 -1
- package/dist/src/cli.js +560 -63
- package/dist/src/cli.js.map +1 -1
- package/dist/src/daemon.d.ts +11 -5
- package/dist/src/daemon.d.ts.map +1 -1
- package/dist/src/daemon.js +574 -161
- package/dist/src/daemon.js.map +1 -1
- package/dist/src/doctor-clawtip-wallet.d.ts +14 -0
- package/dist/src/doctor-clawtip-wallet.d.ts.map +1 -0
- package/dist/src/doctor-clawtip-wallet.js +54 -0
- package/dist/src/doctor-clawtip-wallet.js.map +1 -0
- package/dist/src/doctor-diagnostics.d.ts +99 -0
- package/dist/src/doctor-diagnostics.d.ts.map +1 -0
- package/dist/src/doctor-diagnostics.js +552 -0
- package/dist/src/doctor-diagnostics.js.map +1 -0
- package/dist/src/init-clawtip-activation.d.ts +48 -0
- package/dist/src/init-clawtip-activation.d.ts.map +1 -0
- package/dist/src/init-clawtip-activation.js +395 -0
- package/dist/src/init-clawtip-activation.js.map +1 -0
- package/dist/src/init-payment-options.d.ts +56 -0
- package/dist/src/init-payment-options.d.ts.map +1 -0
- package/dist/src/init-payment-options.js +165 -0
- package/dist/src/init-payment-options.js.map +1 -0
- package/dist/src/provider-install.d.ts +37 -2
- package/dist/src/provider-install.d.ts.map +1 -1
- package/dist/src/provider-install.js +317 -67
- package/dist/src/provider-install.js.map +1 -1
- package/dist/src/seller-catalog.d.ts +79 -0
- package/dist/src/seller-catalog.d.ts.map +1 -0
- package/dist/src/seller-catalog.js +126 -0
- package/dist/src/seller-catalog.js.map +1 -0
- package/dist/src/tb-proxyd.js +13 -2
- package/dist/src/tb-proxyd.js.map +1 -1
- package/dist/src/terminal-image.d.ts +22 -0
- package/dist/src/terminal-image.d.ts.map +1 -0
- package/dist/src/terminal-image.js +135 -0
- package/dist/src/terminal-image.js.map +1 -0
- package/package.json +1 -1
- package/src/buyer-store.ts +253 -18
- package/src/cli.ts +709 -68
- package/src/daemon.ts +651 -167
- package/src/doctor-clawtip-wallet.ts +70 -0
- package/src/doctor-diagnostics.ts +861 -0
- package/src/init-clawtip-activation.ts +487 -0
- package/src/init-payment-options.ts +249 -0
- package/src/provider-install.ts +426 -76
- package/src/seller-catalog.ts +222 -0
- package/src/tb-proxyd.ts +14 -2
- package/src/terminal-image.ts +187 -0
- package/tests/e2e.test.ts +88 -5
- package/tests/tokenbuddy.test.ts +1362 -27
package/tests/tokenbuddy.test.ts
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
import { TokenbuddyDaemon } from "../src/daemon.js";
|
|
2
|
-
import { BuyerStore, resolveBuyerStorePath } from "../src/buyer-store.js";
|
|
3
|
-
import {
|
|
2
|
+
import { BuyerStore, resolveBuyerStorePath, type PaymentConfig } from "../src/buyer-store.js";
|
|
3
|
+
import {
|
|
4
|
+
buildCli,
|
|
5
|
+
fetchClawtipBootstrap,
|
|
6
|
+
normalizeClawtipBootstrapResourceUrl,
|
|
7
|
+
} from "../src/cli.js";
|
|
8
|
+
import {
|
|
9
|
+
checkOpenClawRuntime,
|
|
10
|
+
parseClawtipCliOutput,
|
|
11
|
+
readClawtipPayCredential,
|
|
12
|
+
resolveClawtipQrMediaPath,
|
|
13
|
+
startClawtipWalletBootstrap,
|
|
14
|
+
waitForClawtipActivationConfirmation,
|
|
15
|
+
writeClawtipOrderFile,
|
|
16
|
+
} from "../src/init-clawtip-activation.js";
|
|
4
17
|
import {
|
|
5
18
|
applyProviderInstall,
|
|
6
19
|
detectProviders,
|
|
@@ -8,13 +21,33 @@ import {
|
|
|
8
21
|
rollbackProviderInstall
|
|
9
22
|
} from "../src/provider-install.js";
|
|
10
23
|
import { detectTerminals } from "../src/terminal-detect.js";
|
|
24
|
+
import {
|
|
25
|
+
buildInitSuccessMessage,
|
|
26
|
+
buildInitTerminalSelectionState,
|
|
27
|
+
detectExistingClawtipBinding,
|
|
28
|
+
inspectClawtipWalletReadiness,
|
|
29
|
+
inspectOpenClawWalletConfig,
|
|
30
|
+
INIT_COMING_SOON_PAYMENT_OPTIONS,
|
|
31
|
+
INIT_PAYMENT_OPTIONS,
|
|
32
|
+
noteInitComingSoonPayments,
|
|
33
|
+
OTHER_TERMINAL_OPTION,
|
|
34
|
+
validateInitTerminalSelection,
|
|
35
|
+
} from "../src/init-payment-options.js";
|
|
36
|
+
import { printDoctorClawtipWallet } from "../src/doctor-clawtip-wallet.js";
|
|
37
|
+
import {
|
|
38
|
+
detectTerminalImageDisplay,
|
|
39
|
+
displayTerminalImage,
|
|
40
|
+
} from "../src/terminal-image.js";
|
|
11
41
|
import * as path from "path";
|
|
12
42
|
import * as fs from "fs";
|
|
13
43
|
import http from "http";
|
|
14
44
|
import { AddressInfo } from "net";
|
|
45
|
+
import zlib from "zlib";
|
|
15
46
|
|
|
16
47
|
const TEMP_BUYER_DB = path.resolve(__dirname, "../../data-test/buyer-cache-test.db");
|
|
17
48
|
const TEMP_STORE_ROOT = path.resolve(__dirname, "../../data-test/buyer-store-test");
|
|
49
|
+
const INSPECTION_STORE_ROOT = path.resolve(__dirname, "../../data-test/json-inspection-store");
|
|
50
|
+
const INSPECTION_HOME = path.resolve(__dirname, "../../data-test/json-inspection-home");
|
|
18
51
|
const PACKAGE_JSON = path.resolve(__dirname, "../package.json");
|
|
19
52
|
|
|
20
53
|
function rmSqliteFiles(dbPath: string): void {
|
|
@@ -108,15 +141,27 @@ describe("BuyerStore safe SQLite persistence", () => {
|
|
|
108
141
|
expect(store.getToken("seller-a")).toBeUndefined();
|
|
109
142
|
|
|
110
143
|
store.saveToken("seller-a", "raw-token-secret", "model:gpt-4", 500000, "2030-01-01T00:00:00.000Z");
|
|
111
|
-
expect(store.getToken("seller-a")).
|
|
144
|
+
expect(store.getToken("seller-a")).toMatchObject({
|
|
112
145
|
token: "raw-token-secret",
|
|
113
|
-
balanceMicros: 500000
|
|
146
|
+
balanceMicros: 500000,
|
|
147
|
+
reservedMicros: 0,
|
|
148
|
+
spentMicros: 0,
|
|
149
|
+
balanceSource: "purchase_complete"
|
|
114
150
|
});
|
|
115
151
|
|
|
116
|
-
store.
|
|
117
|
-
|
|
152
|
+
store.reconcileTokenBalance({
|
|
153
|
+
sellerKey: "seller-a",
|
|
154
|
+
balanceMicros: 499890,
|
|
155
|
+
reservedMicros: 0,
|
|
156
|
+
spentMicros: 110,
|
|
157
|
+
balanceSource: "seller_settlement_summary"
|
|
158
|
+
});
|
|
159
|
+
expect(store.getToken("seller-a")).toMatchObject({
|
|
118
160
|
token: "raw-token-secret",
|
|
119
|
-
balanceMicros:
|
|
161
|
+
balanceMicros: 499890,
|
|
162
|
+
reservedMicros: 0,
|
|
163
|
+
spentMicros: 110,
|
|
164
|
+
balanceSource: "seller_settlement_summary"
|
|
120
165
|
});
|
|
121
166
|
});
|
|
122
167
|
|
|
@@ -125,6 +170,47 @@ describe("BuyerStore safe SQLite persistence", () => {
|
|
|
125
170
|
expect(store.listPendingPurchases()).toEqual([]);
|
|
126
171
|
expect(store.listPurchaseLedger()).toEqual([]);
|
|
127
172
|
expect(store.listInferenceLedger()).toEqual([]);
|
|
173
|
+
expect(store.summary()).toMatchObject({
|
|
174
|
+
providerRuntimeConfigCount: 0,
|
|
175
|
+
daemonRuntimeConfigCount: 0,
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("stores provider runtime config and daemon routing config in the buyer store", () => {
|
|
180
|
+
store.saveProviderRuntimeConfig("opencode", {
|
|
181
|
+
selectionKind: "single-model",
|
|
182
|
+
protocolPreference: "responses",
|
|
183
|
+
defaultModel: "gpt-5.5",
|
|
184
|
+
sellerId: "seller-a",
|
|
185
|
+
});
|
|
186
|
+
store.saveDaemonRuntimeConfig("routing", {
|
|
187
|
+
mode: "fixed",
|
|
188
|
+
sellerId: "seller-a",
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
expect(store.getProviderRuntimeConfig("opencode")).toMatchObject({
|
|
192
|
+
providerId: "opencode",
|
|
193
|
+
config: expect.objectContaining({
|
|
194
|
+
defaultModel: "gpt-5.5",
|
|
195
|
+
sellerId: "seller-a",
|
|
196
|
+
}),
|
|
197
|
+
});
|
|
198
|
+
expect(store.getDaemonRuntimeConfig("routing")).toMatchObject({
|
|
199
|
+
configKey: "routing",
|
|
200
|
+
config: expect.objectContaining({
|
|
201
|
+
mode: "fixed",
|
|
202
|
+
sellerId: "seller-a",
|
|
203
|
+
}),
|
|
204
|
+
});
|
|
205
|
+
expect(store.summary()).toMatchObject({
|
|
206
|
+
providerRuntimeConfigCount: 1,
|
|
207
|
+
daemonRuntimeConfigCount: 1,
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
expect(store.removeProviderRuntimeConfig("opencode")).toBe(true);
|
|
211
|
+
expect(store.removeDaemonRuntimeConfig("routing")).toBe(true);
|
|
212
|
+
expect(store.getProviderRuntimeConfig("opencode")).toBeUndefined();
|
|
213
|
+
expect(store.getDaemonRuntimeConfig("routing")).toBeUndefined();
|
|
128
214
|
});
|
|
129
215
|
|
|
130
216
|
test("stores payment config and pending purchases with safe references", () => {
|
|
@@ -374,31 +460,726 @@ describe("TokenBuddy payment CLI", () => {
|
|
|
374
460
|
});
|
|
375
461
|
});
|
|
376
462
|
|
|
463
|
+
describe("TokenBuddy init payment options", () => {
|
|
464
|
+
test("init hides mock payment and shows coming-soon agent wallets as unavailable", () => {
|
|
465
|
+
const noteMessages: string[] = [];
|
|
466
|
+
noteInitComingSoonPayments((message?: string) => {
|
|
467
|
+
noteMessages.push(String(message || ""));
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
expect(INIT_PAYMENT_OPTIONS).toEqual([
|
|
471
|
+
expect.objectContaining({ value: "clawtip" })
|
|
472
|
+
]);
|
|
473
|
+
expect(INIT_PAYMENT_OPTIONS.map((option) => option.value)).not.toContain("mock");
|
|
474
|
+
|
|
475
|
+
const notes = noteMessages.join("\n");
|
|
476
|
+
expect(INIT_COMING_SOON_PAYMENT_OPTIONS).toEqual(expect.arrayContaining([
|
|
477
|
+
expect.objectContaining({ id: "wechat-pay", label: "WeChat Pay" }),
|
|
478
|
+
expect.objectContaining({ id: "alipay-agent-payment", label: "Alipay Agent Payment" }),
|
|
479
|
+
expect.objectContaining({ id: "coinbase-smart-wallet", label: "Coinbase Smart Wallet" })
|
|
480
|
+
]));
|
|
481
|
+
expect(notes).toContain("WeChat Pay(接入中)");
|
|
482
|
+
expect(notes).toContain("Alipay Agent Payment(接入中)");
|
|
483
|
+
expect(notes).toContain("Coinbase Smart Wallet(接入中)");
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
test("detects an existing local clawtip binding from saved payment config", () => {
|
|
487
|
+
const binding = detectExistingClawtipBinding({
|
|
488
|
+
method: "clawtip",
|
|
489
|
+
enabled: false,
|
|
490
|
+
isDefault: false,
|
|
491
|
+
updatedAt: "2026-05-30T00:00:00.000Z",
|
|
492
|
+
config: {
|
|
493
|
+
orderNo: "order_123",
|
|
494
|
+
resourceUrl: "https://example.test/pay"
|
|
495
|
+
}
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
expect(binding).toEqual(expect.objectContaining({
|
|
499
|
+
orderNo: "order_123",
|
|
500
|
+
resourceUrl: "https://example.test/pay",
|
|
501
|
+
config: expect.objectContaining({
|
|
502
|
+
orderNo: "order_123",
|
|
503
|
+
resourceUrl: "https://example.test/pay"
|
|
504
|
+
})
|
|
505
|
+
}));
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
test("requires the OpenClaw wallet config before reusing ClawTip metadata", () => {
|
|
509
|
+
const payment: PaymentConfig = {
|
|
510
|
+
method: "clawtip",
|
|
511
|
+
enabled: true,
|
|
512
|
+
isDefault: true,
|
|
513
|
+
updatedAt: "2026-05-30T00:00:00.000Z",
|
|
514
|
+
config: {
|
|
515
|
+
orderNo: "order_123",
|
|
516
|
+
resourceUrl: "https://example.test/pay"
|
|
517
|
+
}
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
const missingWallet = inspectClawtipWalletReadiness(payment, {
|
|
521
|
+
expectedPath: "/tmp/home/.openclaw/configs/config.json",
|
|
522
|
+
configsDirExists: true,
|
|
523
|
+
exists: false,
|
|
524
|
+
alternatePaths: ["/tmp/home/.openclaw/configs/config.json.bak"]
|
|
525
|
+
});
|
|
526
|
+
expect(missingWallet.status).toBe("metadata_missing_wallet");
|
|
527
|
+
expect(missingWallet.savedBinding?.orderNo).toBe("order_123");
|
|
528
|
+
expect(missingWallet.reusableBinding).toBeUndefined();
|
|
529
|
+
|
|
530
|
+
const readyWallet = inspectClawtipWalletReadiness(payment, {
|
|
531
|
+
expectedPath: "/tmp/home/.openclaw/configs/config.json",
|
|
532
|
+
configsDirExists: true,
|
|
533
|
+
exists: true,
|
|
534
|
+
alternatePaths: []
|
|
535
|
+
});
|
|
536
|
+
expect(readyWallet.status).toBe("ready");
|
|
537
|
+
expect(readyWallet.reusableBinding?.orderNo).toBe("order_123");
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
test("detects renamed OpenClaw wallet config files as nearby files", () => {
|
|
541
|
+
const home = path.join(TEMP_STORE_ROOT, "openclaw-home");
|
|
542
|
+
const configsDir = path.join(home, ".openclaw", "configs");
|
|
543
|
+
rmDir(home);
|
|
544
|
+
fs.mkdirSync(configsDir, { recursive: true });
|
|
545
|
+
fs.writeFileSync(path.join(configsDir, "config.json.bak"), "{}", "utf8");
|
|
546
|
+
|
|
547
|
+
const wallet = inspectOpenClawWalletConfig(home);
|
|
548
|
+
|
|
549
|
+
expect(wallet.exists).toBe(false);
|
|
550
|
+
expect(wallet.configsDirExists).toBe(true);
|
|
551
|
+
expect(wallet.expectedPath).toBe(path.join(configsDir, "config.json"));
|
|
552
|
+
expect(wallet.alternatePaths).toEqual([
|
|
553
|
+
path.join(configsDir, "config.json.bak")
|
|
554
|
+
]);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
test("builds a clear init success message with summary lines", () => {
|
|
558
|
+
const message = buildInitSuccessMessage([
|
|
559
|
+
"2 programming terminals configured for TokenBuddy.",
|
|
560
|
+
"ClawTip wallet already bound locally; activation skipped."
|
|
561
|
+
]);
|
|
562
|
+
|
|
563
|
+
expect(message).toContain("✅ TokenBuddy setup completed successfully.");
|
|
564
|
+
expect(message).toContain("- 2 programming terminals configured for TokenBuddy.");
|
|
565
|
+
expect(message).toContain("- ClawTip wallet already bound locally; activation skipped.");
|
|
566
|
+
expect(message).toContain("Run `tb doctor` to audit status anytime.");
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
test("builds terminal selection with configured entries separated from selectable options", () => {
|
|
570
|
+
const selection = buildInitTerminalSelectionState([
|
|
571
|
+
{
|
|
572
|
+
id: "codex",
|
|
573
|
+
name: "Codex CLI",
|
|
574
|
+
detected: true,
|
|
575
|
+
configured: true,
|
|
576
|
+
status: "configured",
|
|
577
|
+
configPath: "/tmp/codex.toml",
|
|
578
|
+
reason: "Configured at ~/.codex/config.toml"
|
|
579
|
+
},
|
|
580
|
+
{
|
|
581
|
+
id: "hermes",
|
|
582
|
+
name: "Hermes Terminal",
|
|
583
|
+
detected: true,
|
|
584
|
+
configured: false,
|
|
585
|
+
status: "installed",
|
|
586
|
+
configPath: "/tmp/hermes.json",
|
|
587
|
+
reason: "Installed, TokenBuddy config missing"
|
|
588
|
+
}
|
|
589
|
+
]);
|
|
590
|
+
|
|
591
|
+
expect(selection.installed).toEqual([
|
|
592
|
+
expect.objectContaining({
|
|
593
|
+
value: "codex",
|
|
594
|
+
label: "Codex CLI(已安装)"
|
|
595
|
+
})
|
|
596
|
+
]);
|
|
597
|
+
expect(selection.options).toEqual([
|
|
598
|
+
expect.objectContaining({
|
|
599
|
+
value: "hermes",
|
|
600
|
+
label: "Hermes Terminal"
|
|
601
|
+
}),
|
|
602
|
+
expect.objectContaining({
|
|
603
|
+
value: OTHER_TERMINAL_OPTION.value,
|
|
604
|
+
label: OTHER_TERMINAL_OPTION.label
|
|
605
|
+
})
|
|
606
|
+
]);
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
test("requires at least one terminal choice", () => {
|
|
610
|
+
expect(validateInitTerminalSelection([])).toBe("Select at least one terminal or choose Other.");
|
|
611
|
+
expect(validateInitTerminalSelection(["other"])).toBeUndefined();
|
|
612
|
+
expect(validateInitTerminalSelection(["hermes"])).toBeUndefined();
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
test("waits for ClawTip scan confirmation until the wallet config appears", async () => {
|
|
616
|
+
let attempts = 0;
|
|
617
|
+
const inspectWalletConfig = jest.fn(() => {
|
|
618
|
+
attempts += 1;
|
|
619
|
+
return {
|
|
620
|
+
expectedPath: "/tmp/home/.openclaw/configs/config.json",
|
|
621
|
+
configsDirExists: true,
|
|
622
|
+
exists: attempts >= 3,
|
|
623
|
+
alternatePaths: []
|
|
624
|
+
};
|
|
625
|
+
});
|
|
626
|
+
const sleep = jest.fn(async () => undefined);
|
|
627
|
+
|
|
628
|
+
await expect(waitForClawtipActivationConfirmation({
|
|
629
|
+
inspectWalletConfig,
|
|
630
|
+
pollIntervalMs: 10,
|
|
631
|
+
sleep,
|
|
632
|
+
})).resolves.toBe(true);
|
|
633
|
+
|
|
634
|
+
expect(inspectWalletConfig).toHaveBeenCalledTimes(3);
|
|
635
|
+
expect(sleep).toHaveBeenCalledTimes(2);
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
test("cancels ClawTip scan confirmation on Ctrl+C", async () => {
|
|
639
|
+
const cancelled = jest.fn();
|
|
640
|
+
await expect(
|
|
641
|
+
waitForClawtipActivationConfirmation({
|
|
642
|
+
inspectWalletConfig: () => ({
|
|
643
|
+
expectedPath: "/tmp/home/.openclaw/configs/config.json",
|
|
644
|
+
configsDirExists: true,
|
|
645
|
+
exists: false,
|
|
646
|
+
alternatePaths: []
|
|
647
|
+
}),
|
|
648
|
+
isCancelled: () => true,
|
|
649
|
+
cancel: cancelled,
|
|
650
|
+
pollIntervalMs: 10,
|
|
651
|
+
sleep: async () => undefined,
|
|
652
|
+
})
|
|
653
|
+
).resolves.toBe(false);
|
|
654
|
+
|
|
655
|
+
expect(cancelled).toHaveBeenCalledWith("ClawTip activation cancelled.");
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
test("parses ClawTip auth URL and clawtipId from pay CLI output", () => {
|
|
659
|
+
const parsed = parseClawtipCliOutput(
|
|
660
|
+
"请扫码 https://clawtip.jd.com/qrcode?foo=1&clawtipId=device-789 完成授权"
|
|
661
|
+
);
|
|
662
|
+
|
|
663
|
+
expect(parsed.requiresWalletAuth).toBe(true);
|
|
664
|
+
expect(parsed.authUrl).toBe("https://clawtip.jd.com/qrcode?foo=1&clawtipId=device-789");
|
|
665
|
+
expect(parsed.clawtipId).toBe("device-789");
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
test("ignores bootstrap env endpoint noise when parsing the real ClawTip auth URL", () => {
|
|
669
|
+
const parsed = parseClawtipCliOutput([
|
|
670
|
+
"process.env.CLAWTIP_PAY https://ms.jr.jd.com/gw2/generic/hyqy/h5/m/clawtipPay",
|
|
671
|
+
"请扫码 https://idt.jd.com/unifiedAuthM/plat/safeMonitor/?authFlowUid=abc 完成授权",
|
|
672
|
+
].join("\n"));
|
|
673
|
+
|
|
674
|
+
expect(parsed.authUrl).toBe(
|
|
675
|
+
"https://idt.jd.com/unifiedAuthM/plat/safeMonitor/?authFlowUid=abc"
|
|
676
|
+
);
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
test("parses the official ClawTip wallet QR image path", () => {
|
|
680
|
+
const parsed = parseClawtipCliOutput([
|
|
681
|
+
"从 .env.prod 加载环境变量",
|
|
682
|
+
"/Users/test/.openclaw/workspace/clawtip/qrcode/clawtip-index-code.png",
|
|
683
|
+
].join("\n"));
|
|
684
|
+
|
|
685
|
+
expect(parsed.mediaPath).toBe("/Users/test/.openclaw/workspace/clawtip/qrcode/clawtip-index-code.png");
|
|
686
|
+
expect(parsed.requiresWalletAuth).toBe(true);
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
test("surfaces ClawTip upstream payment errors directly", async () => {
|
|
690
|
+
const parsed = parseClawtipCliOutput("ClawTip 返回错误:商家信息有误");
|
|
691
|
+
|
|
692
|
+
expect(parsed.failureMessage).toContain("商家信息有误");
|
|
693
|
+
await expect(startClawtipWalletBootstrap({
|
|
694
|
+
orderNo: "order_error",
|
|
695
|
+
amountFen: 1,
|
|
696
|
+
payTo: "pay-to-test",
|
|
697
|
+
encryptedData: "ciphertext",
|
|
698
|
+
indicator: "indicator_error",
|
|
699
|
+
slug: "tb-wallet-bootstrap",
|
|
700
|
+
skillId: "si-tb-wallet-bootstrap",
|
|
701
|
+
description: "TokenBuddy ClawTip wallet activation",
|
|
702
|
+
resourceUrl: "https://tb-wallet-bootstrap.fly.dev"
|
|
703
|
+
}, {
|
|
704
|
+
home: path.join(TEMP_STORE_ROOT, "clawtip-error-home"),
|
|
705
|
+
runClawtipCommand: async () => "ClawTip 返回错误:商家信息有误",
|
|
706
|
+
})).rejects.toThrow("ClawTip pay failed: ClawTip 返回错误:商家信息有误");
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
test("uses only ClawTip CLI media paths for QR resolution", () => {
|
|
710
|
+
expect(resolveClawtipQrMediaPath({
|
|
711
|
+
authUrl: "https://clawtip.jd.com/qrcode?clawtipId=device-789",
|
|
712
|
+
clawtipId: "device-789",
|
|
713
|
+
mediaPath: "/tmp/clawtip/qrcode-1.png",
|
|
714
|
+
requiresWalletAuth: true,
|
|
715
|
+
walletReady: false,
|
|
716
|
+
}, "/tmp/orders/order-1.json")).toBe("/tmp/clawtip/qrcode-1.png");
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
test("fails ClawTip QR resolution when pay CLI emits no QR source", () => {
|
|
720
|
+
expect(() => resolveClawtipQrMediaPath({
|
|
721
|
+
authUrl: undefined,
|
|
722
|
+
clawtipId: undefined,
|
|
723
|
+
mediaPath: undefined,
|
|
724
|
+
requiresWalletAuth: false,
|
|
725
|
+
walletReady: false,
|
|
726
|
+
}, "/tmp/orders/order-1.json")).toThrow("ClawTip pay did not return a QR media file.");
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
test("checks OpenClaw before ClawTip wallet bootstrap", async () => {
|
|
730
|
+
const calls: string[][] = [];
|
|
731
|
+
const openClawVersion = await checkOpenClawRuntime({
|
|
732
|
+
runOpenClawCommand: async (args) => {
|
|
733
|
+
calls.push(["openclaw", ...args]);
|
|
734
|
+
return "OpenClaw 2026.5.18";
|
|
735
|
+
},
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
expect(openClawVersion).toBe("OpenClaw 2026.5.18");
|
|
739
|
+
expect(calls).toEqual([
|
|
740
|
+
["openclaw", "--version"],
|
|
741
|
+
]);
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
test("normalizes the bootstrap resource URL away from the public registry endpoint", () => {
|
|
745
|
+
expect(normalizeClawtipBootstrapResourceUrl(
|
|
746
|
+
"https://tb-wallet-bootstrap.fly.dev",
|
|
747
|
+
"https://tb-wallet-bootstrap.fly.dev/registry/sellers"
|
|
748
|
+
)).toBe("https://tb-wallet-bootstrap.fly.dev");
|
|
749
|
+
|
|
750
|
+
expect(normalizeClawtipBootstrapResourceUrl(
|
|
751
|
+
"https://tb-wallet-bootstrap.fly.dev/base",
|
|
752
|
+
"https://tb-wallet-bootstrap.fly.dev/registry/sellers"
|
|
753
|
+
)).toBe("https://tb-wallet-bootstrap.fly.dev/base");
|
|
754
|
+
|
|
755
|
+
expect(normalizeClawtipBootstrapResourceUrl(
|
|
756
|
+
"https://tb-wallet-bootstrap.fly.dev",
|
|
757
|
+
"https://example.test/pay"
|
|
758
|
+
)).toBe("https://example.test/pay");
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
test("rejects a bootstrap response that still uses the placeholder ClawTip payTo", async () => {
|
|
762
|
+
const originalFetch = global.fetch;
|
|
763
|
+
global.fetch = jest.fn(async () => new Response(JSON.stringify({
|
|
764
|
+
activationFeeFen: 1,
|
|
765
|
+
payment: {
|
|
766
|
+
orderNo: "order_placeholder",
|
|
767
|
+
indicator: "indicator_placeholder",
|
|
768
|
+
payTo: "bootstrap-pay-to",
|
|
769
|
+
resourceUrl: "https://tb-wallet-bootstrap.fly.dev/registry/sellers",
|
|
770
|
+
}
|
|
771
|
+
}), {
|
|
772
|
+
status: 200,
|
|
773
|
+
headers: { "Content-Type": "application/json" }
|
|
774
|
+
})) as typeof fetch;
|
|
775
|
+
|
|
776
|
+
try {
|
|
777
|
+
await expect(fetchClawtipBootstrap("https://tb-wallet-bootstrap.fly.dev")).rejects.toThrow(
|
|
778
|
+
"ClawTip bootstrap service is misconfigured: payTo is still the placeholder"
|
|
779
|
+
);
|
|
780
|
+
} finally {
|
|
781
|
+
global.fetch = originalFetch;
|
|
782
|
+
}
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
test("writes the Rust-compatible ClawTip order file shape", () => {
|
|
786
|
+
const home = path.join(TEMP_STORE_ROOT, "clawtip-order-home");
|
|
787
|
+
rmDir(home);
|
|
788
|
+
|
|
789
|
+
const orderFile = writeClawtipOrderFile({
|
|
790
|
+
orderNo: "order_123",
|
|
791
|
+
amountFen: 1,
|
|
792
|
+
payTo: "pay-to-test",
|
|
793
|
+
encryptedData: "ciphertext",
|
|
794
|
+
indicator: "indicator_123",
|
|
795
|
+
slug: "tb-wallet-bootstrap",
|
|
796
|
+
skillId: "si-tb-wallet-bootstrap",
|
|
797
|
+
description: "TokenBuddy ClawTip wallet activation",
|
|
798
|
+
resourceUrl: "https://tb-wallet-bootstrap.fly.dev"
|
|
799
|
+
}, home);
|
|
800
|
+
|
|
801
|
+
expect(orderFile).toBe(path.join(
|
|
802
|
+
home,
|
|
803
|
+
".openclaw",
|
|
804
|
+
"skills",
|
|
805
|
+
"orders",
|
|
806
|
+
"indicator_123",
|
|
807
|
+
"order_123.json"
|
|
808
|
+
));
|
|
809
|
+
|
|
810
|
+
const saved = JSON.parse(fs.readFileSync(orderFile, "utf8"));
|
|
811
|
+
expect(saved).toEqual(expect.objectContaining({
|
|
812
|
+
"skill-id": "si-tb-wallet-bootstrap",
|
|
813
|
+
order_no: "order_123",
|
|
814
|
+
amount: 1,
|
|
815
|
+
encrypted_data: "ciphertext",
|
|
816
|
+
pay_to: "pay-to-test",
|
|
817
|
+
slug: "tb-wallet-bootstrap",
|
|
818
|
+
resource_url: "https://tb-wallet-bootstrap.fly.dev"
|
|
819
|
+
}));
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
test("starts ClawTip payment activation and reads payCredential from the order file", async () => {
|
|
823
|
+
const home = path.join(TEMP_STORE_ROOT, "clawtip-bootstrap-home");
|
|
824
|
+
rmDir(home);
|
|
825
|
+
|
|
826
|
+
const activation = await startClawtipWalletBootstrap({
|
|
827
|
+
orderNo: "order_456",
|
|
828
|
+
amountFen: 1,
|
|
829
|
+
payTo: "pay-to-test",
|
|
830
|
+
encryptedData: "ciphertext",
|
|
831
|
+
indicator: "indicator_456",
|
|
832
|
+
slug: "tb-wallet-bootstrap",
|
|
833
|
+
skillId: "si-tb-wallet-bootstrap",
|
|
834
|
+
description: "TokenBuddy ClawTip wallet activation",
|
|
835
|
+
resourceUrl: "https://tb-wallet-bootstrap.fly.dev"
|
|
836
|
+
}, {
|
|
837
|
+
home,
|
|
838
|
+
runClawtipCommand: async () => {
|
|
839
|
+
const orderFile = path.join(
|
|
840
|
+
home,
|
|
841
|
+
".openclaw",
|
|
842
|
+
"skills",
|
|
843
|
+
"orders",
|
|
844
|
+
"indicator_456",
|
|
845
|
+
"order_456.json"
|
|
846
|
+
);
|
|
847
|
+
const order = JSON.parse(fs.readFileSync(orderFile, "utf8"));
|
|
848
|
+
order.payCredential = "credential_456";
|
|
849
|
+
fs.writeFileSync(orderFile, JSON.stringify(order, null, 2), "utf8");
|
|
850
|
+
return "已获取到支付凭证";
|
|
851
|
+
}
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
expect(activation.orderFile).toBe(path.join(
|
|
855
|
+
home,
|
|
856
|
+
".openclaw",
|
|
857
|
+
"skills",
|
|
858
|
+
"orders",
|
|
859
|
+
"indicator_456",
|
|
860
|
+
"order_456.json"
|
|
861
|
+
));
|
|
862
|
+
expect(activation.payCredential).toBe("credential_456");
|
|
863
|
+
expect(readClawtipPayCredential(activation.orderFile)).toBe("credential_456");
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
test("recovers the latest generated ClawTip QR media path when pay output omits MEDIA", async () => {
|
|
867
|
+
const home = path.join(TEMP_STORE_ROOT, "clawtip-bootstrap-qr-home");
|
|
868
|
+
rmDir(home);
|
|
869
|
+
|
|
870
|
+
const activation = await startClawtipWalletBootstrap({
|
|
871
|
+
orderNo: "order_789",
|
|
872
|
+
amountFen: 1,
|
|
873
|
+
payTo: "pay-to-test",
|
|
874
|
+
encryptedData: "ciphertext",
|
|
875
|
+
indicator: "indicator_789",
|
|
876
|
+
slug: "tb-wallet-bootstrap",
|
|
877
|
+
skillId: "si-tb-wallet-bootstrap",
|
|
878
|
+
description: "TokenBuddy ClawTip wallet activation",
|
|
879
|
+
resourceUrl: "https://tb-wallet-bootstrap.fly.dev"
|
|
880
|
+
}, {
|
|
881
|
+
home,
|
|
882
|
+
runClawtipCommand: async () => {
|
|
883
|
+
const qrDir = path.join(home, ".openclaw", "workspace", "clawtip", "qrcode");
|
|
884
|
+
fs.mkdirSync(qrDir, { recursive: true });
|
|
885
|
+
const qrPath = path.join(qrDir, "qrcode-generated.png");
|
|
886
|
+
fs.writeFileSync(qrPath, "png", "utf8");
|
|
887
|
+
return [
|
|
888
|
+
"process.env.CLAWTIP_PAY https://ms.jr.jd.com/gw2/generic/hyqy/h5/m/clawtipPay",
|
|
889
|
+
"请扫码 https://idt.jd.com/unifiedAuthM/plat/safeMonitor/?authFlowUid=abc 完成授权",
|
|
890
|
+
].join("\n");
|
|
891
|
+
}
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
expect(activation.parsedOutput.authUrl).toBe(
|
|
895
|
+
"https://idt.jd.com/unifiedAuthM/plat/safeMonitor/?authFlowUid=abc"
|
|
896
|
+
);
|
|
897
|
+
expect(activation.parsedOutput.mediaPath).toBe(
|
|
898
|
+
path.join(home, ".openclaw", "workspace", "clawtip", "qrcode", "qrcode-generated.png")
|
|
899
|
+
);
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
test("recovers ClawTip QR media even when pay output only writes payCredential", async () => {
|
|
903
|
+
const home = path.join(TEMP_STORE_ROOT, "clawtip-bootstrap-credential-and-qr-home");
|
|
904
|
+
rmDir(home);
|
|
905
|
+
|
|
906
|
+
const activation = await startClawtipWalletBootstrap({
|
|
907
|
+
orderNo: "order_credential_qr",
|
|
908
|
+
amountFen: 1,
|
|
909
|
+
payTo: "pay-to-test",
|
|
910
|
+
encryptedData: "ciphertext",
|
|
911
|
+
indicator: "indicator_credential_qr",
|
|
912
|
+
slug: "tb-wallet-bootstrap",
|
|
913
|
+
skillId: "si-tb-wallet-bootstrap",
|
|
914
|
+
description: "TokenBuddy ClawTip wallet activation",
|
|
915
|
+
resourceUrl: "https://tb-wallet-bootstrap.fly.dev"
|
|
916
|
+
}, {
|
|
917
|
+
home,
|
|
918
|
+
runClawtipCommand: async () => {
|
|
919
|
+
const orderFile = path.join(
|
|
920
|
+
home,
|
|
921
|
+
".openclaw",
|
|
922
|
+
"skills",
|
|
923
|
+
"orders",
|
|
924
|
+
"indicator_credential_qr",
|
|
925
|
+
"order_credential_qr.json"
|
|
926
|
+
);
|
|
927
|
+
const order = JSON.parse(fs.readFileSync(orderFile, "utf8"));
|
|
928
|
+
order.payCredential = "credential_without_wallet_config";
|
|
929
|
+
fs.writeFileSync(orderFile, JSON.stringify(order, null, 2), "utf8");
|
|
930
|
+
|
|
931
|
+
const qrDir = path.join(home, ".openclaw", "workspace", "clawtip", "qrcode");
|
|
932
|
+
fs.mkdirSync(qrDir, { recursive: true });
|
|
933
|
+
const qrPath = path.join(qrDir, "qrcode-credential.png");
|
|
934
|
+
fs.writeFileSync(qrPath, "png", "utf8");
|
|
935
|
+
return "已获取到支付凭证";
|
|
936
|
+
}
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
expect(activation.payCredential).toBe("credential_without_wallet_config");
|
|
940
|
+
expect(activation.parsedOutput.requiresWalletAuth).toBe(true);
|
|
941
|
+
expect(activation.parsedOutput.mediaPath).toBe(
|
|
942
|
+
path.join(home, ".openclaw", "workspace", "clawtip", "qrcode", "qrcode-credential.png")
|
|
943
|
+
);
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
test("doctor prompts tb init when no ClawTip wallet is available", () => {
|
|
947
|
+
const lines: string[] = [];
|
|
948
|
+
|
|
949
|
+
printDoctorClawtipWallet({
|
|
950
|
+
status: "missing",
|
|
951
|
+
ready: false,
|
|
952
|
+
paymentMetadataPresent: false,
|
|
953
|
+
walletConfigPresent: false,
|
|
954
|
+
configsDirExists: false,
|
|
955
|
+
expectedPath: "/tmp/home/.openclaw/configs/config.json",
|
|
956
|
+
alternatePaths: [],
|
|
957
|
+
message: "ClawTip payment metadata and local OpenClaw wallet config are not configured.",
|
|
958
|
+
}, (line) => {
|
|
959
|
+
lines.push(line);
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
expect(lines.join("\n")).toContain(
|
|
963
|
+
"Action: Run `tb init` and choose ClawTip to bind a wallet before using ClawTip-backed purchases."
|
|
964
|
+
);
|
|
965
|
+
});
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
describe("TokenBuddy terminal image display", () => {
|
|
969
|
+
const imagePath = "/Users/test/.openclaw/workspace/clawtip/qrcode/clawtip-index-code.png";
|
|
970
|
+
|
|
971
|
+
test("detects inline image capable terminals only when stdout is a TTY", () => {
|
|
972
|
+
expect(detectTerminalImageDisplay({
|
|
973
|
+
env: { TERM_PROGRAM: "iTerm.app" },
|
|
974
|
+
stdoutIsTTY: true,
|
|
975
|
+
})).toBe("iterm");
|
|
976
|
+
expect(detectTerminalImageDisplay({
|
|
977
|
+
env: { WEZTERM_EXECUTABLE: "/Applications/WezTerm.app/wezterm" },
|
|
978
|
+
stdoutIsTTY: true,
|
|
979
|
+
})).toBe("iterm");
|
|
980
|
+
expect(detectTerminalImageDisplay({
|
|
981
|
+
env: { KITTY_WINDOW_ID: "1" },
|
|
982
|
+
stdoutIsTTY: true,
|
|
983
|
+
})).toBe("kitty");
|
|
984
|
+
expect(detectTerminalImageDisplay({
|
|
985
|
+
env: { TERM_PROGRAM: "iTerm.app" },
|
|
986
|
+
stdoutIsTTY: false,
|
|
987
|
+
})).toBeUndefined();
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
test("renders the ClawTip QR image inline for iTerm2-compatible terminals", async () => {
|
|
991
|
+
const writes: string[] = [];
|
|
992
|
+
const runCommand = jest.fn(async () => undefined);
|
|
993
|
+
|
|
994
|
+
const result = await displayTerminalImage(imagePath, {
|
|
995
|
+
env: { TERM_PROGRAM: "iTerm.app" },
|
|
996
|
+
stdoutIsTTY: true,
|
|
997
|
+
fileExists: () => true,
|
|
998
|
+
readFile: () => Buffer.from("png-bytes"),
|
|
999
|
+
write: (chunk) => {
|
|
1000
|
+
writes.push(chunk);
|
|
1001
|
+
},
|
|
1002
|
+
runCommand,
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
expect(result).toEqual(expect.objectContaining({
|
|
1006
|
+
method: "inline-iterm",
|
|
1007
|
+
displayed: true,
|
|
1008
|
+
}));
|
|
1009
|
+
expect(writes.join("")).toContain("\u001B]1337;File=");
|
|
1010
|
+
expect(writes.join("")).toContain("inline=1");
|
|
1011
|
+
expect(runCommand).not.toHaveBeenCalled();
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
test("renders the ClawTip QR image inline for Kitty terminals", async () => {
|
|
1015
|
+
const writes: string[] = [];
|
|
1016
|
+
|
|
1017
|
+
const result = await displayTerminalImage(imagePath, {
|
|
1018
|
+
env: { TERM: "xterm-kitty" },
|
|
1019
|
+
stdoutIsTTY: true,
|
|
1020
|
+
fileExists: () => true,
|
|
1021
|
+
write: (chunk) => {
|
|
1022
|
+
writes.push(chunk);
|
|
1023
|
+
},
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
expect(result).toEqual(expect.objectContaining({
|
|
1027
|
+
method: "inline-kitty",
|
|
1028
|
+
displayed: true,
|
|
1029
|
+
}));
|
|
1030
|
+
expect(writes.join("")).toContain("\u001B_Ga=T,t=f,f=100,c=60;");
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
test("opens the ClawTip QR image with the system viewer when inline images are unsupported", async () => {
|
|
1034
|
+
const runCommand = jest.fn(async () => undefined);
|
|
1035
|
+
|
|
1036
|
+
const result = await displayTerminalImage(imagePath, {
|
|
1037
|
+
env: { TERM_PROGRAM: "Apple_Terminal" },
|
|
1038
|
+
platform: "darwin",
|
|
1039
|
+
stdoutIsTTY: true,
|
|
1040
|
+
fileExists: () => true,
|
|
1041
|
+
runCommand,
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
expect(result).toEqual(expect.objectContaining({
|
|
1045
|
+
method: "system-open",
|
|
1046
|
+
displayed: true,
|
|
1047
|
+
fallbackCommand: `open ${imagePath}`,
|
|
1048
|
+
}));
|
|
1049
|
+
expect(runCommand).toHaveBeenCalledWith("open", [imagePath]);
|
|
1050
|
+
});
|
|
1051
|
+
|
|
1052
|
+
test("falls back to a manual open command when the system viewer fails", async () => {
|
|
1053
|
+
const result = await displayTerminalImage(imagePath, {
|
|
1054
|
+
env: {},
|
|
1055
|
+
platform: "darwin",
|
|
1056
|
+
stdoutIsTTY: true,
|
|
1057
|
+
fileExists: () => true,
|
|
1058
|
+
runCommand: async () => {
|
|
1059
|
+
throw new Error("no gui session");
|
|
1060
|
+
},
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
expect(result).toEqual(expect.objectContaining({
|
|
1064
|
+
method: "manual",
|
|
1065
|
+
displayed: false,
|
|
1066
|
+
fallbackCommand: `open ${imagePath}`,
|
|
1067
|
+
}));
|
|
1068
|
+
expect(result.message).toContain("no gui session");
|
|
1069
|
+
});
|
|
1070
|
+
});
|
|
1071
|
+
|
|
377
1072
|
describe("TokenBuddy JSON inspection commands", () => {
|
|
378
1073
|
let controlServer: http.Server;
|
|
1074
|
+
let proxyServer: http.Server;
|
|
379
1075
|
let controlPort: number;
|
|
1076
|
+
let proxyPort: number;
|
|
380
1077
|
let previousControlPort: string | undefined;
|
|
381
1078
|
let previousProxyPort: string | undefined;
|
|
1079
|
+
let previousBuyerStoreRoot: string | undefined;
|
|
1080
|
+
let previousHome: string | undefined;
|
|
382
1081
|
|
|
383
1082
|
beforeEach((done) => {
|
|
384
1083
|
previousControlPort = process.env.TB_PROXYD_CONTROL_PORT;
|
|
385
1084
|
previousProxyPort = process.env.TB_PROXYD_PROXY_PORT;
|
|
1085
|
+
previousBuyerStoreRoot = process.env.TOKENBUDDY_BUYER_STORE;
|
|
1086
|
+
previousHome = process.env.HOME;
|
|
1087
|
+
rmDir(INSPECTION_STORE_ROOT);
|
|
1088
|
+
rmDir(INSPECTION_HOME);
|
|
1089
|
+
process.env.TOKENBUDDY_BUYER_STORE = INSPECTION_STORE_ROOT;
|
|
1090
|
+
process.env.HOME = INSPECTION_HOME;
|
|
1091
|
+
fs.mkdirSync(path.join(INSPECTION_HOME, ".openclaw", "configs"), { recursive: true });
|
|
1092
|
+
fs.writeFileSync(path.join(INSPECTION_HOME, ".openclaw", "configs", "config.json.bak"), "{}", "utf8");
|
|
1093
|
+
const store = new BuyerStore();
|
|
1094
|
+
store.savePayment({
|
|
1095
|
+
method: "clawtip",
|
|
1096
|
+
enabled: true,
|
|
1097
|
+
isDefault: true,
|
|
1098
|
+
config: {
|
|
1099
|
+
orderNo: "order_json",
|
|
1100
|
+
resourceUrl: "https://example.test/pay"
|
|
1101
|
+
}
|
|
1102
|
+
});
|
|
1103
|
+
store.close();
|
|
1104
|
+
proxyServer = http.createServer((req, res) => {
|
|
1105
|
+
res.setHeader("Content-Type", "application/json");
|
|
1106
|
+
if (req.url === "/v1/models") {
|
|
1107
|
+
res.end(JSON.stringify({
|
|
1108
|
+
object: "list",
|
|
1109
|
+
data: [
|
|
1110
|
+
{ id: "gpt-4", sellerId: "json-test-seller" }
|
|
1111
|
+
]
|
|
1112
|
+
}));
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
res.statusCode = 404;
|
|
1116
|
+
res.end(JSON.stringify({ error: "not_found" }));
|
|
1117
|
+
});
|
|
386
1118
|
controlServer = http.createServer((req, res) => {
|
|
387
1119
|
res.setHeader("Content-Type", "application/json");
|
|
1120
|
+
if (req.url === "/health") {
|
|
1121
|
+
res.end(JSON.stringify({
|
|
1122
|
+
status: "ok",
|
|
1123
|
+
controlPort,
|
|
1124
|
+
proxyPort
|
|
1125
|
+
}));
|
|
1126
|
+
return;
|
|
1127
|
+
}
|
|
388
1128
|
if (req.url === "/status") {
|
|
389
1129
|
res.end(JSON.stringify({
|
|
390
1130
|
status: "running",
|
|
391
1131
|
pid: 12345,
|
|
392
1132
|
controlPort,
|
|
393
|
-
proxyPort
|
|
1133
|
+
proxyPort
|
|
1134
|
+
}));
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
if (req.url === "/sellers") {
|
|
1138
|
+
res.end(JSON.stringify({
|
|
1139
|
+
registryUrl: "https://example.test/registry/sellers",
|
|
1140
|
+
version: 7,
|
|
1141
|
+
defaultSeller: "json-test-seller",
|
|
1142
|
+
sellers: [
|
|
1143
|
+
{
|
|
1144
|
+
id: "json-test-seller",
|
|
1145
|
+
name: "JSON Seller",
|
|
1146
|
+
url: "https://seller.example.test",
|
|
1147
|
+
supportedProtocols: ["responses"],
|
|
1148
|
+
paymentMethods: ["mock"],
|
|
1149
|
+
discountRatio: 0.25,
|
|
1150
|
+
status: "configured"
|
|
1151
|
+
}
|
|
1152
|
+
]
|
|
394
1153
|
}));
|
|
395
1154
|
return;
|
|
396
1155
|
}
|
|
397
1156
|
if (req.url === "/models") {
|
|
398
1157
|
res.end(JSON.stringify({
|
|
399
1158
|
object: "list",
|
|
1159
|
+
registryUrl: "https://example.test/registry/sellers",
|
|
400
1160
|
data: [
|
|
401
|
-
{
|
|
1161
|
+
{
|
|
1162
|
+
id: "gpt-4",
|
|
1163
|
+
sellerId: "json-test-seller",
|
|
1164
|
+
sellerName: "JSON Seller",
|
|
1165
|
+
sellerUrl: "https://seller.example.test",
|
|
1166
|
+
supportedProtocols: ["responses"],
|
|
1167
|
+
paymentMethods: ["mock"],
|
|
1168
|
+
inputPriceMicrosPer1m: 1000000,
|
|
1169
|
+
outputPriceMicrosPer1m: 3000000
|
|
1170
|
+
}
|
|
1171
|
+
],
|
|
1172
|
+
sellers: [
|
|
1173
|
+
{
|
|
1174
|
+
id: "json-test-seller",
|
|
1175
|
+
name: "JSON Seller",
|
|
1176
|
+
url: "https://seller.example.test",
|
|
1177
|
+
supportedProtocols: ["responses"],
|
|
1178
|
+
paymentMethods: ["mock"],
|
|
1179
|
+
discountRatio: 0.25,
|
|
1180
|
+
status: "ok",
|
|
1181
|
+
modelCount: 1
|
|
1182
|
+
}
|
|
402
1183
|
]
|
|
403
1184
|
}));
|
|
404
1185
|
return;
|
|
@@ -406,11 +1187,14 @@ describe("TokenBuddy JSON inspection commands", () => {
|
|
|
406
1187
|
res.statusCode = 404;
|
|
407
1188
|
res.end(JSON.stringify({ error: "not_found" }));
|
|
408
1189
|
});
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
1190
|
+
proxyServer.listen(0, "127.0.0.1", () => {
|
|
1191
|
+
proxyPort = (proxyServer.address() as AddressInfo).port;
|
|
1192
|
+
controlServer.listen(0, "127.0.0.1", () => {
|
|
1193
|
+
controlPort = (controlServer.address() as AddressInfo).port;
|
|
1194
|
+
process.env.TB_PROXYD_CONTROL_PORT = String(controlPort);
|
|
1195
|
+
process.env.TB_PROXYD_PROXY_PORT = String(proxyPort);
|
|
1196
|
+
done();
|
|
1197
|
+
});
|
|
414
1198
|
});
|
|
415
1199
|
});
|
|
416
1200
|
|
|
@@ -425,8 +1209,22 @@ describe("TokenBuddy JSON inspection commands", () => {
|
|
|
425
1209
|
} else {
|
|
426
1210
|
process.env.TB_PROXYD_PROXY_PORT = previousProxyPort;
|
|
427
1211
|
}
|
|
1212
|
+
if (previousBuyerStoreRoot === undefined) {
|
|
1213
|
+
delete process.env.TOKENBUDDY_BUYER_STORE;
|
|
1214
|
+
} else {
|
|
1215
|
+
process.env.TOKENBUDDY_BUYER_STORE = previousBuyerStoreRoot;
|
|
1216
|
+
}
|
|
1217
|
+
if (previousHome === undefined) {
|
|
1218
|
+
delete process.env.HOME;
|
|
1219
|
+
} else {
|
|
1220
|
+
process.env.HOME = previousHome;
|
|
1221
|
+
}
|
|
428
1222
|
jest.restoreAllMocks();
|
|
429
|
-
controlServer.close(
|
|
1223
|
+
controlServer.close(() => proxyServer.close(() => {
|
|
1224
|
+
rmDir(INSPECTION_STORE_ROOT);
|
|
1225
|
+
rmDir(INSPECTION_HOME);
|
|
1226
|
+
done();
|
|
1227
|
+
}));
|
|
430
1228
|
});
|
|
431
1229
|
|
|
432
1230
|
test("doctor --json reports daemon and provider state", async () => {
|
|
@@ -442,7 +1240,7 @@ describe("TokenBuddy JSON inspection commands", () => {
|
|
|
442
1240
|
expect(parsed.daemon).toMatchObject({
|
|
443
1241
|
running: true,
|
|
444
1242
|
controlPort,
|
|
445
|
-
proxyPort
|
|
1243
|
+
proxyPort,
|
|
446
1244
|
fixAvailable: true
|
|
447
1245
|
});
|
|
448
1246
|
expect(parsed.repair).toMatchObject({
|
|
@@ -450,12 +1248,60 @@ describe("TokenBuddy JSON inspection commands", () => {
|
|
|
450
1248
|
attempted: false,
|
|
451
1249
|
fixed: false
|
|
452
1250
|
});
|
|
1251
|
+
expect(parsed.access).toMatchObject({
|
|
1252
|
+
token: "TOKENBUDDY_PROXY",
|
|
1253
|
+
controlBaseUrl: `http://127.0.0.1:${controlPort}`,
|
|
1254
|
+
proxyBaseUrl: `http://127.0.0.1:${proxyPort}`,
|
|
1255
|
+
});
|
|
1256
|
+
expect(parsed.access.endpoints).toEqual(expect.arrayContaining([
|
|
1257
|
+
expect.objectContaining({ id: "control.health", available: true }),
|
|
1258
|
+
expect.objectContaining({ id: "proxy.openai", available: true, token: "TOKENBUDDY_PROXY" })
|
|
1259
|
+
]));
|
|
1260
|
+
expect(parsed.sellers).toMatchObject({
|
|
1261
|
+
available: true,
|
|
1262
|
+
registryUrl: "https://example.test/registry/sellers",
|
|
1263
|
+
defaultSeller: "json-test-seller",
|
|
1264
|
+
});
|
|
1265
|
+
expect(parsed.models).toMatchObject({
|
|
1266
|
+
available: true,
|
|
1267
|
+
count: 1,
|
|
1268
|
+
registryUrl: "https://example.test/registry/sellers",
|
|
1269
|
+
});
|
|
1270
|
+
expect(parsed.clawtipWallet).toMatchObject({
|
|
1271
|
+
status: "metadata_missing_wallet",
|
|
1272
|
+
ready: false,
|
|
1273
|
+
paymentMetadataPresent: true,
|
|
1274
|
+
walletConfigPresent: false,
|
|
1275
|
+
expectedPath: path.join(INSPECTION_HOME, ".openclaw", "configs", "config.json"),
|
|
1276
|
+
alternatePaths: [
|
|
1277
|
+
path.join(INSPECTION_HOME, ".openclaw", "configs", "config.json.bak")
|
|
1278
|
+
]
|
|
1279
|
+
});
|
|
453
1280
|
expect(parsed.providers).toEqual(expect.arrayContaining([
|
|
454
1281
|
expect.objectContaining({ id: "codex" }),
|
|
455
1282
|
expect.objectContaining({ id: "claude-code" })
|
|
456
1283
|
]));
|
|
457
1284
|
});
|
|
458
1285
|
|
|
1286
|
+
test("doctor prints progress messages without repeating the model list", async () => {
|
|
1287
|
+
const output: string[] = [];
|
|
1288
|
+
jest.spyOn(console, "log").mockImplementation((message?: unknown) => {
|
|
1289
|
+
output.push(String(message));
|
|
1290
|
+
});
|
|
1291
|
+
|
|
1292
|
+
await buildCli().parseAsync(["node", "tb", "doctor"]);
|
|
1293
|
+
|
|
1294
|
+
const joined = output.join("\n");
|
|
1295
|
+
expect(joined).toContain("--- ClawTip Wallet ---");
|
|
1296
|
+
expect(joined).toContain("❌ ClawTip Wallet [metadata_missing_wallet]");
|
|
1297
|
+
expect(joined).toContain("Payment metadata: present");
|
|
1298
|
+
expect(joined).toContain("Wallet config: missing");
|
|
1299
|
+
expect(joined).toContain("Checking local control plane and proxy endpoints...");
|
|
1300
|
+
expect(joined).toContain("Refreshing seller registry...");
|
|
1301
|
+
expect(joined).toContain("Model catalog hidden in `tb doctor`. Run `tb models` for the current model summary.");
|
|
1302
|
+
expect(joined).not.toContain("Unique models:");
|
|
1303
|
+
});
|
|
1304
|
+
|
|
459
1305
|
test("models --json returns daemon model data", async () => {
|
|
460
1306
|
const output: string[] = [];
|
|
461
1307
|
jest.spyOn(console, "log").mockImplementation((message?: unknown) => {
|
|
@@ -466,47 +1312,127 @@ describe("TokenBuddy JSON inspection commands", () => {
|
|
|
466
1312
|
|
|
467
1313
|
expect(output).toHaveLength(1);
|
|
468
1314
|
const parsed = JSON.parse(output[0]) as any;
|
|
469
|
-
expect(parsed.
|
|
470
|
-
{
|
|
1315
|
+
expect(parsed.grouped).toEqual([
|
|
1316
|
+
expect.objectContaining({
|
|
1317
|
+
id: "gpt-4",
|
|
1318
|
+
sellerCount: 1,
|
|
1319
|
+
discountRange: "0.25",
|
|
1320
|
+
priceRange: "in $1 / out $3"
|
|
1321
|
+
})
|
|
471
1322
|
]);
|
|
472
1323
|
});
|
|
1324
|
+
|
|
1325
|
+
test("models prints the doctor-style grouped model summary", async () => {
|
|
1326
|
+
const output: string[] = [];
|
|
1327
|
+
jest.spyOn(console, "log").mockImplementation((message?: unknown) => {
|
|
1328
|
+
output.push(String(message));
|
|
1329
|
+
});
|
|
1330
|
+
|
|
1331
|
+
await buildCli().parseAsync(["node", "tb", "models"]);
|
|
1332
|
+
|
|
1333
|
+
const joined = output.join("\n");
|
|
1334
|
+
expect(joined).toContain("Model catalog refresh complete.");
|
|
1335
|
+
expect(joined).toContain("Unique models: 1");
|
|
1336
|
+
expect(joined).toContain("Seller offers: 1");
|
|
1337
|
+
expect(joined).toContain("Model ID");
|
|
1338
|
+
expect(joined).toContain("Seller Count");
|
|
1339
|
+
expect(joined).toContain("Discount Range");
|
|
1340
|
+
expect(joined).toContain("Price Range");
|
|
1341
|
+
expect(joined).toContain("gpt-4");
|
|
1342
|
+
expect(joined).toContain("0.25");
|
|
1343
|
+
expect(joined).toContain("$1");
|
|
1344
|
+
expect(joined).toContain("$3");
|
|
1345
|
+
});
|
|
473
1346
|
});
|
|
474
1347
|
|
|
475
1348
|
describe("Provider install planning", () => {
|
|
476
1349
|
const PROVIDER_HOME = path.resolve(__dirname, "../../data-test/provider-home");
|
|
477
1350
|
const PROVIDER_STORE_ROOT = path.resolve(__dirname, "../../data-test/provider-store");
|
|
1351
|
+
const PROVIDER_BIN_ROOT = path.resolve(__dirname, "../../data-test/provider-bin");
|
|
478
1352
|
const proxyUrl = "http://127.0.0.1:17821";
|
|
1353
|
+
let previousPath: string | undefined;
|
|
1354
|
+
|
|
1355
|
+
function writeExecutable(name: string): void {
|
|
1356
|
+
const executablePath = path.join(PROVIDER_BIN_ROOT, name);
|
|
1357
|
+
fs.writeFileSync(executablePath, "#!/bin/sh\nexit 0\n", "utf8");
|
|
1358
|
+
fs.chmodSync(executablePath, 0o755);
|
|
1359
|
+
}
|
|
479
1360
|
|
|
480
1361
|
beforeEach(() => {
|
|
481
1362
|
rmDir(PROVIDER_HOME);
|
|
482
1363
|
rmDir(PROVIDER_STORE_ROOT);
|
|
1364
|
+
rmDir(PROVIDER_BIN_ROOT);
|
|
483
1365
|
fs.mkdirSync(path.join(PROVIDER_HOME, ".codex"), { recursive: true });
|
|
484
1366
|
fs.mkdirSync(path.join(PROVIDER_HOME, ".claude"), { recursive: true });
|
|
485
1367
|
fs.mkdirSync(path.join(PROVIDER_HOME, ".openclaw"), { recursive: true });
|
|
1368
|
+
fs.mkdirSync(path.join(PROVIDER_HOME, ".config", "opencode"), { recursive: true });
|
|
1369
|
+
fs.mkdirSync(PROVIDER_BIN_ROOT, { recursive: true });
|
|
1370
|
+
writeExecutable("codex");
|
|
1371
|
+
writeExecutable("claude");
|
|
1372
|
+
writeExecutable("openclaw");
|
|
1373
|
+
writeExecutable("opencode");
|
|
1374
|
+
writeExecutable("hermes");
|
|
1375
|
+
previousPath = process.env.PATH;
|
|
1376
|
+
process.env.PATH = `${PROVIDER_BIN_ROOT}${path.delimiter}${previousPath || ""}`;
|
|
486
1377
|
fs.writeFileSync(path.join(PROVIDER_HOME, ".codex", "config.toml"), "approval_policy = \"never\"\n", "utf8");
|
|
487
1378
|
fs.writeFileSync(path.join(PROVIDER_HOME, ".claude", "settings.json"), JSON.stringify({ theme: "dark" }, null, 2), "utf8");
|
|
488
1379
|
fs.writeFileSync(path.join(PROVIDER_HOME, ".openclaw", "config.json"), JSON.stringify({ keep: "field" }, null, 2), "utf8");
|
|
1380
|
+
fs.writeFileSync(path.join(PROVIDER_HOME, ".config", "opencode", "opencode.json"), JSON.stringify({ share: "disabled" }, null, 2), "utf8");
|
|
489
1381
|
});
|
|
490
1382
|
|
|
491
1383
|
afterEach(() => {
|
|
492
1384
|
rmDir(PROVIDER_HOME);
|
|
493
1385
|
rmDir(PROVIDER_STORE_ROOT);
|
|
1386
|
+
rmDir(PROVIDER_BIN_ROOT);
|
|
1387
|
+
if (previousPath === undefined) {
|
|
1388
|
+
delete process.env.PATH;
|
|
1389
|
+
} else {
|
|
1390
|
+
process.env.PATH = previousPath;
|
|
1391
|
+
}
|
|
494
1392
|
});
|
|
495
1393
|
|
|
496
1394
|
test("detects providers and previews without mutating files", () => {
|
|
497
1395
|
const codexBefore = fs.readFileSync(path.join(PROVIDER_HOME, ".codex", "config.toml"), "utf8");
|
|
498
1396
|
const providers = detectProviders({ home: PROVIDER_HOME });
|
|
499
1397
|
expect(providers).toEqual(expect.arrayContaining([
|
|
500
|
-
expect.objectContaining({ id: "codex",
|
|
501
|
-
expect.objectContaining({ id: "claude-code",
|
|
502
|
-
expect.objectContaining({ id: "openclaw",
|
|
503
|
-
expect.objectContaining({ id: "hermes",
|
|
1398
|
+
expect.objectContaining({ id: "codex", status: "configured", configured: true }),
|
|
1399
|
+
expect.objectContaining({ id: "claude-code", status: "configured", configured: true }),
|
|
1400
|
+
expect.objectContaining({ id: "openclaw", status: "configured", configured: true }),
|
|
1401
|
+
expect.objectContaining({ id: "hermes", status: "installed", configured: false })
|
|
504
1402
|
]));
|
|
505
1403
|
|
|
506
1404
|
const changes = previewProviderInstall({
|
|
507
1405
|
providers: ["codex", "claude-code", "openclaw", "hermes"],
|
|
508
1406
|
proxyUrl,
|
|
509
|
-
|
|
1407
|
+
providerSelections: {
|
|
1408
|
+
codex: {
|
|
1409
|
+
selectionKind: "single-model",
|
|
1410
|
+
protocolPreference: "responses",
|
|
1411
|
+
defaultModel: "gpt-4",
|
|
1412
|
+
},
|
|
1413
|
+
"claude-code": {
|
|
1414
|
+
selectionKind: "claude-role-mapping",
|
|
1415
|
+
protocolPreference: "messages",
|
|
1416
|
+
fallbackModel: "MiniMax-M2.7-highspeed",
|
|
1417
|
+
roles: {
|
|
1418
|
+
sonnet: {
|
|
1419
|
+
upstreamModel: "MiniMax-M2.7-highspeed",
|
|
1420
|
+
displayName: "MiniMax-M2.7-highspeed",
|
|
1421
|
+
declareOneM: true,
|
|
1422
|
+
},
|
|
1423
|
+
},
|
|
1424
|
+
},
|
|
1425
|
+
openclaw: {
|
|
1426
|
+
selectionKind: "single-model",
|
|
1427
|
+
protocolPreference: "chat_completions",
|
|
1428
|
+
defaultModel: "gpt-4",
|
|
1429
|
+
},
|
|
1430
|
+
hermes: {
|
|
1431
|
+
selectionKind: "single-model",
|
|
1432
|
+
protocolPreference: "chat_completions",
|
|
1433
|
+
defaultModel: "gpt-4",
|
|
1434
|
+
},
|
|
1435
|
+
},
|
|
510
1436
|
home: PROVIDER_HOME
|
|
511
1437
|
});
|
|
512
1438
|
|
|
@@ -517,13 +1443,89 @@ describe("Provider install planning", () => {
|
|
|
517
1443
|
expect(fs.readFileSync(path.join(PROVIDER_HOME, ".codex", "config.toml"), "utf8")).toBe(codexBefore);
|
|
518
1444
|
});
|
|
519
1445
|
|
|
1446
|
+
test("reports installed-only providers when executable or native config hints exist", () => {
|
|
1447
|
+
fs.rmSync(path.join(PROVIDER_HOME, ".openclaw", "config.json"), { force: true });
|
|
1448
|
+
fs.writeFileSync(path.join(PROVIDER_HOME, ".openclaw", "openclaw.json"), JSON.stringify({ profile: "default" }, null, 2), "utf8");
|
|
1449
|
+
fs.mkdirSync(path.join(PROVIDER_HOME, ".hermes"), { recursive: true });
|
|
1450
|
+
fs.writeFileSync(path.join(PROVIDER_HOME, ".hermes", "config.yaml"), "model: gpt-4\n", "utf8");
|
|
1451
|
+
|
|
1452
|
+
const providers = detectProviders({ home: PROVIDER_HOME });
|
|
1453
|
+
expect(providers).toEqual(expect.arrayContaining([
|
|
1454
|
+
expect.objectContaining({
|
|
1455
|
+
id: "openclaw",
|
|
1456
|
+
status: "installed",
|
|
1457
|
+
configured: false,
|
|
1458
|
+
executablePath: expect.stringContaining(path.join("provider-bin", "openclaw")),
|
|
1459
|
+
observedPaths: expect.arrayContaining([path.join(PROVIDER_HOME, ".openclaw", "openclaw.json")]),
|
|
1460
|
+
}),
|
|
1461
|
+
expect.objectContaining({
|
|
1462
|
+
id: "hermes",
|
|
1463
|
+
status: "installed",
|
|
1464
|
+
configured: false,
|
|
1465
|
+
executablePath: expect.stringContaining(path.join("provider-bin", "hermes")),
|
|
1466
|
+
observedPaths: expect.arrayContaining([path.join(PROVIDER_HOME, ".hermes", "config.yaml")]),
|
|
1467
|
+
}),
|
|
1468
|
+
]));
|
|
1469
|
+
});
|
|
1470
|
+
|
|
520
1471
|
test("applies provider config and rolls back existing and created files", () => {
|
|
521
1472
|
const store = new BuyerStore({ root: PROVIDER_STORE_ROOT });
|
|
522
1473
|
try {
|
|
523
1474
|
const applied = applyProviderInstall({
|
|
524
|
-
providers: ["codex", "claude-code", "openclaw", "hermes"],
|
|
1475
|
+
providers: ["codex", "claude-code", "openclaw", "opencode", "hermes"],
|
|
525
1476
|
proxyUrl,
|
|
526
|
-
|
|
1477
|
+
providerSelections: {
|
|
1478
|
+
codex: {
|
|
1479
|
+
selectionKind: "single-model",
|
|
1480
|
+
protocolPreference: "responses",
|
|
1481
|
+
defaultModel: "gpt-4",
|
|
1482
|
+
sellerId: "seller-one",
|
|
1483
|
+
},
|
|
1484
|
+
"claude-code": {
|
|
1485
|
+
selectionKind: "claude-role-mapping",
|
|
1486
|
+
protocolPreference: "messages",
|
|
1487
|
+
fallbackModel: "MiniMax-M2.7-highspeed",
|
|
1488
|
+
roles: {
|
|
1489
|
+
sonnet: {
|
|
1490
|
+
upstreamModel: "MiniMax-M2.7-highspeed",
|
|
1491
|
+
displayName: "MiniMax-M2.7-highspeed",
|
|
1492
|
+
declareOneM: true,
|
|
1493
|
+
},
|
|
1494
|
+
opus: {
|
|
1495
|
+
upstreamModel: "MiniMax-M2.7-highspeed",
|
|
1496
|
+
displayName: "MiniMax-M2.7-highspeed",
|
|
1497
|
+
declareOneM: true,
|
|
1498
|
+
},
|
|
1499
|
+
haiku: {
|
|
1500
|
+
upstreamModel: "MiniMax-M2.7-highspeed",
|
|
1501
|
+
displayName: "MiniMax-M2.7-highspeed",
|
|
1502
|
+
declareOneM: false,
|
|
1503
|
+
},
|
|
1504
|
+
},
|
|
1505
|
+
},
|
|
1506
|
+
openclaw: {
|
|
1507
|
+
selectionKind: "single-model",
|
|
1508
|
+
protocolPreference: "chat_completions",
|
|
1509
|
+
defaultModel: "gpt-4",
|
|
1510
|
+
sellerId: "seller-one",
|
|
1511
|
+
},
|
|
1512
|
+
opencode: {
|
|
1513
|
+
selectionKind: "single-model",
|
|
1514
|
+
protocolPreference: "responses",
|
|
1515
|
+
defaultModel: "gpt-4",
|
|
1516
|
+
sellerId: "seller-one",
|
|
1517
|
+
},
|
|
1518
|
+
hermes: {
|
|
1519
|
+
selectionKind: "single-model",
|
|
1520
|
+
protocolPreference: "chat_completions",
|
|
1521
|
+
defaultModel: "gpt-4",
|
|
1522
|
+
sellerId: "seller-one",
|
|
1523
|
+
},
|
|
1524
|
+
},
|
|
1525
|
+
sellerRouting: {
|
|
1526
|
+
mode: "fixed",
|
|
1527
|
+
sellerId: "seller-one",
|
|
1528
|
+
},
|
|
527
1529
|
home: PROVIDER_HOME
|
|
528
1530
|
}, store);
|
|
529
1531
|
expect(applied).toEqual(expect.arrayContaining([
|
|
@@ -539,15 +1541,30 @@ describe("Provider install planning", () => {
|
|
|
539
1541
|
const claude = JSON.parse(fs.readFileSync(path.join(PROVIDER_HOME, ".claude", "settings.json"), "utf8"));
|
|
540
1542
|
expect(claude.theme).toBe("dark");
|
|
541
1543
|
expect(claude.env.ANTHROPIC_BASE_URL).toBe(proxyUrl);
|
|
1544
|
+
expect(claude.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe("claude-sonnet-4-6[1M]");
|
|
1545
|
+
expect(claude.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe("claude-opus-4-7[1M]");
|
|
1546
|
+
expect(claude.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe("claude-haiku-4-5");
|
|
1547
|
+
expect(claude.env.ANTHROPIC_DEFAULT_SONNET_MODEL_NAME).toBe("MiniMax-M2.7-highspeed");
|
|
1548
|
+
expect(store.getProviderRuntimeConfig("claude-code")).toBeDefined();
|
|
1549
|
+
expect(store.getDaemonRuntimeConfig("routing")).toMatchObject({
|
|
1550
|
+
config: expect.objectContaining({
|
|
1551
|
+
mode: "fixed",
|
|
1552
|
+
sellerId: "seller-one",
|
|
1553
|
+
}),
|
|
1554
|
+
});
|
|
542
1555
|
|
|
543
1556
|
const openclaw = JSON.parse(fs.readFileSync(path.join(PROVIDER_HOME, ".openclaw", "config.json"), "utf8"));
|
|
544
1557
|
expect(openclaw.keep).toBe("field");
|
|
545
1558
|
expect(openclaw.api_url).toBe(proxyUrl);
|
|
1559
|
+
const opencode = JSON.parse(fs.readFileSync(path.join(PROVIDER_HOME, ".config", "opencode", "opencode.json"), "utf8"));
|
|
1560
|
+
expect(opencode.share).toBe("disabled");
|
|
1561
|
+
expect(opencode.provider.tokenbuddy.options.baseURL).toBe(`${proxyUrl}/v1`);
|
|
1562
|
+
expect(opencode.provider.tokenbuddy.models["gpt-4"].name).toBe("gpt-4");
|
|
546
1563
|
expect(fs.existsSync(path.join(PROVIDER_HOME, ".hermes", "settings.json"))).toBe(true);
|
|
547
1564
|
expect(store.getProviderInstallSnapshot("codex")).toBeDefined();
|
|
548
1565
|
|
|
549
1566
|
const rolledBack = rollbackProviderInstall({
|
|
550
|
-
providers: ["codex", "claude-code", "openclaw", "hermes"],
|
|
1567
|
+
providers: ["codex", "claude-code", "openclaw", "opencode", "hermes"],
|
|
551
1568
|
home: PROVIDER_HOME
|
|
552
1569
|
}, store);
|
|
553
1570
|
|
|
@@ -557,8 +1574,11 @@ describe("Provider install planning", () => {
|
|
|
557
1574
|
]));
|
|
558
1575
|
expect(fs.readFileSync(path.join(PROVIDER_HOME, ".codex", "config.toml"), "utf8")).toBe("approval_policy = \"never\"\n");
|
|
559
1576
|
expect(JSON.parse(fs.readFileSync(path.join(PROVIDER_HOME, ".openclaw", "config.json"), "utf8"))).toEqual({ keep: "field" });
|
|
1577
|
+
expect(JSON.parse(fs.readFileSync(path.join(PROVIDER_HOME, ".config", "opencode", "opencode.json"), "utf8"))).toEqual({ share: "disabled" });
|
|
560
1578
|
expect(fs.existsSync(path.join(PROVIDER_HOME, ".hermes", "settings.json"))).toBe(false);
|
|
561
1579
|
expect(store.getProviderInstallSnapshot("codex")).toBeUndefined();
|
|
1580
|
+
expect(store.getProviderRuntimeConfig("claude-code")).toBeUndefined();
|
|
1581
|
+
expect(store.getDaemonRuntimeConfig("routing")).toBeUndefined();
|
|
562
1582
|
} finally {
|
|
563
1583
|
store.close();
|
|
564
1584
|
}
|
|
@@ -570,9 +1590,11 @@ describe("TokenBuddy CLI and Daemon Integration Tests", () => {
|
|
|
570
1590
|
let mockSellerServer: http.Server;
|
|
571
1591
|
let sellerReqCount = 0;
|
|
572
1592
|
let completeReqCount = 0;
|
|
1593
|
+
let balanceReqCount = 0;
|
|
573
1594
|
let mockSellerPort: number;
|
|
574
1595
|
let daemonControlPort: number;
|
|
575
1596
|
let daemonProxyPort: number;
|
|
1597
|
+
const insufficientFundsAttempts = new Map<string, number>();
|
|
576
1598
|
const sellerRequests: Array<{
|
|
577
1599
|
url?: string;
|
|
578
1600
|
authorization?: string;
|
|
@@ -591,6 +1613,25 @@ describe("TokenBuddy CLI and Daemon Integration Tests", () => {
|
|
|
591
1613
|
});
|
|
592
1614
|
});
|
|
593
1615
|
|
|
1616
|
+
function setSettlementHeader(res: http.ServerResponse, requestId: string, settledMicros: number, remainingCreditMicros: number): void {
|
|
1617
|
+
res.setHeader("X-TokenBuddy-Settlement", JSON.stringify({
|
|
1618
|
+
requestId,
|
|
1619
|
+
request_id: requestId,
|
|
1620
|
+
settledMicros,
|
|
1621
|
+
settled_micros: settledMicros,
|
|
1622
|
+
settledUsdMicros: settledMicros,
|
|
1623
|
+
settled_usd_micros: settledMicros,
|
|
1624
|
+
remainingCreditMicros,
|
|
1625
|
+
remaining_credit_micros: remainingCreditMicros,
|
|
1626
|
+
reservedBalanceMicros: 0,
|
|
1627
|
+
reserved_balance_micros: 0,
|
|
1628
|
+
spentMicros: settledMicros,
|
|
1629
|
+
spent_micros: settledMicros,
|
|
1630
|
+
priceVersion: "openrouter_usd.v1",
|
|
1631
|
+
price_version: "openrouter_usd.v1"
|
|
1632
|
+
}));
|
|
1633
|
+
}
|
|
1634
|
+
|
|
594
1635
|
beforeAll((done) => {
|
|
595
1636
|
mockSellerServer = http.createServer(async (req, res) => {
|
|
596
1637
|
res.setHeader("Content-Type", "application/json");
|
|
@@ -674,6 +1715,22 @@ describe("TokenBuddy CLI and Daemon Integration Tests", () => {
|
|
|
674
1715
|
return;
|
|
675
1716
|
}
|
|
676
1717
|
|
|
1718
|
+
if (req.url === "/v1/balance") {
|
|
1719
|
+
balanceReqCount++;
|
|
1720
|
+
sellerRequests.push({
|
|
1721
|
+
url: req.url,
|
|
1722
|
+
authorization: req.headers.authorization
|
|
1723
|
+
});
|
|
1724
|
+
res.end(JSON.stringify({
|
|
1725
|
+
tokenId: "cred_mock",
|
|
1726
|
+
creditMicros: 1000,
|
|
1727
|
+
reservedMicros: 0,
|
|
1728
|
+
spentMicros: 1999000,
|
|
1729
|
+
currency: "Micros"
|
|
1730
|
+
}));
|
|
1731
|
+
return;
|
|
1732
|
+
}
|
|
1733
|
+
|
|
677
1734
|
if (req.url === "/v1/chat/completions") {
|
|
678
1735
|
const body = await readJsonBody(req);
|
|
679
1736
|
sellerRequests.push({
|
|
@@ -682,12 +1739,37 @@ describe("TokenBuddy CLI and Daemon Integration Tests", () => {
|
|
|
682
1739
|
idempotencyKey: req.headers["idempotency-key"] as string | undefined,
|
|
683
1740
|
body
|
|
684
1741
|
});
|
|
1742
|
+
if (body.requestId === "chat_req_402_retry") {
|
|
1743
|
+
const attempts = insufficientFundsAttempts.get(body.requestId) || 0;
|
|
1744
|
+
insufficientFundsAttempts.set(body.requestId, attempts + 1);
|
|
1745
|
+
if (attempts === 0) {
|
|
1746
|
+
res.statusCode = 402;
|
|
1747
|
+
res.end(JSON.stringify({
|
|
1748
|
+
error: {
|
|
1749
|
+
code: "insufficient_funds",
|
|
1750
|
+
message: "Insufficient funds"
|
|
1751
|
+
}
|
|
1752
|
+
}));
|
|
1753
|
+
return;
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
685
1756
|
if (body.stream) {
|
|
686
1757
|
res.writeHead(200, { "Content-Type": "text/event-stream" });
|
|
687
1758
|
res.write("data: {\"id\":\"chatcmpl-stream\",\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\n\n");
|
|
1759
|
+
res.write("event: tokenbuddy.settlement\n");
|
|
1760
|
+
res.write(`data: ${JSON.stringify({
|
|
1761
|
+
requestId: body.requestId || "stream_req_mock",
|
|
1762
|
+
settledMicros: 110,
|
|
1763
|
+
settledUsdMicros: 110,
|
|
1764
|
+
remainingCreditMicros: 1999890,
|
|
1765
|
+
reservedBalanceMicros: 0,
|
|
1766
|
+
spentMicros: 110,
|
|
1767
|
+
priceVersion: "openrouter_usd.v1"
|
|
1768
|
+
})}\n\n`);
|
|
688
1769
|
res.end("data: [DONE]\n\n");
|
|
689
1770
|
return;
|
|
690
1771
|
}
|
|
1772
|
+
setSettlementHeader(res, body.requestId || "chat_req_mock", 110, 1999890);
|
|
691
1773
|
res.end(JSON.stringify({
|
|
692
1774
|
id: "chatcmpl-mock",
|
|
693
1775
|
usage: { prompt_tokens: 10, completion_tokens: 10 }
|
|
@@ -703,6 +1785,30 @@ describe("TokenBuddy CLI and Daemon Integration Tests", () => {
|
|
|
703
1785
|
idempotencyKey: req.headers["idempotency-key"] as string | undefined,
|
|
704
1786
|
body
|
|
705
1787
|
});
|
|
1788
|
+
if (body.requestId === "responses_req_stream_shape") {
|
|
1789
|
+
res.writeHead(200, { "Content-Type": "text/event-stream" });
|
|
1790
|
+
res.write('event: response.created\ndata: {"type":"response.created","response":{"id":"resp_stream_shape","object":"response","model":"gpt-4.1-mini","status":"in_progress","output":[]}}\n\n');
|
|
1791
|
+
res.write('event: response.output_item.added\ndata: {"type":"response.output_item.added","item":{"type":"message","id":"item_stream_shape","role":"assistant","status":"in_progress"},"sequence_number":1}\n\n');
|
|
1792
|
+
res.write('event: response.output_text.delta\ndata: {"type":"response.output_text.delta","delta":"hello","item_id":"item_stream_shape","sequence_number":2}\n\n');
|
|
1793
|
+
res.write('event: response.output_text.done\ndata: {"type":"response.output_text.done","item_id":"item_stream_shape","sequence_number":3}\n\n');
|
|
1794
|
+
res.write('event: response.output_item.done\ndata: {"type":"response.output_item.done","item":{"type":"message","id":"item_stream_shape","status":"completed"},"sequence_number":4}\n\n');
|
|
1795
|
+
res.end('event: response.completed\ndata: {"type":"response.completed","response":{"id":"resp_stream_shape","object":"response","model":"gpt-4.1-mini","status":"completed","output":[],"usage":{"input_tokens":10,"output_tokens":1,"total_tokens":11}},"sequence_number":5}\n\n');
|
|
1796
|
+
return;
|
|
1797
|
+
}
|
|
1798
|
+
if (body.requestId === "responses_req_br") {
|
|
1799
|
+
const compressed = zlib.brotliCompressSync(Buffer.from(JSON.stringify({
|
|
1800
|
+
id: "resp-br",
|
|
1801
|
+
usage: { input_tokens: 7, output_tokens: 9 }
|
|
1802
|
+
})));
|
|
1803
|
+
res.writeHead(200, {
|
|
1804
|
+
"Content-Type": "application/json",
|
|
1805
|
+
"Content-Encoding": "br",
|
|
1806
|
+
"Content-Length": compressed.byteLength
|
|
1807
|
+
});
|
|
1808
|
+
res.end(compressed);
|
|
1809
|
+
return;
|
|
1810
|
+
}
|
|
1811
|
+
setSettlementHeader(res, body.requestId || "responses_req_mock", 64, 1999936);
|
|
706
1812
|
res.end(JSON.stringify({
|
|
707
1813
|
id: "resp-mock",
|
|
708
1814
|
usage: { input_tokens: 7, output_tokens: 9 }
|
|
@@ -718,6 +1824,7 @@ describe("TokenBuddy CLI and Daemon Integration Tests", () => {
|
|
|
718
1824
|
idempotencyKey: req.headers["idempotency-key"] as string | undefined,
|
|
719
1825
|
body
|
|
720
1826
|
});
|
|
1827
|
+
setSettlementHeader(res, body.requestId || "messages_req_mock", 44, 1999956);
|
|
721
1828
|
res.end(JSON.stringify({
|
|
722
1829
|
id: "msg-mock",
|
|
723
1830
|
usage: { input_tokens: 5, output_tokens: 6 }
|
|
@@ -742,6 +1849,8 @@ describe("TokenBuddy CLI and Daemon Integration Tests", () => {
|
|
|
742
1849
|
rmSqliteFiles(TEMP_BUYER_DB);
|
|
743
1850
|
sellerReqCount = 0;
|
|
744
1851
|
completeReqCount = 0;
|
|
1852
|
+
balanceReqCount = 0;
|
|
1853
|
+
insufficientFundsAttempts.clear();
|
|
745
1854
|
sellerRequests.length = 0;
|
|
746
1855
|
const seedStore = new BuyerStore({ dbPath: TEMP_BUYER_DB });
|
|
747
1856
|
seedStore.savePayment({
|
|
@@ -772,6 +1881,28 @@ describe("TokenBuddy CLI and Daemon Integration Tests", () => {
|
|
|
772
1881
|
prompt: "raw control prompt",
|
|
773
1882
|
response: "raw control response"
|
|
774
1883
|
});
|
|
1884
|
+
seedStore.saveProviderRuntimeConfig("claude-code", {
|
|
1885
|
+
selectionKind: "claude-role-mapping",
|
|
1886
|
+
protocolPreference: "messages",
|
|
1887
|
+
fallbackModel: "claude-3-5-sonnet",
|
|
1888
|
+
roles: {
|
|
1889
|
+
sonnet: {
|
|
1890
|
+
upstreamModel: "claude-3-5-sonnet",
|
|
1891
|
+
displayName: "Claude Sonnet Mock",
|
|
1892
|
+
declareOneM: true
|
|
1893
|
+
},
|
|
1894
|
+
opus: {
|
|
1895
|
+
upstreamModel: "claude-3-5-sonnet",
|
|
1896
|
+
displayName: "Claude Opus Mock",
|
|
1897
|
+
declareOneM: true
|
|
1898
|
+
},
|
|
1899
|
+
haiku: {
|
|
1900
|
+
upstreamModel: "claude-3-5-sonnet",
|
|
1901
|
+
displayName: "Claude Haiku Mock",
|
|
1902
|
+
declareOneM: false
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
});
|
|
775
1906
|
seedStore.close();
|
|
776
1907
|
|
|
777
1908
|
daemon = new TokenbuddyDaemon({
|
|
@@ -907,11 +2038,75 @@ describe("TokenBuddy CLI and Daemon Integration Tests", () => {
|
|
|
907
2038
|
const inferences = await (await fetch(`${controlUrl}/ledger/inferences`)).json() as any;
|
|
908
2039
|
expect(purchases.purchases.some((entry: any) => entry.purchaseId === "pur_mock_123" && entry.paymentMethod === "mock")).toBe(true);
|
|
909
2040
|
expect(inferences.inferences.filter((entry: any) => entry.endpoint === "/v1/chat/completions")).toHaveLength(11);
|
|
2041
|
+
const chatLedgers = inferences.inferences.filter((entry: any) => entry.requestId === "chat_req_parallel");
|
|
2042
|
+
expect(chatLedgers).toHaveLength(10);
|
|
2043
|
+
expect(chatLedgers).toEqual(expect.arrayContaining([
|
|
2044
|
+
expect.objectContaining({
|
|
2045
|
+
billedMicros: 110,
|
|
2046
|
+
estimatedMicros: 80,
|
|
2047
|
+
settledMicros: 110,
|
|
2048
|
+
settledUsdMicros: 110,
|
|
2049
|
+
priceVersion: "openrouter_usd.v1",
|
|
2050
|
+
balanceSnapshotMicros: 1999890,
|
|
2051
|
+
balanceSource: "seller_authoritative"
|
|
2052
|
+
})
|
|
2053
|
+
]));
|
|
2054
|
+
expect(balanceReqCount).toBe(0);
|
|
2055
|
+
const store = new BuyerStore({ dbPath: TEMP_BUYER_DB });
|
|
2056
|
+
try {
|
|
2057
|
+
expect(store.getToken("mock-seller")).toMatchObject({
|
|
2058
|
+
balanceMicros: 1999890,
|
|
2059
|
+
reservedMicros: 0,
|
|
2060
|
+
spentMicros: 110,
|
|
2061
|
+
balanceSource: "seller_settlement_summary"
|
|
2062
|
+
});
|
|
2063
|
+
} finally {
|
|
2064
|
+
store.close();
|
|
2065
|
+
}
|
|
910
2066
|
const publicOutput = JSON.stringify({ purchases, inferences });
|
|
911
2067
|
expect(publicOutput).not.toContain("raw concurrent prompt secret");
|
|
912
2068
|
expect(publicOutput).not.toContain("tok_mock_token_abc");
|
|
913
2069
|
});
|
|
914
2070
|
|
|
2071
|
+
test("refreshes balance, auto-purchases, and retries once after seller 402 insufficient funds", async () => {
|
|
2072
|
+
const store = new BuyerStore({ dbPath: TEMP_BUYER_DB });
|
|
2073
|
+
store.saveToken("mock-seller", "tok_existing_high_cache", "model:gpt-4", 900000, "2030-01-01T00:00:00.000Z");
|
|
2074
|
+
store.close();
|
|
2075
|
+
|
|
2076
|
+
const response = await fetch(`http://127.0.0.1:${daemonProxyPort}/v1/chat/completions`, {
|
|
2077
|
+
method: "POST",
|
|
2078
|
+
headers: {
|
|
2079
|
+
"Content-Type": "application/json",
|
|
2080
|
+
"Idempotency-Key": "idem-402-retry"
|
|
2081
|
+
},
|
|
2082
|
+
body: JSON.stringify({
|
|
2083
|
+
model: "gpt-4",
|
|
2084
|
+
messages: [{ role: "user", content: "trigger 402" }],
|
|
2085
|
+
requestId: "chat_req_402_retry"
|
|
2086
|
+
})
|
|
2087
|
+
});
|
|
2088
|
+
|
|
2089
|
+
expect(response.ok).toBe(true);
|
|
2090
|
+
expect((await response.json() as any).id).toBe("chatcmpl-mock");
|
|
2091
|
+
expect(balanceReqCount).toBe(1);
|
|
2092
|
+
expect(sellerRequests.filter((request) => request.url === "/v1/chat/completions" && request.body?.requestId === "chat_req_402_retry")).toHaveLength(2);
|
|
2093
|
+
expect(sellerRequests.filter((request) => request.url === "/purchase/create")).toHaveLength(1);
|
|
2094
|
+
expect(sellerRequests.filter((request) => request.url === "/purchase/complete")).toHaveLength(1);
|
|
2095
|
+
expect(sellerRequests.filter((request) => request.url === "/v1/chat/completions" && request.body?.requestId === "chat_req_402_retry")
|
|
2096
|
+
.every((request) => request.idempotencyKey === "idem-402-retry")).toBe(true);
|
|
2097
|
+
|
|
2098
|
+
const inferences = await (await fetch(`http://127.0.0.1:${daemonControlPort}/ledger/inferences`)).json() as any;
|
|
2099
|
+
expect(inferences.inferences).toEqual(expect.arrayContaining([
|
|
2100
|
+
expect.objectContaining({
|
|
2101
|
+
requestId: "chat_req_402_retry",
|
|
2102
|
+
billedMicros: 110,
|
|
2103
|
+
estimatedMicros: 80,
|
|
2104
|
+
settledMicros: 110,
|
|
2105
|
+
balanceSource: "seller_authoritative"
|
|
2106
|
+
})
|
|
2107
|
+
]));
|
|
2108
|
+
});
|
|
2109
|
+
|
|
915
2110
|
test("proxies models, responses, and anthropic message endpoints through compatible seller manifests", async () => {
|
|
916
2111
|
const proxyUrl = `http://127.0.0.1:${daemonProxyPort}`;
|
|
917
2112
|
|
|
@@ -954,6 +2149,19 @@ describe("TokenBuddy CLI and Daemon Integration Tests", () => {
|
|
|
954
2149
|
expect(largeResponses.ok).toBe(true);
|
|
955
2150
|
expect((await largeResponses.json() as any).id).toBe("resp-mock");
|
|
956
2151
|
|
|
2152
|
+
const compressedResponses = await fetch(`${proxyUrl}/v1/responses`, {
|
|
2153
|
+
method: "POST",
|
|
2154
|
+
headers: { "Content-Type": "application/json" },
|
|
2155
|
+
body: JSON.stringify({
|
|
2156
|
+
model: "gpt-4.1-mini",
|
|
2157
|
+
input: "raw compressed responses prompt secret",
|
|
2158
|
+
requestId: "responses_req_br"
|
|
2159
|
+
})
|
|
2160
|
+
});
|
|
2161
|
+
expect(compressedResponses.ok).toBe(true);
|
|
2162
|
+
expect(compressedResponses.headers.get("content-encoding")).toBeNull();
|
|
2163
|
+
expect((await compressedResponses.json() as any).id).toBe("resp-br");
|
|
2164
|
+
|
|
957
2165
|
for (const endpoint of ["/v1/messages", "/messages"]) {
|
|
958
2166
|
const message = await fetch(`${proxyUrl}${endpoint}`, {
|
|
959
2167
|
method: "POST",
|
|
@@ -985,6 +2193,30 @@ describe("TokenBuddy CLI and Daemon Integration Tests", () => {
|
|
|
985
2193
|
expect(publicOutput).not.toContain("raw anthropic prompt secret");
|
|
986
2194
|
});
|
|
987
2195
|
|
|
2196
|
+
test("maps Claude role aliases to upstream models before message routing", async () => {
|
|
2197
|
+
for (const model of ["sonnet", "claude-sonnet-4-6[1M]"]) {
|
|
2198
|
+
const message = await fetch(`http://127.0.0.1:${daemonProxyPort}/v1/messages`, {
|
|
2199
|
+
method: "POST",
|
|
2200
|
+
headers: { "Content-Type": "application/json" },
|
|
2201
|
+
body: JSON.stringify({
|
|
2202
|
+
model,
|
|
2203
|
+
messages: [{ role: "user", content: "role mapping request" }]
|
|
2204
|
+
})
|
|
2205
|
+
});
|
|
2206
|
+
expect(message.ok).toBe(true);
|
|
2207
|
+
expect((await message.json() as any).id).toBe("msg-mock");
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
const messageRequests = sellerRequests.filter((request) => request.url === "/v1/messages");
|
|
2211
|
+
expect(messageRequests).toEqual(expect.arrayContaining([
|
|
2212
|
+
expect.objectContaining({
|
|
2213
|
+
body: expect.objectContaining({
|
|
2214
|
+
model: "claude-3-5-sonnet"
|
|
2215
|
+
})
|
|
2216
|
+
})
|
|
2217
|
+
]));
|
|
2218
|
+
});
|
|
2219
|
+
|
|
988
2220
|
test("passes through streaming chat responses and records safe ledger metadata", async () => {
|
|
989
2221
|
const response = await fetch(`http://127.0.0.1:${daemonProxyPort}/v1/chat/completions`, {
|
|
990
2222
|
method: "POST",
|
|
@@ -1005,17 +2237,48 @@ describe("TokenBuddy CLI and Daemon Integration Tests", () => {
|
|
|
1005
2237
|
const body = await response.text();
|
|
1006
2238
|
expect(body).toContain("chatcmpl-stream");
|
|
1007
2239
|
expect(body).toContain("[DONE]");
|
|
2240
|
+
expect(body).not.toContain("tokenbuddy.settlement");
|
|
1008
2241
|
expect(sellerRequests.find((request) => request.url === "/v1/chat/completions" && request.body?.stream)?.idempotencyKey).toBe("idem-stream-preserved");
|
|
1009
2242
|
|
|
1010
2243
|
const inferences = await (await fetch(`http://127.0.0.1:${daemonControlPort}/ledger/inferences`)).json() as any;
|
|
1011
2244
|
expect(inferences.inferences).toEqual(expect.arrayContaining([
|
|
1012
|
-
expect.objectContaining({
|
|
2245
|
+
expect.objectContaining({
|
|
2246
|
+
endpoint: "/v1/chat/completions",
|
|
2247
|
+
requestId: "stream_req_1",
|
|
2248
|
+
status: "settled",
|
|
2249
|
+
billedMicros: 110,
|
|
2250
|
+
settledMicros: 110,
|
|
2251
|
+
settledUsdMicros: 110,
|
|
2252
|
+
balanceSource: "seller_authoritative"
|
|
2253
|
+
})
|
|
1013
2254
|
]));
|
|
1014
2255
|
const publicOutput = JSON.stringify(inferences);
|
|
1015
2256
|
expect(publicOutput).not.toContain("raw stream prompt secret");
|
|
1016
2257
|
expect(publicOutput).not.toContain("chatcmpl-stream");
|
|
1017
2258
|
});
|
|
1018
2259
|
|
|
2260
|
+
test("normalizes responses SSE shape for OpenCode-compatible consumers", async () => {
|
|
2261
|
+
const response = await fetch(`http://127.0.0.1:${daemonProxyPort}/v1/responses`, {
|
|
2262
|
+
method: "POST",
|
|
2263
|
+
headers: { "Content-Type": "application/json" },
|
|
2264
|
+
body: JSON.stringify({
|
|
2265
|
+
model: "gpt-4.1-mini",
|
|
2266
|
+
input: "shape normalization",
|
|
2267
|
+
requestId: "responses_req_stream_shape",
|
|
2268
|
+
stream: true
|
|
2269
|
+
})
|
|
2270
|
+
});
|
|
2271
|
+
|
|
2272
|
+
expect(response.ok).toBe(true);
|
|
2273
|
+
expect(response.headers.get("content-type")).toContain("text/event-stream");
|
|
2274
|
+
const body = await response.text();
|
|
2275
|
+
expect(body).toContain("response.content_part.added");
|
|
2276
|
+
expect(body).toContain("response.content_part.done");
|
|
2277
|
+
expect(body).toContain("\"item_id\":\"item_stream_shape\"");
|
|
2278
|
+
expect(body).toContain("\"output_text\":\"hello\"");
|
|
2279
|
+
expect(body).toContain("\"content\":[{\"type\":\"output_text\",\"text\":\"hello\",\"annotations\":[]}]");
|
|
2280
|
+
});
|
|
2281
|
+
|
|
1019
2282
|
test("fails closed when no compatible seller can serve the requested model", async () => {
|
|
1020
2283
|
const response = await fetch(`http://127.0.0.1:${daemonProxyPort}/v1/chat/completions`, {
|
|
1021
2284
|
method: "POST",
|
|
@@ -1108,6 +2371,41 @@ describe("TokenBuddy manual routing mode", () => {
|
|
|
1108
2371
|
return;
|
|
1109
2372
|
}
|
|
1110
2373
|
|
|
2374
|
+
if (req.url === "/backup/purchase/create") {
|
|
2375
|
+
expect(body.paymentMethod).toBe("mock");
|
|
2376
|
+
events.push({ seller: "backup-seller", url: req.url });
|
|
2377
|
+
res.end(JSON.stringify({
|
|
2378
|
+
purchaseId: "pur_backup_123",
|
|
2379
|
+
status: "pending",
|
|
2380
|
+
creditMicros: 2000000,
|
|
2381
|
+
currency: "USD",
|
|
2382
|
+
expiresAt: new Date(Date.now() + 86400 * 1000).toISOString()
|
|
2383
|
+
}));
|
|
2384
|
+
return;
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2387
|
+
if (req.url === "/backup/purchase/complete") {
|
|
2388
|
+
events.push({ seller: "backup-seller", url: req.url });
|
|
2389
|
+
res.end(JSON.stringify({
|
|
2390
|
+
purchaseId: "pur_backup_123",
|
|
2391
|
+
status: "active",
|
|
2392
|
+
accessToken: "tok_backup_token_abc",
|
|
2393
|
+
tokenClass: "model:gpt-manual",
|
|
2394
|
+
creditMicros: 2000000,
|
|
2395
|
+
currency: "USD"
|
|
2396
|
+
}));
|
|
2397
|
+
return;
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
if (req.url === "/backup/v1/chat/completions") {
|
|
2401
|
+
events.push({ seller: "backup-seller", url: req.url });
|
|
2402
|
+
res.end(JSON.stringify({
|
|
2403
|
+
id: "backup-chat",
|
|
2404
|
+
usage: { prompt_tokens: 4, completion_tokens: 5 }
|
|
2405
|
+
}));
|
|
2406
|
+
return;
|
|
2407
|
+
}
|
|
2408
|
+
|
|
1111
2409
|
if (req.url?.startsWith("/backup/")) {
|
|
1112
2410
|
events.push({ seller: "backup-seller", url: req.url });
|
|
1113
2411
|
res.end(JSON.stringify({ id: "backup-should-not-run" }));
|
|
@@ -1183,4 +2481,41 @@ describe("TokenBuddy manual routing mode", () => {
|
|
|
1183
2481
|
expect(purchases.purchases.some((entry: any) => entry.sellerKey === "backup-seller")).toBe(false);
|
|
1184
2482
|
expect(inferences.inferences.some((entry: any) => entry.sellerKey === "backup-seller")).toBe(false);
|
|
1185
2483
|
});
|
|
2484
|
+
|
|
2485
|
+
test("pins manual routing to the configured seller id when selectedSellerId is set", async () => {
|
|
2486
|
+
daemon.stop();
|
|
2487
|
+
events.length = 0;
|
|
2488
|
+
daemon = new TokenbuddyDaemon({
|
|
2489
|
+
controlPort: 0,
|
|
2490
|
+
proxyPort: 0,
|
|
2491
|
+
dbPath,
|
|
2492
|
+
sellerRegistryUrl: `http://127.0.0.1:${sellerPort}/registry/sellers`,
|
|
2493
|
+
selectionMode: "manual",
|
|
2494
|
+
selectedSellerId: "backup-seller"
|
|
2495
|
+
});
|
|
2496
|
+
daemon.start();
|
|
2497
|
+
daemonControlPort = ((daemon as any).controlServer.address() as AddressInfo).port;
|
|
2498
|
+
daemonProxyPort = ((daemon as any).proxyServer.address() as AddressInfo).port;
|
|
2499
|
+
|
|
2500
|
+
const status = await (await fetch(`http://127.0.0.1:${daemonControlPort}/status`)).json() as any;
|
|
2501
|
+
expect(status.selectionMode).toBe("manual");
|
|
2502
|
+
expect(status.selectedSellerId).toBe("backup-seller");
|
|
2503
|
+
|
|
2504
|
+
const response = await fetch(`http://127.0.0.1:${daemonProxyPort}/v1/chat/completions`, {
|
|
2505
|
+
method: "POST",
|
|
2506
|
+
headers: { "Content-Type": "application/json" },
|
|
2507
|
+
body: JSON.stringify({
|
|
2508
|
+
model: "gpt-manual",
|
|
2509
|
+
messages: [{ role: "user", content: "manual selected seller should stay pinned" }]
|
|
2510
|
+
})
|
|
2511
|
+
});
|
|
2512
|
+
|
|
2513
|
+
expect(response.ok).toBe(true);
|
|
2514
|
+
expect(events).toEqual([
|
|
2515
|
+
{ seller: "backup-seller", url: "/backup/manifest" },
|
|
2516
|
+
{ seller: "backup-seller", url: "/backup/purchase/create" },
|
|
2517
|
+
{ seller: "backup-seller", url: "/backup/purchase/complete" },
|
|
2518
|
+
{ seller: "backup-seller", url: "/backup/v1/chat/completions" }
|
|
2519
|
+
]);
|
|
2520
|
+
});
|
|
1186
2521
|
});
|