@tokenbuddy/tokenbuddy 1.0.5 → 1.0.6

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 (40) hide show
  1. package/dist/src/buyer-store.d.ts +20 -0
  2. package/dist/src/buyer-store.d.ts.map +1 -1
  3. package/dist/src/buyer-store.js +73 -1
  4. package/dist/src/buyer-store.js.map +1 -1
  5. package/dist/src/cli.d.ts.map +1 -1
  6. package/dist/src/cli.js +390 -62
  7. package/dist/src/cli.js.map +1 -1
  8. package/dist/src/daemon.d.ts +6 -5
  9. package/dist/src/daemon.d.ts.map +1 -1
  10. package/dist/src/daemon.js +298 -92
  11. package/dist/src/daemon.js.map +1 -1
  12. package/dist/src/doctor-diagnostics.d.ts +97 -0
  13. package/dist/src/doctor-diagnostics.d.ts.map +1 -0
  14. package/dist/src/doctor-diagnostics.js +547 -0
  15. package/dist/src/doctor-diagnostics.js.map +1 -0
  16. package/dist/src/init-payment-options.d.ts +34 -0
  17. package/dist/src/init-payment-options.d.ts.map +1 -0
  18. package/dist/src/init-payment-options.js +90 -0
  19. package/dist/src/init-payment-options.js.map +1 -0
  20. package/dist/src/provider-install.d.ts +37 -2
  21. package/dist/src/provider-install.d.ts.map +1 -1
  22. package/dist/src/provider-install.js +317 -67
  23. package/dist/src/provider-install.js.map +1 -1
  24. package/dist/src/seller-catalog.d.ts +79 -0
  25. package/dist/src/seller-catalog.d.ts.map +1 -0
  26. package/dist/src/seller-catalog.js +126 -0
  27. package/dist/src/seller-catalog.js.map +1 -0
  28. package/dist/src/tb-proxyd.js +13 -2
  29. package/dist/src/tb-proxyd.js.map +1 -1
  30. package/package.json +1 -1
  31. package/src/buyer-store.ts +113 -1
  32. package/src/cli.ts +490 -67
  33. package/src/daemon.ts +346 -117
  34. package/src/doctor-diagnostics.ts +850 -0
  35. package/src/init-payment-options.ts +131 -0
  36. package/src/provider-install.ts +426 -76
  37. package/src/seller-catalog.ts +222 -0
  38. package/src/tb-proxyd.ts +14 -2
  39. package/tests/e2e.test.ts +9 -0
  40. package/tests/tokenbuddy.test.ts +628 -19
@@ -8,10 +8,21 @@ import {
8
8
  rollbackProviderInstall
9
9
  } from "../src/provider-install.js";
10
10
  import { detectTerminals } from "../src/terminal-detect.js";
11
+ import {
12
+ buildInitSuccessMessage,
13
+ buildInitTerminalOptions,
14
+ detectExistingClawtipBinding,
15
+ INIT_COMING_SOON_PAYMENT_OPTIONS,
16
+ INIT_PAYMENT_OPTIONS,
17
+ noteInitComingSoonPayments,
18
+ OTHER_TERMINAL_OPTION,
19
+ validateInitTerminalSelection,
20
+ } from "../src/init-payment-options.js";
11
21
  import * as path from "path";
12
22
  import * as fs from "fs";
13
23
  import http from "http";
14
24
  import { AddressInfo } from "net";
25
+ import zlib from "zlib";
15
26
 
16
27
  const TEMP_BUYER_DB = path.resolve(__dirname, "../../data-test/buyer-cache-test.db");
17
28
  const TEMP_STORE_ROOT = path.resolve(__dirname, "../../data-test/buyer-store-test");
@@ -125,6 +136,47 @@ describe("BuyerStore safe SQLite persistence", () => {
125
136
  expect(store.listPendingPurchases()).toEqual([]);
126
137
  expect(store.listPurchaseLedger()).toEqual([]);
127
138
  expect(store.listInferenceLedger()).toEqual([]);
139
+ expect(store.summary()).toMatchObject({
140
+ providerRuntimeConfigCount: 0,
141
+ daemonRuntimeConfigCount: 0,
142
+ });
143
+ });
144
+
145
+ test("stores provider runtime config and daemon routing config in the buyer store", () => {
146
+ store.saveProviderRuntimeConfig("opencode", {
147
+ selectionKind: "single-model",
148
+ protocolPreference: "responses",
149
+ defaultModel: "gpt-5.5",
150
+ sellerId: "seller-a",
151
+ });
152
+ store.saveDaemonRuntimeConfig("routing", {
153
+ mode: "fixed",
154
+ sellerId: "seller-a",
155
+ });
156
+
157
+ expect(store.getProviderRuntimeConfig("opencode")).toMatchObject({
158
+ providerId: "opencode",
159
+ config: expect.objectContaining({
160
+ defaultModel: "gpt-5.5",
161
+ sellerId: "seller-a",
162
+ }),
163
+ });
164
+ expect(store.getDaemonRuntimeConfig("routing")).toMatchObject({
165
+ configKey: "routing",
166
+ config: expect.objectContaining({
167
+ mode: "fixed",
168
+ sellerId: "seller-a",
169
+ }),
170
+ });
171
+ expect(store.summary()).toMatchObject({
172
+ providerRuntimeConfigCount: 1,
173
+ daemonRuntimeConfigCount: 1,
174
+ });
175
+
176
+ expect(store.removeProviderRuntimeConfig("opencode")).toBe(true);
177
+ expect(store.removeDaemonRuntimeConfig("routing")).toBe(true);
178
+ expect(store.getProviderRuntimeConfig("opencode")).toBeUndefined();
179
+ expect(store.getDaemonRuntimeConfig("routing")).toBeUndefined();
128
180
  });
129
181
 
130
182
  test("stores payment config and pending purchases with safe references", () => {
@@ -374,31 +426,201 @@ describe("TokenBuddy payment CLI", () => {
374
426
  });
375
427
  });
376
428
 
429
+ describe("TokenBuddy init payment options", () => {
430
+ test("init hides mock payment and shows coming-soon agent wallets as unavailable", () => {
431
+ const noteMessages: string[] = [];
432
+ noteInitComingSoonPayments((message?: string) => {
433
+ noteMessages.push(String(message || ""));
434
+ });
435
+
436
+ expect(INIT_PAYMENT_OPTIONS).toEqual([
437
+ expect.objectContaining({ value: "clawtip" })
438
+ ]);
439
+ expect(INIT_PAYMENT_OPTIONS.map((option) => option.value)).not.toContain("mock");
440
+
441
+ const notes = noteMessages.join("\n");
442
+ expect(INIT_COMING_SOON_PAYMENT_OPTIONS).toEqual(expect.arrayContaining([
443
+ expect.objectContaining({ id: "wechat-pay", label: "WeChat Pay" }),
444
+ expect.objectContaining({ id: "alipay-agent-payment", label: "Alipay Agent Payment" }),
445
+ expect.objectContaining({ id: "coinbase-smart-wallet", label: "Coinbase Smart Wallet" })
446
+ ]));
447
+ expect(notes).toContain("WeChat Pay(接入中)");
448
+ expect(notes).toContain("Alipay Agent Payment(接入中)");
449
+ expect(notes).toContain("Coinbase Smart Wallet(接入中)");
450
+ });
451
+
452
+ test("detects an existing local clawtip binding from saved payment config", () => {
453
+ const binding = detectExistingClawtipBinding({
454
+ method: "clawtip",
455
+ enabled: false,
456
+ isDefault: false,
457
+ updatedAt: "2026-05-30T00:00:00.000Z",
458
+ config: {
459
+ orderNo: "order_123",
460
+ resourceUrl: "https://example.test/pay"
461
+ }
462
+ });
463
+
464
+ expect(binding).toEqual(expect.objectContaining({
465
+ orderNo: "order_123",
466
+ resourceUrl: "https://example.test/pay",
467
+ config: expect.objectContaining({
468
+ orderNo: "order_123",
469
+ resourceUrl: "https://example.test/pay"
470
+ })
471
+ }));
472
+ });
473
+
474
+ test("builds a clear init success message with summary lines", () => {
475
+ const message = buildInitSuccessMessage([
476
+ "2 programming terminals configured for TokenBuddy.",
477
+ "ClawTip wallet already bound locally; activation skipped."
478
+ ]);
479
+
480
+ expect(message).toContain("✅ TokenBuddy setup completed successfully.");
481
+ expect(message).toContain("- 2 programming terminals configured for TokenBuddy.");
482
+ expect(message).toContain("- ClawTip wallet already bound locally; activation skipped.");
483
+ expect(message).toContain("Run `tb doctor` to audit status anytime.");
484
+ });
485
+
486
+ test("builds terminal options with configured entries marked as already installed", () => {
487
+ const options = buildInitTerminalOptions([
488
+ {
489
+ id: "codex",
490
+ name: "Codex CLI",
491
+ detected: true,
492
+ configured: true,
493
+ status: "configured",
494
+ configPath: "/tmp/codex.toml",
495
+ reason: "Configured at ~/.codex/config.toml"
496
+ },
497
+ {
498
+ id: "hermes",
499
+ name: "Hermes Terminal",
500
+ detected: true,
501
+ configured: false,
502
+ status: "installed",
503
+ configPath: "/tmp/hermes.json",
504
+ reason: "Installed, TokenBuddy config missing"
505
+ }
506
+ ]);
507
+
508
+ expect(options).toEqual([
509
+ expect.objectContaining({
510
+ value: "codex:installed",
511
+ label: "Codex CLI(已安装)"
512
+ }),
513
+ expect.objectContaining({
514
+ value: "hermes",
515
+ label: "Hermes Terminal"
516
+ }),
517
+ expect.objectContaining({
518
+ value: OTHER_TERMINAL_OPTION.value,
519
+ label: OTHER_TERMINAL_OPTION.label
520
+ })
521
+ ]);
522
+ });
523
+
524
+ test("requires at least one actionable terminal choice", () => {
525
+ expect(validateInitTerminalSelection([])).toBe("Select at least one terminal or choose Other.");
526
+ expect(validateInitTerminalSelection(["codex:installed"])).toBe(
527
+ "Installed terminals are already configured. Select another terminal or choose Other."
528
+ );
529
+ expect(validateInitTerminalSelection(["other"])).toBeUndefined();
530
+ expect(validateInitTerminalSelection(["hermes"])).toBeUndefined();
531
+ });
532
+ });
533
+
377
534
  describe("TokenBuddy JSON inspection commands", () => {
378
535
  let controlServer: http.Server;
536
+ let proxyServer: http.Server;
379
537
  let controlPort: number;
538
+ let proxyPort: number;
380
539
  let previousControlPort: string | undefined;
381
540
  let previousProxyPort: string | undefined;
382
541
 
383
542
  beforeEach((done) => {
384
543
  previousControlPort = process.env.TB_PROXYD_CONTROL_PORT;
385
544
  previousProxyPort = process.env.TB_PROXYD_PROXY_PORT;
545
+ proxyServer = http.createServer((req, res) => {
546
+ res.setHeader("Content-Type", "application/json");
547
+ if (req.url === "/v1/models") {
548
+ res.end(JSON.stringify({
549
+ object: "list",
550
+ data: [
551
+ { id: "gpt-4", sellerId: "json-test-seller" }
552
+ ]
553
+ }));
554
+ return;
555
+ }
556
+ res.statusCode = 404;
557
+ res.end(JSON.stringify({ error: "not_found" }));
558
+ });
386
559
  controlServer = http.createServer((req, res) => {
387
560
  res.setHeader("Content-Type", "application/json");
561
+ if (req.url === "/health") {
562
+ res.end(JSON.stringify({
563
+ status: "ok",
564
+ controlPort,
565
+ proxyPort
566
+ }));
567
+ return;
568
+ }
388
569
  if (req.url === "/status") {
389
570
  res.end(JSON.stringify({
390
571
  status: "running",
391
572
  pid: 12345,
392
573
  controlPort,
393
- proxyPort: 45678
574
+ proxyPort
575
+ }));
576
+ return;
577
+ }
578
+ if (req.url === "/sellers") {
579
+ res.end(JSON.stringify({
580
+ registryUrl: "https://example.test/registry/sellers",
581
+ version: 7,
582
+ defaultSeller: "json-test-seller",
583
+ sellers: [
584
+ {
585
+ id: "json-test-seller",
586
+ name: "JSON Seller",
587
+ url: "https://seller.example.test",
588
+ supportedProtocols: ["responses"],
589
+ paymentMethods: ["mock"],
590
+ discountRatio: 0.25,
591
+ status: "configured"
592
+ }
593
+ ]
394
594
  }));
395
595
  return;
396
596
  }
397
597
  if (req.url === "/models") {
398
598
  res.end(JSON.stringify({
399
599
  object: "list",
600
+ registryUrl: "https://example.test/registry/sellers",
400
601
  data: [
401
- { id: "gpt-4", sellerId: "json-test-seller" }
602
+ {
603
+ id: "gpt-4",
604
+ sellerId: "json-test-seller",
605
+ sellerName: "JSON Seller",
606
+ sellerUrl: "https://seller.example.test",
607
+ supportedProtocols: ["responses"],
608
+ paymentMethods: ["mock"],
609
+ inputPriceMicrosPer1m: 1000000,
610
+ outputPriceMicrosPer1m: 3000000
611
+ }
612
+ ],
613
+ sellers: [
614
+ {
615
+ id: "json-test-seller",
616
+ name: "JSON Seller",
617
+ url: "https://seller.example.test",
618
+ supportedProtocols: ["responses"],
619
+ paymentMethods: ["mock"],
620
+ discountRatio: 0.25,
621
+ status: "ok",
622
+ modelCount: 1
623
+ }
402
624
  ]
403
625
  }));
404
626
  return;
@@ -406,11 +628,14 @@ describe("TokenBuddy JSON inspection commands", () => {
406
628
  res.statusCode = 404;
407
629
  res.end(JSON.stringify({ error: "not_found" }));
408
630
  });
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();
631
+ proxyServer.listen(0, "127.0.0.1", () => {
632
+ proxyPort = (proxyServer.address() as AddressInfo).port;
633
+ controlServer.listen(0, "127.0.0.1", () => {
634
+ controlPort = (controlServer.address() as AddressInfo).port;
635
+ process.env.TB_PROXYD_CONTROL_PORT = String(controlPort);
636
+ process.env.TB_PROXYD_PROXY_PORT = String(proxyPort);
637
+ done();
638
+ });
414
639
  });
415
640
  });
416
641
 
@@ -426,7 +651,7 @@ describe("TokenBuddy JSON inspection commands", () => {
426
651
  process.env.TB_PROXYD_PROXY_PORT = previousProxyPort;
427
652
  }
428
653
  jest.restoreAllMocks();
429
- controlServer.close(done);
654
+ controlServer.close(() => proxyServer.close(done));
430
655
  });
431
656
 
432
657
  test("doctor --json reports daemon and provider state", async () => {
@@ -442,7 +667,7 @@ describe("TokenBuddy JSON inspection commands", () => {
442
667
  expect(parsed.daemon).toMatchObject({
443
668
  running: true,
444
669
  controlPort,
445
- proxyPort: 45678,
670
+ proxyPort,
446
671
  fixAvailable: true
447
672
  });
448
673
  expect(parsed.repair).toMatchObject({
@@ -450,12 +675,46 @@ describe("TokenBuddy JSON inspection commands", () => {
450
675
  attempted: false,
451
676
  fixed: false
452
677
  });
678
+ expect(parsed.access).toMatchObject({
679
+ token: "TOKENBUDDY_PROXY",
680
+ controlBaseUrl: `http://127.0.0.1:${controlPort}`,
681
+ proxyBaseUrl: `http://127.0.0.1:${proxyPort}`,
682
+ });
683
+ expect(parsed.access.endpoints).toEqual(expect.arrayContaining([
684
+ expect.objectContaining({ id: "control.health", available: true }),
685
+ expect.objectContaining({ id: "proxy.openai", available: true, token: "TOKENBUDDY_PROXY" })
686
+ ]));
687
+ expect(parsed.sellers).toMatchObject({
688
+ available: true,
689
+ registryUrl: "https://example.test/registry/sellers",
690
+ defaultSeller: "json-test-seller",
691
+ });
692
+ expect(parsed.models).toMatchObject({
693
+ available: true,
694
+ count: 1,
695
+ registryUrl: "https://example.test/registry/sellers",
696
+ });
453
697
  expect(parsed.providers).toEqual(expect.arrayContaining([
454
698
  expect.objectContaining({ id: "codex" }),
455
699
  expect.objectContaining({ id: "claude-code" })
456
700
  ]));
457
701
  });
458
702
 
703
+ test("doctor prints progress messages without repeating the model list", async () => {
704
+ const output: string[] = [];
705
+ jest.spyOn(console, "log").mockImplementation((message?: unknown) => {
706
+ output.push(String(message));
707
+ });
708
+
709
+ await buildCli().parseAsync(["node", "tb", "doctor"]);
710
+
711
+ const joined = output.join("\n");
712
+ expect(joined).toContain("Checking local control plane and proxy endpoints...");
713
+ expect(joined).toContain("Refreshing seller registry...");
714
+ expect(joined).toContain("Model catalog hidden in `tb doctor`. Run `tb models` for the current model summary.");
715
+ expect(joined).not.toContain("Unique models:");
716
+ });
717
+
459
718
  test("models --json returns daemon model data", async () => {
460
719
  const output: string[] = [];
461
720
  jest.spyOn(console, "log").mockImplementation((message?: unknown) => {
@@ -466,47 +725,127 @@ describe("TokenBuddy JSON inspection commands", () => {
466
725
 
467
726
  expect(output).toHaveLength(1);
468
727
  const parsed = JSON.parse(output[0]) as any;
469
- expect(parsed.data).toEqual([
470
- { id: "gpt-4", sellerId: "json-test-seller" }
728
+ expect(parsed.grouped).toEqual([
729
+ expect.objectContaining({
730
+ id: "gpt-4",
731
+ sellerCount: 1,
732
+ discountRange: "0.25",
733
+ priceRange: "in $1 / out $3"
734
+ })
471
735
  ]);
472
736
  });
737
+
738
+ test("models prints the doctor-style grouped model summary", async () => {
739
+ const output: string[] = [];
740
+ jest.spyOn(console, "log").mockImplementation((message?: unknown) => {
741
+ output.push(String(message));
742
+ });
743
+
744
+ await buildCli().parseAsync(["node", "tb", "models"]);
745
+
746
+ const joined = output.join("\n");
747
+ expect(joined).toContain("Model catalog refresh complete.");
748
+ expect(joined).toContain("Unique models: 1");
749
+ expect(joined).toContain("Seller offers: 1");
750
+ expect(joined).toContain("Model ID");
751
+ expect(joined).toContain("Seller Count");
752
+ expect(joined).toContain("Discount Range");
753
+ expect(joined).toContain("Price Range");
754
+ expect(joined).toContain("gpt-4");
755
+ expect(joined).toContain("0.25");
756
+ expect(joined).toContain("$1");
757
+ expect(joined).toContain("$3");
758
+ });
473
759
  });
474
760
 
475
761
  describe("Provider install planning", () => {
476
762
  const PROVIDER_HOME = path.resolve(__dirname, "../../data-test/provider-home");
477
763
  const PROVIDER_STORE_ROOT = path.resolve(__dirname, "../../data-test/provider-store");
764
+ const PROVIDER_BIN_ROOT = path.resolve(__dirname, "../../data-test/provider-bin");
478
765
  const proxyUrl = "http://127.0.0.1:17821";
766
+ let previousPath: string | undefined;
767
+
768
+ function writeExecutable(name: string): void {
769
+ const executablePath = path.join(PROVIDER_BIN_ROOT, name);
770
+ fs.writeFileSync(executablePath, "#!/bin/sh\nexit 0\n", "utf8");
771
+ fs.chmodSync(executablePath, 0o755);
772
+ }
479
773
 
480
774
  beforeEach(() => {
481
775
  rmDir(PROVIDER_HOME);
482
776
  rmDir(PROVIDER_STORE_ROOT);
777
+ rmDir(PROVIDER_BIN_ROOT);
483
778
  fs.mkdirSync(path.join(PROVIDER_HOME, ".codex"), { recursive: true });
484
779
  fs.mkdirSync(path.join(PROVIDER_HOME, ".claude"), { recursive: true });
485
780
  fs.mkdirSync(path.join(PROVIDER_HOME, ".openclaw"), { recursive: true });
781
+ fs.mkdirSync(path.join(PROVIDER_HOME, ".config", "opencode"), { recursive: true });
782
+ fs.mkdirSync(PROVIDER_BIN_ROOT, { recursive: true });
783
+ writeExecutable("codex");
784
+ writeExecutable("claude");
785
+ writeExecutable("openclaw");
786
+ writeExecutable("opencode");
787
+ writeExecutable("hermes");
788
+ previousPath = process.env.PATH;
789
+ process.env.PATH = `${PROVIDER_BIN_ROOT}${path.delimiter}${previousPath || ""}`;
486
790
  fs.writeFileSync(path.join(PROVIDER_HOME, ".codex", "config.toml"), "approval_policy = \"never\"\n", "utf8");
487
791
  fs.writeFileSync(path.join(PROVIDER_HOME, ".claude", "settings.json"), JSON.stringify({ theme: "dark" }, null, 2), "utf8");
488
792
  fs.writeFileSync(path.join(PROVIDER_HOME, ".openclaw", "config.json"), JSON.stringify({ keep: "field" }, null, 2), "utf8");
793
+ fs.writeFileSync(path.join(PROVIDER_HOME, ".config", "opencode", "opencode.json"), JSON.stringify({ share: "disabled" }, null, 2), "utf8");
489
794
  });
490
795
 
491
796
  afterEach(() => {
492
797
  rmDir(PROVIDER_HOME);
493
798
  rmDir(PROVIDER_STORE_ROOT);
799
+ rmDir(PROVIDER_BIN_ROOT);
800
+ if (previousPath === undefined) {
801
+ delete process.env.PATH;
802
+ } else {
803
+ process.env.PATH = previousPath;
804
+ }
494
805
  });
495
806
 
496
807
  test("detects providers and previews without mutating files", () => {
497
808
  const codexBefore = fs.readFileSync(path.join(PROVIDER_HOME, ".codex", "config.toml"), "utf8");
498
809
  const providers = detectProviders({ home: PROVIDER_HOME });
499
810
  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 })
811
+ expect.objectContaining({ id: "codex", status: "configured", configured: true }),
812
+ expect.objectContaining({ id: "claude-code", status: "configured", configured: true }),
813
+ expect.objectContaining({ id: "openclaw", status: "configured", configured: true }),
814
+ expect.objectContaining({ id: "hermes", status: "installed", configured: false })
504
815
  ]));
505
816
 
506
817
  const changes = previewProviderInstall({
507
818
  providers: ["codex", "claude-code", "openclaw", "hermes"],
508
819
  proxyUrl,
509
- model: "gpt-4",
820
+ providerSelections: {
821
+ codex: {
822
+ selectionKind: "single-model",
823
+ protocolPreference: "responses",
824
+ defaultModel: "gpt-4",
825
+ },
826
+ "claude-code": {
827
+ selectionKind: "claude-role-mapping",
828
+ protocolPreference: "messages",
829
+ fallbackModel: "MiniMax-M2.7-highspeed",
830
+ roles: {
831
+ sonnet: {
832
+ upstreamModel: "MiniMax-M2.7-highspeed",
833
+ displayName: "MiniMax-M2.7-highspeed",
834
+ declareOneM: true,
835
+ },
836
+ },
837
+ },
838
+ openclaw: {
839
+ selectionKind: "single-model",
840
+ protocolPreference: "chat_completions",
841
+ defaultModel: "gpt-4",
842
+ },
843
+ hermes: {
844
+ selectionKind: "single-model",
845
+ protocolPreference: "chat_completions",
846
+ defaultModel: "gpt-4",
847
+ },
848
+ },
510
849
  home: PROVIDER_HOME
511
850
  });
512
851
 
@@ -517,13 +856,89 @@ describe("Provider install planning", () => {
517
856
  expect(fs.readFileSync(path.join(PROVIDER_HOME, ".codex", "config.toml"), "utf8")).toBe(codexBefore);
518
857
  });
519
858
 
859
+ test("reports installed-only providers when executable or native config hints exist", () => {
860
+ fs.rmSync(path.join(PROVIDER_HOME, ".openclaw", "config.json"), { force: true });
861
+ fs.writeFileSync(path.join(PROVIDER_HOME, ".openclaw", "openclaw.json"), JSON.stringify({ profile: "default" }, null, 2), "utf8");
862
+ fs.mkdirSync(path.join(PROVIDER_HOME, ".hermes"), { recursive: true });
863
+ fs.writeFileSync(path.join(PROVIDER_HOME, ".hermes", "config.yaml"), "model: gpt-4\n", "utf8");
864
+
865
+ const providers = detectProviders({ home: PROVIDER_HOME });
866
+ expect(providers).toEqual(expect.arrayContaining([
867
+ expect.objectContaining({
868
+ id: "openclaw",
869
+ status: "installed",
870
+ configured: false,
871
+ executablePath: expect.stringContaining(path.join("provider-bin", "openclaw")),
872
+ observedPaths: expect.arrayContaining([path.join(PROVIDER_HOME, ".openclaw", "openclaw.json")]),
873
+ }),
874
+ expect.objectContaining({
875
+ id: "hermes",
876
+ status: "installed",
877
+ configured: false,
878
+ executablePath: expect.stringContaining(path.join("provider-bin", "hermes")),
879
+ observedPaths: expect.arrayContaining([path.join(PROVIDER_HOME, ".hermes", "config.yaml")]),
880
+ }),
881
+ ]));
882
+ });
883
+
520
884
  test("applies provider config and rolls back existing and created files", () => {
521
885
  const store = new BuyerStore({ root: PROVIDER_STORE_ROOT });
522
886
  try {
523
887
  const applied = applyProviderInstall({
524
- providers: ["codex", "claude-code", "openclaw", "hermes"],
888
+ providers: ["codex", "claude-code", "openclaw", "opencode", "hermes"],
525
889
  proxyUrl,
526
- model: "gpt-4",
890
+ providerSelections: {
891
+ codex: {
892
+ selectionKind: "single-model",
893
+ protocolPreference: "responses",
894
+ defaultModel: "gpt-4",
895
+ sellerId: "seller-one",
896
+ },
897
+ "claude-code": {
898
+ selectionKind: "claude-role-mapping",
899
+ protocolPreference: "messages",
900
+ fallbackModel: "MiniMax-M2.7-highspeed",
901
+ roles: {
902
+ sonnet: {
903
+ upstreamModel: "MiniMax-M2.7-highspeed",
904
+ displayName: "MiniMax-M2.7-highspeed",
905
+ declareOneM: true,
906
+ },
907
+ opus: {
908
+ upstreamModel: "MiniMax-M2.7-highspeed",
909
+ displayName: "MiniMax-M2.7-highspeed",
910
+ declareOneM: true,
911
+ },
912
+ haiku: {
913
+ upstreamModel: "MiniMax-M2.7-highspeed",
914
+ displayName: "MiniMax-M2.7-highspeed",
915
+ declareOneM: false,
916
+ },
917
+ },
918
+ },
919
+ openclaw: {
920
+ selectionKind: "single-model",
921
+ protocolPreference: "chat_completions",
922
+ defaultModel: "gpt-4",
923
+ sellerId: "seller-one",
924
+ },
925
+ opencode: {
926
+ selectionKind: "single-model",
927
+ protocolPreference: "responses",
928
+ defaultModel: "gpt-4",
929
+ sellerId: "seller-one",
930
+ },
931
+ hermes: {
932
+ selectionKind: "single-model",
933
+ protocolPreference: "chat_completions",
934
+ defaultModel: "gpt-4",
935
+ sellerId: "seller-one",
936
+ },
937
+ },
938
+ sellerRouting: {
939
+ mode: "fixed",
940
+ sellerId: "seller-one",
941
+ },
527
942
  home: PROVIDER_HOME
528
943
  }, store);
529
944
  expect(applied).toEqual(expect.arrayContaining([
@@ -539,15 +954,30 @@ describe("Provider install planning", () => {
539
954
  const claude = JSON.parse(fs.readFileSync(path.join(PROVIDER_HOME, ".claude", "settings.json"), "utf8"));
540
955
  expect(claude.theme).toBe("dark");
541
956
  expect(claude.env.ANTHROPIC_BASE_URL).toBe(proxyUrl);
957
+ expect(claude.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe("claude-sonnet-4-6[1M]");
958
+ expect(claude.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe("claude-opus-4-7[1M]");
959
+ expect(claude.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe("claude-haiku-4-5");
960
+ expect(claude.env.ANTHROPIC_DEFAULT_SONNET_MODEL_NAME).toBe("MiniMax-M2.7-highspeed");
961
+ expect(store.getProviderRuntimeConfig("claude-code")).toBeDefined();
962
+ expect(store.getDaemonRuntimeConfig("routing")).toMatchObject({
963
+ config: expect.objectContaining({
964
+ mode: "fixed",
965
+ sellerId: "seller-one",
966
+ }),
967
+ });
542
968
 
543
969
  const openclaw = JSON.parse(fs.readFileSync(path.join(PROVIDER_HOME, ".openclaw", "config.json"), "utf8"));
544
970
  expect(openclaw.keep).toBe("field");
545
971
  expect(openclaw.api_url).toBe(proxyUrl);
972
+ const opencode = JSON.parse(fs.readFileSync(path.join(PROVIDER_HOME, ".config", "opencode", "opencode.json"), "utf8"));
973
+ expect(opencode.share).toBe("disabled");
974
+ expect(opencode.provider.tokenbuddy.options.baseURL).toBe(`${proxyUrl}/v1`);
975
+ expect(opencode.provider.tokenbuddy.models["gpt-4"].name).toBe("gpt-4");
546
976
  expect(fs.existsSync(path.join(PROVIDER_HOME, ".hermes", "settings.json"))).toBe(true);
547
977
  expect(store.getProviderInstallSnapshot("codex")).toBeDefined();
548
978
 
549
979
  const rolledBack = rollbackProviderInstall({
550
- providers: ["codex", "claude-code", "openclaw", "hermes"],
980
+ providers: ["codex", "claude-code", "openclaw", "opencode", "hermes"],
551
981
  home: PROVIDER_HOME
552
982
  }, store);
553
983
 
@@ -557,8 +987,11 @@ describe("Provider install planning", () => {
557
987
  ]));
558
988
  expect(fs.readFileSync(path.join(PROVIDER_HOME, ".codex", "config.toml"), "utf8")).toBe("approval_policy = \"never\"\n");
559
989
  expect(JSON.parse(fs.readFileSync(path.join(PROVIDER_HOME, ".openclaw", "config.json"), "utf8"))).toEqual({ keep: "field" });
990
+ expect(JSON.parse(fs.readFileSync(path.join(PROVIDER_HOME, ".config", "opencode", "opencode.json"), "utf8"))).toEqual({ share: "disabled" });
560
991
  expect(fs.existsSync(path.join(PROVIDER_HOME, ".hermes", "settings.json"))).toBe(false);
561
992
  expect(store.getProviderInstallSnapshot("codex")).toBeUndefined();
993
+ expect(store.getProviderRuntimeConfig("claude-code")).toBeUndefined();
994
+ expect(store.getDaemonRuntimeConfig("routing")).toBeUndefined();
562
995
  } finally {
563
996
  store.close();
564
997
  }
@@ -703,6 +1136,29 @@ describe("TokenBuddy CLI and Daemon Integration Tests", () => {
703
1136
  idempotencyKey: req.headers["idempotency-key"] as string | undefined,
704
1137
  body
705
1138
  });
1139
+ if (body.requestId === "responses_req_stream_shape") {
1140
+ res.writeHead(200, { "Content-Type": "text/event-stream" });
1141
+ 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');
1142
+ 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');
1143
+ 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');
1144
+ res.write('event: response.output_text.done\ndata: {"type":"response.output_text.done","item_id":"item_stream_shape","sequence_number":3}\n\n');
1145
+ 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');
1146
+ 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');
1147
+ return;
1148
+ }
1149
+ if (body.requestId === "responses_req_br") {
1150
+ const compressed = zlib.brotliCompressSync(Buffer.from(JSON.stringify({
1151
+ id: "resp-br",
1152
+ usage: { input_tokens: 7, output_tokens: 9 }
1153
+ })));
1154
+ res.writeHead(200, {
1155
+ "Content-Type": "application/json",
1156
+ "Content-Encoding": "br",
1157
+ "Content-Length": compressed.byteLength
1158
+ });
1159
+ res.end(compressed);
1160
+ return;
1161
+ }
706
1162
  res.end(JSON.stringify({
707
1163
  id: "resp-mock",
708
1164
  usage: { input_tokens: 7, output_tokens: 9 }
@@ -772,6 +1228,28 @@ describe("TokenBuddy CLI and Daemon Integration Tests", () => {
772
1228
  prompt: "raw control prompt",
773
1229
  response: "raw control response"
774
1230
  });
1231
+ seedStore.saveProviderRuntimeConfig("claude-code", {
1232
+ selectionKind: "claude-role-mapping",
1233
+ protocolPreference: "messages",
1234
+ fallbackModel: "claude-3-5-sonnet",
1235
+ roles: {
1236
+ sonnet: {
1237
+ upstreamModel: "claude-3-5-sonnet",
1238
+ displayName: "Claude Sonnet Mock",
1239
+ declareOneM: true
1240
+ },
1241
+ opus: {
1242
+ upstreamModel: "claude-3-5-sonnet",
1243
+ displayName: "Claude Opus Mock",
1244
+ declareOneM: true
1245
+ },
1246
+ haiku: {
1247
+ upstreamModel: "claude-3-5-sonnet",
1248
+ displayName: "Claude Haiku Mock",
1249
+ declareOneM: false
1250
+ }
1251
+ }
1252
+ });
775
1253
  seedStore.close();
776
1254
 
777
1255
  daemon = new TokenbuddyDaemon({
@@ -954,6 +1432,19 @@ describe("TokenBuddy CLI and Daemon Integration Tests", () => {
954
1432
  expect(largeResponses.ok).toBe(true);
955
1433
  expect((await largeResponses.json() as any).id).toBe("resp-mock");
956
1434
 
1435
+ const compressedResponses = await fetch(`${proxyUrl}/v1/responses`, {
1436
+ method: "POST",
1437
+ headers: { "Content-Type": "application/json" },
1438
+ body: JSON.stringify({
1439
+ model: "gpt-4.1-mini",
1440
+ input: "raw compressed responses prompt secret",
1441
+ requestId: "responses_req_br"
1442
+ })
1443
+ });
1444
+ expect(compressedResponses.ok).toBe(true);
1445
+ expect(compressedResponses.headers.get("content-encoding")).toBeNull();
1446
+ expect((await compressedResponses.json() as any).id).toBe("resp-br");
1447
+
957
1448
  for (const endpoint of ["/v1/messages", "/messages"]) {
958
1449
  const message = await fetch(`${proxyUrl}${endpoint}`, {
959
1450
  method: "POST",
@@ -985,6 +1476,30 @@ describe("TokenBuddy CLI and Daemon Integration Tests", () => {
985
1476
  expect(publicOutput).not.toContain("raw anthropic prompt secret");
986
1477
  });
987
1478
 
1479
+ test("maps Claude role aliases to upstream models before message routing", async () => {
1480
+ for (const model of ["sonnet", "claude-sonnet-4-6[1M]"]) {
1481
+ const message = await fetch(`http://127.0.0.1:${daemonProxyPort}/v1/messages`, {
1482
+ method: "POST",
1483
+ headers: { "Content-Type": "application/json" },
1484
+ body: JSON.stringify({
1485
+ model,
1486
+ messages: [{ role: "user", content: "role mapping request" }]
1487
+ })
1488
+ });
1489
+ expect(message.ok).toBe(true);
1490
+ expect((await message.json() as any).id).toBe("msg-mock");
1491
+ }
1492
+
1493
+ const messageRequests = sellerRequests.filter((request) => request.url === "/v1/messages");
1494
+ expect(messageRequests).toEqual(expect.arrayContaining([
1495
+ expect.objectContaining({
1496
+ body: expect.objectContaining({
1497
+ model: "claude-3-5-sonnet"
1498
+ })
1499
+ })
1500
+ ]));
1501
+ });
1502
+
988
1503
  test("passes through streaming chat responses and records safe ledger metadata", async () => {
989
1504
  const response = await fetch(`http://127.0.0.1:${daemonProxyPort}/v1/chat/completions`, {
990
1505
  method: "POST",
@@ -1016,6 +1531,28 @@ describe("TokenBuddy CLI and Daemon Integration Tests", () => {
1016
1531
  expect(publicOutput).not.toContain("chatcmpl-stream");
1017
1532
  });
1018
1533
 
1534
+ test("normalizes responses SSE shape for OpenCode-compatible consumers", async () => {
1535
+ const response = await fetch(`http://127.0.0.1:${daemonProxyPort}/v1/responses`, {
1536
+ method: "POST",
1537
+ headers: { "Content-Type": "application/json" },
1538
+ body: JSON.stringify({
1539
+ model: "gpt-4.1-mini",
1540
+ input: "shape normalization",
1541
+ requestId: "responses_req_stream_shape",
1542
+ stream: true
1543
+ })
1544
+ });
1545
+
1546
+ expect(response.ok).toBe(true);
1547
+ expect(response.headers.get("content-type")).toContain("text/event-stream");
1548
+ const body = await response.text();
1549
+ expect(body).toContain("response.content_part.added");
1550
+ expect(body).toContain("response.content_part.done");
1551
+ expect(body).toContain("\"item_id\":\"item_stream_shape\"");
1552
+ expect(body).toContain("\"output_text\":\"hello\"");
1553
+ expect(body).toContain("\"content\":[{\"type\":\"output_text\",\"text\":\"hello\",\"annotations\":[]}]");
1554
+ });
1555
+
1019
1556
  test("fails closed when no compatible seller can serve the requested model", async () => {
1020
1557
  const response = await fetch(`http://127.0.0.1:${daemonProxyPort}/v1/chat/completions`, {
1021
1558
  method: "POST",
@@ -1108,6 +1645,41 @@ describe("TokenBuddy manual routing mode", () => {
1108
1645
  return;
1109
1646
  }
1110
1647
 
1648
+ if (req.url === "/backup/purchase/create") {
1649
+ expect(body.paymentMethod).toBe("mock");
1650
+ events.push({ seller: "backup-seller", url: req.url });
1651
+ res.end(JSON.stringify({
1652
+ purchaseId: "pur_backup_123",
1653
+ status: "pending",
1654
+ creditMicros: 2000000,
1655
+ currency: "USD",
1656
+ expiresAt: new Date(Date.now() + 86400 * 1000).toISOString()
1657
+ }));
1658
+ return;
1659
+ }
1660
+
1661
+ if (req.url === "/backup/purchase/complete") {
1662
+ events.push({ seller: "backup-seller", url: req.url });
1663
+ res.end(JSON.stringify({
1664
+ purchaseId: "pur_backup_123",
1665
+ status: "active",
1666
+ accessToken: "tok_backup_token_abc",
1667
+ tokenClass: "model:gpt-manual",
1668
+ creditMicros: 2000000,
1669
+ currency: "USD"
1670
+ }));
1671
+ return;
1672
+ }
1673
+
1674
+ if (req.url === "/backup/v1/chat/completions") {
1675
+ events.push({ seller: "backup-seller", url: req.url });
1676
+ res.end(JSON.stringify({
1677
+ id: "backup-chat",
1678
+ usage: { prompt_tokens: 4, completion_tokens: 5 }
1679
+ }));
1680
+ return;
1681
+ }
1682
+
1111
1683
  if (req.url?.startsWith("/backup/")) {
1112
1684
  events.push({ seller: "backup-seller", url: req.url });
1113
1685
  res.end(JSON.stringify({ id: "backup-should-not-run" }));
@@ -1183,4 +1755,41 @@ describe("TokenBuddy manual routing mode", () => {
1183
1755
  expect(purchases.purchases.some((entry: any) => entry.sellerKey === "backup-seller")).toBe(false);
1184
1756
  expect(inferences.inferences.some((entry: any) => entry.sellerKey === "backup-seller")).toBe(false);
1185
1757
  });
1758
+
1759
+ test("pins manual routing to the configured seller id when selectedSellerId is set", async () => {
1760
+ daemon.stop();
1761
+ events.length = 0;
1762
+ daemon = new TokenbuddyDaemon({
1763
+ controlPort: 0,
1764
+ proxyPort: 0,
1765
+ dbPath,
1766
+ sellerRegistryUrl: `http://127.0.0.1:${sellerPort}/registry/sellers`,
1767
+ selectionMode: "manual",
1768
+ selectedSellerId: "backup-seller"
1769
+ });
1770
+ daemon.start();
1771
+ daemonControlPort = ((daemon as any).controlServer.address() as AddressInfo).port;
1772
+ daemonProxyPort = ((daemon as any).proxyServer.address() as AddressInfo).port;
1773
+
1774
+ const status = await (await fetch(`http://127.0.0.1:${daemonControlPort}/status`)).json() as any;
1775
+ expect(status.selectionMode).toBe("manual");
1776
+ expect(status.selectedSellerId).toBe("backup-seller");
1777
+
1778
+ const response = await fetch(`http://127.0.0.1:${daemonProxyPort}/v1/chat/completions`, {
1779
+ method: "POST",
1780
+ headers: { "Content-Type": "application/json" },
1781
+ body: JSON.stringify({
1782
+ model: "gpt-manual",
1783
+ messages: [{ role: "user", content: "manual selected seller should stay pinned" }]
1784
+ })
1785
+ });
1786
+
1787
+ expect(response.ok).toBe(true);
1788
+ expect(events).toEqual([
1789
+ { seller: "backup-seller", url: "/backup/manifest" },
1790
+ { seller: "backup-seller", url: "/backup/purchase/create" },
1791
+ { seller: "backup-seller", url: "/backup/purchase/complete" },
1792
+ { seller: "backup-seller", url: "/backup/v1/chat/completions" }
1793
+ ]);
1794
+ });
1186
1795
  });