@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.
Files changed (56) hide show
  1. package/dist/src/buyer-store.d.ts +48 -1
  2. package/dist/src/buyer-store.d.ts.map +1 -1
  3. package/dist/src/buyer-store.js +144 -17
  4. package/dist/src/buyer-store.js.map +1 -1
  5. package/dist/src/cli.d.ts +17 -0
  6. package/dist/src/cli.d.ts.map +1 -1
  7. package/dist/src/cli.js +560 -63
  8. package/dist/src/cli.js.map +1 -1
  9. package/dist/src/daemon.d.ts +11 -5
  10. package/dist/src/daemon.d.ts.map +1 -1
  11. package/dist/src/daemon.js +574 -161
  12. package/dist/src/daemon.js.map +1 -1
  13. package/dist/src/doctor-clawtip-wallet.d.ts +14 -0
  14. package/dist/src/doctor-clawtip-wallet.d.ts.map +1 -0
  15. package/dist/src/doctor-clawtip-wallet.js +54 -0
  16. package/dist/src/doctor-clawtip-wallet.js.map +1 -0
  17. package/dist/src/doctor-diagnostics.d.ts +99 -0
  18. package/dist/src/doctor-diagnostics.d.ts.map +1 -0
  19. package/dist/src/doctor-diagnostics.js +552 -0
  20. package/dist/src/doctor-diagnostics.js.map +1 -0
  21. package/dist/src/init-clawtip-activation.d.ts +48 -0
  22. package/dist/src/init-clawtip-activation.d.ts.map +1 -0
  23. package/dist/src/init-clawtip-activation.js +395 -0
  24. package/dist/src/init-clawtip-activation.js.map +1 -0
  25. package/dist/src/init-payment-options.d.ts +56 -0
  26. package/dist/src/init-payment-options.d.ts.map +1 -0
  27. package/dist/src/init-payment-options.js +165 -0
  28. package/dist/src/init-payment-options.js.map +1 -0
  29. package/dist/src/provider-install.d.ts +37 -2
  30. package/dist/src/provider-install.d.ts.map +1 -1
  31. package/dist/src/provider-install.js +317 -67
  32. package/dist/src/provider-install.js.map +1 -1
  33. package/dist/src/seller-catalog.d.ts +79 -0
  34. package/dist/src/seller-catalog.d.ts.map +1 -0
  35. package/dist/src/seller-catalog.js +126 -0
  36. package/dist/src/seller-catalog.js.map +1 -0
  37. package/dist/src/tb-proxyd.js +13 -2
  38. package/dist/src/tb-proxyd.js.map +1 -1
  39. package/dist/src/terminal-image.d.ts +22 -0
  40. package/dist/src/terminal-image.d.ts.map +1 -0
  41. package/dist/src/terminal-image.js +135 -0
  42. package/dist/src/terminal-image.js.map +1 -0
  43. package/package.json +1 -1
  44. package/src/buyer-store.ts +253 -18
  45. package/src/cli.ts +709 -68
  46. package/src/daemon.ts +651 -167
  47. package/src/doctor-clawtip-wallet.ts +70 -0
  48. package/src/doctor-diagnostics.ts +861 -0
  49. package/src/init-clawtip-activation.ts +487 -0
  50. package/src/init-payment-options.ts +249 -0
  51. package/src/provider-install.ts +426 -76
  52. package/src/seller-catalog.ts +222 -0
  53. package/src/tb-proxyd.ts +14 -2
  54. package/src/terminal-image.ts +187 -0
  55. package/tests/e2e.test.ts +88 -5
  56. package/tests/tokenbuddy.test.ts +1362 -27
@@ -1,6 +1,19 @@
1
1
  import { TokenbuddyDaemon } from "../src/daemon.js";
2
- import { BuyerStore, resolveBuyerStorePath } from "../src/buyer-store.js";
3
- import { buildCli } from "../src/cli.js";
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")).toEqual({
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.deductBalance("seller-a", 120000);
117
- expect(store.getToken("seller-a")).toEqual({
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: 380000
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: 45678
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
- { id: "gpt-4", sellerId: "json-test-seller" }
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
- controlServer.listen(0, "127.0.0.1", () => {
410
- controlPort = (controlServer.address() as AddressInfo).port;
411
- process.env.TB_PROXYD_CONTROL_PORT = String(controlPort);
412
- process.env.TB_PROXYD_PROXY_PORT = "45678";
413
- done();
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(done);
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: 45678,
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.data).toEqual([
470
- { id: "gpt-4", sellerId: "json-test-seller" }
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", detected: true }),
501
- expect.objectContaining({ id: "claude-code", detected: true }),
502
- expect.objectContaining({ id: "openclaw", detected: true }),
503
- expect.objectContaining({ id: "hermes", detected: false })
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
- model: "gpt-4",
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
- model: "gpt-4",
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({ endpoint: "/v1/chat/completions", requestId: "stream_req_1" })
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
  });