@tokenbuddy/tokenbuddy 1.0.11 → 1.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/dist/src/buyer-store.d.ts +61 -0
  2. package/dist/src/buyer-store.d.ts.map +1 -1
  3. package/dist/src/buyer-store.js +12 -0
  4. package/dist/src/buyer-store.js.map +1 -1
  5. package/dist/src/cli.d.ts +47 -0
  6. package/dist/src/cli.d.ts.map +1 -1
  7. package/dist/src/cli.js +287 -63
  8. package/dist/src/cli.js.map +1 -1
  9. package/dist/src/credit-tracker.d.ts +26 -0
  10. package/dist/src/credit-tracker.d.ts.map +1 -1
  11. package/dist/src/credit-tracker.js +8 -0
  12. package/dist/src/credit-tracker.js.map +1 -1
  13. package/dist/src/daemon.d.ts +29 -3
  14. package/dist/src/daemon.d.ts.map +1 -1
  15. package/dist/src/daemon.js +292 -65
  16. package/dist/src/daemon.js.map +1 -1
  17. package/dist/src/doctor-clawtip-wallet.d.ts +25 -0
  18. package/dist/src/doctor-clawtip-wallet.d.ts.map +1 -1
  19. package/dist/src/doctor-clawtip-wallet.js +13 -0
  20. package/dist/src/doctor-clawtip-wallet.js.map +1 -1
  21. package/dist/src/doctor-diagnostics.d.ts +63 -0
  22. package/dist/src/doctor-diagnostics.d.ts.map +1 -1
  23. package/dist/src/doctor-diagnostics.js +39 -1
  24. package/dist/src/doctor-diagnostics.js.map +1 -1
  25. package/dist/src/index.d.ts +4 -0
  26. package/dist/src/index.d.ts.map +1 -1
  27. package/dist/src/index.js +4 -0
  28. package/dist/src/index.js.map +1 -1
  29. package/dist/src/init-clawtip-activation.d.ts +103 -0
  30. package/dist/src/init-clawtip-activation.d.ts.map +1 -1
  31. package/dist/src/init-clawtip-activation.js +60 -0
  32. package/dist/src/init-clawtip-activation.js.map +1 -1
  33. package/dist/src/init-payment-options.d.ts +124 -0
  34. package/dist/src/init-payment-options.d.ts.map +1 -1
  35. package/dist/src/init-payment-options.js +68 -0
  36. package/dist/src/init-payment-options.js.map +1 -1
  37. package/dist/src/model-index.d.ts +9 -0
  38. package/dist/src/model-index.d.ts.map +1 -1
  39. package/dist/src/model-index.js.map +1 -1
  40. package/dist/src/prewarm-cache.d.ts +89 -0
  41. package/dist/src/prewarm-cache.d.ts.map +1 -1
  42. package/dist/src/prewarm-cache.js +14 -1
  43. package/dist/src/prewarm-cache.js.map +1 -1
  44. package/dist/src/prewarm-scheduler.d.ts +62 -3
  45. package/dist/src/prewarm-scheduler.d.ts.map +1 -1
  46. package/dist/src/prewarm-scheduler.js +39 -8
  47. package/dist/src/prewarm-scheduler.js.map +1 -1
  48. package/dist/src/provider-install.d.ts +89 -3
  49. package/dist/src/provider-install.d.ts.map +1 -1
  50. package/dist/src/provider-install.js +77 -17
  51. package/dist/src/provider-install.js.map +1 -1
  52. package/dist/src/route-failover.d.ts +48 -0
  53. package/dist/src/route-failover.d.ts.map +1 -1
  54. package/dist/src/route-failover.js.map +1 -1
  55. package/dist/src/seller-catalog.d.ts +158 -10
  56. package/dist/src/seller-catalog.d.ts.map +1 -1
  57. package/dist/src/seller-catalog.js +79 -5
  58. package/dist/src/seller-catalog.js.map +1 -1
  59. package/dist/src/seller-metadata-cache.d.ts +29 -0
  60. package/dist/src/seller-metadata-cache.d.ts.map +1 -0
  61. package/dist/src/seller-metadata-cache.js +71 -0
  62. package/dist/src/seller-metadata-cache.js.map +1 -0
  63. package/dist/src/seller-pool.d.ts +71 -0
  64. package/dist/src/seller-pool.d.ts.map +1 -1
  65. package/dist/src/seller-pool.js +6 -1
  66. package/dist/src/seller-pool.js.map +1 -1
  67. package/dist/src/seller-route-planner.d.ts +118 -0
  68. package/dist/src/seller-route-planner.d.ts.map +1 -0
  69. package/dist/src/seller-route-planner.js +160 -0
  70. package/dist/src/seller-route-planner.js.map +1 -0
  71. package/dist/src/seller-routing-config.d.ts +69 -0
  72. package/dist/src/seller-routing-config.d.ts.map +1 -0
  73. package/dist/src/seller-routing-config.js +164 -0
  74. package/dist/src/seller-routing-config.js.map +1 -0
  75. package/dist/src/seller-routing-strategy.d.ts +118 -0
  76. package/dist/src/seller-routing-strategy.d.ts.map +1 -0
  77. package/dist/src/seller-routing-strategy.js +183 -0
  78. package/dist/src/seller-routing-strategy.js.map +1 -0
  79. package/dist/src/stream-failover.d.ts +23 -0
  80. package/dist/src/stream-failover.d.ts.map +1 -1
  81. package/dist/src/stream-failover.js +4 -0
  82. package/dist/src/stream-failover.js.map +1 -1
  83. package/dist/src/tb-proxyd.js +7 -21
  84. package/dist/src/tb-proxyd.js.map +1 -1
  85. package/dist/src/terminal-detect.d.ts +51 -0
  86. package/dist/src/terminal-detect.d.ts.map +1 -1
  87. package/dist/src/terminal-detect.js +42 -0
  88. package/dist/src/terminal-detect.js.map +1 -1
  89. package/dist/src/terminal-image.d.ts +41 -0
  90. package/dist/src/terminal-image.d.ts.map +1 -1
  91. package/dist/src/terminal-image.js +15 -0
  92. package/dist/src/terminal-image.js.map +1 -1
  93. package/package.json +1 -1
  94. package/src/buyer-store.ts +61 -0
  95. package/src/cli.ts +330 -68
  96. package/src/credit-tracker.ts +26 -0
  97. package/src/daemon.ts +363 -72
  98. package/src/doctor-clawtip-wallet.ts +25 -0
  99. package/src/doctor-diagnostics.ts +63 -1
  100. package/src/index.ts +4 -0
  101. package/src/init-clawtip-activation.ts +103 -0
  102. package/src/init-payment-options.ts +124 -0
  103. package/src/model-index.ts +9 -0
  104. package/src/prewarm-cache.ts +99 -1
  105. package/src/prewarm-scheduler.ts +97 -12
  106. package/src/provider-install.ts +125 -25
  107. package/src/route-failover.ts +48 -0
  108. package/src/seller-catalog.ts +158 -12
  109. package/src/seller-metadata-cache.ts +91 -0
  110. package/src/seller-pool.ts +77 -1
  111. package/src/seller-route-planner.ts +323 -0
  112. package/src/seller-routing-config.ts +198 -0
  113. package/src/seller-routing-strategy.ts +316 -0
  114. package/src/stream-failover.ts +23 -0
  115. package/src/tb-proxyd.ts +7 -23
  116. package/src/terminal-detect.ts +51 -0
  117. package/src/terminal-image.ts +41 -0
  118. package/tests/cli-routing.test.ts +287 -0
  119. package/tests/daemon-classify.test.ts +431 -0
  120. package/tests/daemon-roles.test.ts +92 -0
  121. package/tests/seller-catalog-utilities.test.ts +70 -0
  122. package/tests/seller-metadata-cache.test.ts +89 -0
  123. package/tests/seller-route-planner.test.ts +150 -0
  124. package/tests/seller-routing-config.test.ts +111 -0
  125. package/tests/seller-routing-strategy.test.ts +166 -0
  126. package/tests/tokenbuddy.test.ts +447 -33
  127. /package/{src → tests}/credit-tracker.test.ts +0 -0
  128. /package/{src → tests}/model-index.test.ts +0 -0
  129. /package/{src → tests}/prewarm-cache.test.ts +0 -0
  130. /package/{src → tests}/prewarm-scheduler.test.ts +0 -0
  131. /package/{src → tests}/route-failover.test.ts +0 -0
  132. /package/{src → tests}/seller-catalog-413.test.ts +0 -0
  133. /package/{src → tests}/seller-pool.test.ts +0 -0
  134. /package/{src → tests}/stream-failover.test.ts +0 -0
  135. /package/{src → tests}/thousand-seller.test.ts +0 -0
@@ -2,6 +2,7 @@ import { TokenbuddyDaemon } from "../src/daemon.js";
2
2
  import { BuyerStore, resolveBuyerStorePath, type PaymentConfig } from "../src/buyer-store.js";
3
3
  import type { ProviderRuntimeConfig } from "../src/provider-install.js";
4
4
  import {
5
+ buildLaunchdPlistContent,
5
6
  buildCli,
6
7
  fetchClawtipBootstrap,
7
8
  normalizeClawtipBootstrapResourceUrl,
@@ -44,6 +45,7 @@ import * as fs from "fs";
44
45
  import http from "http";
45
46
  import { AddressInfo } from "net";
46
47
  import zlib from "zlib";
48
+ import { resolveModuleLogFile } from "@tokenbuddy/logging";
47
49
 
48
50
  const TEMP_BUYER_DB = path.resolve(__dirname, "../../data-test/buyer-cache-test.db");
49
51
  const TEMP_STORE_ROOT = path.resolve(__dirname, "../../data-test/buyer-store-test");
@@ -69,7 +71,7 @@ describe("TokenBuddy CLI command surface", () => {
69
71
  .filter(command => command !== "help")
70
72
  .sort();
71
73
 
72
- expect(commandNames).toEqual(["doctor", "init", "models", "payment"]);
74
+ expect(commandNames).toEqual(["doctor", "init", "models", "payment", "routing"]);
73
75
  });
74
76
 
75
77
  test("tb payment help only exposes list, add, and remove", () => {
@@ -106,6 +108,49 @@ describe("TokenBuddy CLI command surface", () => {
106
108
  "tb-proxyd": "./bin/tb-proxyd.js"
107
109
  });
108
110
  });
111
+
112
+ test("launchd plist pins tb-proxyd ports and seller registry", () => {
113
+ const plist = buildLaunchdPlistContent({
114
+ label: "com.tokenbuddy.proxyd",
115
+ nodePath: "/opt/homebrew/bin/node",
116
+ scriptPath: "/opt/homebrew/bin/tb-proxyd.js",
117
+ stdoutPath: "/Users/example/.tokenbuddy-store/tb-proxyd.stdout.log",
118
+ stderrPath: "/Users/example/.tokenbuddy-store/tb-proxyd.stderr.log",
119
+ controlPort: 17820,
120
+ proxyPort: 17821,
121
+ sellerRegistryUrl: "https://tb-wallet-bootstrap.fly.dev/registry/sellers",
122
+ });
123
+
124
+ expect(plist).toContain("<key>EnvironmentVariables</key>");
125
+ expect(plist).toContain("<key>TB_PROXYD_CONTROL_PORT</key>");
126
+ expect(plist).toContain("<string>17820</string>");
127
+ expect(plist).toContain("<key>TB_PROXYD_PROXY_PORT</key>");
128
+ expect(plist).toContain("<string>17821</string>");
129
+ expect(plist).toContain("<key>TB_PROXYD_SELLER_REGISTRY_URL</key>");
130
+ expect(plist).toContain("<string>https://tb-wallet-bootstrap.fly.dev/registry/sellers</string>");
131
+ });
132
+
133
+ test("launchd plist can inject the ClawTip proof provider without embedding proofs", () => {
134
+ const plist = buildLaunchdPlistContent({
135
+ label: "com.tokenbuddy.proxyd",
136
+ nodePath: "/opt/homebrew/bin/node",
137
+ scriptPath: "/opt/homebrew/bin/tb-proxyd.js",
138
+ stdoutPath: "/Users/example/.tokenbuddy-store/tb-proxyd.stdout.log",
139
+ stderrPath: "/Users/example/.tokenbuddy-store/tb-proxyd.stderr.log",
140
+ controlPort: 17820,
141
+ proxyPort: 17821,
142
+ sellerRegistryUrl: "https://tb-wallet-bootstrap.fly.dev/registry/sellers",
143
+ clawtipProofCommand: "/Users/example/bin/tokenbuddy-clawtip-proof-openclaw.sh",
144
+ clawtipProofTimeoutMs: 180000,
145
+ });
146
+
147
+ expect(plist).toContain("<key>TB_PROXYD_CLAWTIP_PROOF_COMMAND</key>");
148
+ expect(plist).toContain("<string>/Users/example/bin/tokenbuddy-clawtip-proof-openclaw.sh</string>");
149
+ expect(plist).toContain("<key>TB_PROXYD_CLAWTIP_PROOF_TIMEOUT_MS</key>");
150
+ expect(plist).toContain("<string>180000</string>");
151
+ expect(plist).not.toContain("payCredential");
152
+ expect(plist).not.toContain("PAYMENT_PROOF");
153
+ });
109
154
  });
110
155
 
111
156
  describe("BuyerStore safe SQLite persistence", () => {
@@ -198,7 +243,6 @@ describe("BuyerStore safe SQLite persistence", () => {
198
243
  selectionKind: "single-model",
199
244
  protocolPreference: "responses",
200
245
  defaultModel: "gpt-5.5",
201
- sellerId: "seller-a",
202
246
  });
203
247
  store.saveDaemonRuntimeConfig("routing", {
204
248
  mode: "fixed",
@@ -209,7 +253,6 @@ describe("BuyerStore safe SQLite persistence", () => {
209
253
  providerId: "opencode",
210
254
  config: expect.objectContaining({
211
255
  defaultModel: "gpt-5.5",
212
- sellerId: "seller-a",
213
256
  }),
214
257
  });
215
258
  expect(store.getDaemonRuntimeConfig("routing")).toMatchObject({
@@ -475,6 +518,87 @@ describe("TokenBuddy payment CLI", () => {
475
518
  expect(logs.join("\n")).toContain("Mock payment method registered");
476
519
  expect(logs.join("\n")).toContain("removed");
477
520
  });
521
+
522
+ test("routing set/show updates buyer routing config without requiring tb-proxyd", async () => {
523
+ await new Promise<void>((resolve) => controlServer.close(() => {
524
+ controlServerOpen = false;
525
+ resolve();
526
+ }));
527
+ const output: string[] = [];
528
+ jest.spyOn(console, "log").mockImplementation((message?: unknown) => {
529
+ output.push(String(message));
530
+ });
531
+
532
+ await buildCli().parseAsync([
533
+ "node",
534
+ "tb",
535
+ "routing",
536
+ "set",
537
+ "fixedSet",
538
+ "--seller-set",
539
+ "seller-a,seller-b",
540
+ "--scorer",
541
+ "speed"
542
+ ]);
543
+
544
+ const store = new BuyerStore({ root: CLI_STORE_ROOT });
545
+ expect(store.getDaemonRuntimeConfig("routing")).toMatchObject({
546
+ config: {
547
+ mode: "fixedSet",
548
+ sellerIds: ["seller-a", "seller-b"],
549
+ scorer: "speed"
550
+ }
551
+ });
552
+ store.close();
553
+
554
+ output.length = 0;
555
+ await buildCli().parseAsync(["node", "tb", "routing", "show", "--json"]);
556
+ const parsed = JSON.parse(output[0]) as any;
557
+ expect(parsed.routing).toEqual({
558
+ mode: "fixedSet",
559
+ sellerIds: ["seller-a", "seller-b"],
560
+ scorer: "speed"
561
+ });
562
+
563
+ await buildCli().parseAsync([
564
+ "node",
565
+ "tb",
566
+ "routing",
567
+ "set",
568
+ "fixed",
569
+ "--seller",
570
+ "seller-c",
571
+ "--scorer",
572
+ "discount"
573
+ ]);
574
+ let refreshed = new BuyerStore({ root: CLI_STORE_ROOT });
575
+ expect(refreshed.getDaemonRuntimeConfig("routing")).toMatchObject({
576
+ config: {
577
+ mode: "fixed",
578
+ sellerId: "seller-c",
579
+ scorer: "discount"
580
+ }
581
+ });
582
+ refreshed.close();
583
+
584
+ await buildCli().parseAsync([
585
+ "node",
586
+ "tb",
587
+ "routing",
588
+ "set",
589
+ "fullAuto",
590
+ "--scorer",
591
+ "balanced"
592
+ ]);
593
+ refreshed = new BuyerStore({ root: CLI_STORE_ROOT });
594
+ expect(refreshed.getDaemonRuntimeConfig("routing")).toMatchObject({
595
+ config: {
596
+ mode: "fullAuto",
597
+ scorer: "balanced"
598
+ }
599
+ });
600
+ refreshed.close();
601
+ });
478
602
  });
479
603
 
480
604
  describe("TokenBuddy init payment options", () => {
@@ -1485,6 +1609,19 @@ describe("Provider install planning", () => {
1485
1609
  ]));
1486
1610
  });
1487
1611
 
1612
+ test("treats existing non-TokenBuddy OpenCode config as installed, not configured", () => {
1613
+ const providers = detectProviders({ home: PROVIDER_HOME });
1614
+
1615
+ expect(providers).toEqual(expect.arrayContaining([
1616
+ expect.objectContaining({
1617
+ id: "opencode",
1618
+ status: "installed",
1619
+ configured: false,
1620
+ executablePath: expect.stringContaining(path.join("provider-bin", "opencode")),
1621
+ }),
1622
+ ]));
1623
+ });
1624
+
1488
1625
  test("applies provider config and rolls back existing and created files", () => {
1489
1626
  const store = new BuyerStore({ root: PROVIDER_STORE_ROOT });
1490
1627
  try {
@@ -1496,7 +1633,6 @@ describe("Provider install planning", () => {
1496
1633
  selectionKind: "single-model",
1497
1634
  protocolPreference: "responses",
1498
1635
  defaultModel: "gpt-4",
1499
- sellerId: "seller-one",
1500
1636
  },
1501
1637
  "claude-code": {
1502
1638
  selectionKind: "claude-role-mapping",
@@ -1524,25 +1660,18 @@ describe("Provider install planning", () => {
1524
1660
  selectionKind: "single-model",
1525
1661
  protocolPreference: "chat_completions",
1526
1662
  defaultModel: "gpt-4",
1527
- sellerId: "seller-one",
1528
1663
  },
1529
1664
  opencode: {
1530
1665
  selectionKind: "single-model",
1531
1666
  protocolPreference: "responses",
1532
1667
  defaultModel: "gpt-4",
1533
- sellerId: "seller-one",
1534
1668
  },
1535
1669
  hermes: {
1536
1670
  selectionKind: "single-model",
1537
1671
  protocolPreference: "chat_completions",
1538
1672
  defaultModel: "gpt-4",
1539
- sellerId: "seller-one",
1540
1673
  },
1541
1674
  },
1542
- sellerRouting: {
1543
- mode: "fixed",
1544
- sellerId: "seller-one",
1545
- },
1546
1675
  home: PROVIDER_HOME
1547
1676
  }, store);
1548
1677
  expect(applied).toEqual(expect.arrayContaining([
@@ -1563,20 +1692,24 @@ describe("Provider install planning", () => {
1563
1692
  expect(claude.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe("claude-haiku-4-5");
1564
1693
  expect(claude.env.ANTHROPIC_DEFAULT_SONNET_MODEL_NAME).toBe("MiniMax-M2.7-highspeed");
1565
1694
  expect(store.getProviderRuntimeConfig("claude-code")).toBeDefined();
1566
- expect(store.getDaemonRuntimeConfig("routing")).toMatchObject({
1567
- config: expect.objectContaining({
1568
- mode: "fixed",
1569
- sellerId: "seller-one",
1570
- }),
1571
- });
1695
+ expect(store.getDaemonRuntimeConfig("routing")).toBeUndefined();
1696
+ expect(store.getProviderRuntimeConfig("opencode")?.config).not.toHaveProperty("sellerId");
1572
1697
 
1573
1698
  const openclaw = JSON.parse(fs.readFileSync(path.join(PROVIDER_HOME, ".openclaw", "config.json"), "utf8"));
1574
1699
  expect(openclaw.keep).toBe("field");
1575
1700
  expect(openclaw.api_url).toBe(proxyUrl);
1576
1701
  const opencode = JSON.parse(fs.readFileSync(path.join(PROVIDER_HOME, ".config", "opencode", "opencode.json"), "utf8"));
1577
1702
  expect(opencode.share).toBe("disabled");
1703
+ expect(JSON.stringify(opencode)).not.toContain("sellerId");
1578
1704
  expect(opencode.provider.tokenbuddy.options.baseURL).toBe(`${proxyUrl}/v1`);
1579
1705
  expect(opencode.provider.tokenbuddy.models["gpt-4"].name).toBe("gpt-4");
1706
+ expect(detectProviders({ home: PROVIDER_HOME })).toEqual(expect.arrayContaining([
1707
+ expect.objectContaining({
1708
+ id: "opencode",
1709
+ status: "configured",
1710
+ configured: true,
1711
+ }),
1712
+ ]));
1580
1713
  expect(fs.existsSync(path.join(PROVIDER_HOME, ".hermes", "settings.json"))).toBe(true);
1581
1714
  expect(store.getProviderInstallSnapshot("codex")).toBeDefined();
1582
1715
 
@@ -1595,21 +1728,15 @@ describe("Provider install planning", () => {
1595
1728
  expect(fs.existsSync(path.join(PROVIDER_HOME, ".hermes", "settings.json"))).toBe(false);
1596
1729
  expect(store.getProviderInstallSnapshot("codex")).toBeUndefined();
1597
1730
  expect(store.getProviderRuntimeConfig("claude-code")).toBeUndefined();
1598
- expect(store.getDaemonRuntimeConfig("routing")).toBeUndefined();
1599
1731
  } finally {
1600
1732
  store.close();
1601
1733
  }
1602
1734
  });
1603
1735
 
1604
- test("opencode provider install uses @ai-sdk/openai-responses by default (Responses API)", () => {
1605
- // 锁住不变量:v1.0.10+ tb-proxy install opencode 必须默认走 Responses API 协议,
1606
- // 而不是 chat completions。原因:code.shoestravel.xin 等上游原生 SSE 事件链
1607
- // 才是 Responses API 风格(response.created / response.output_text.delta / response.completed),
1608
- // buyer 端 SseUsageExtractor 解析 usage 字段更稳定。改回 @ai-sdk/openai 需先
1609
- // 评估 5-seller 架构是否仍能端到端 work。
1736
+ test("opencode provider install uses @ai-sdk/openai-compatible for chat completions", () => {
1610
1737
  const config: ProviderRuntimeConfig = {
1611
1738
  selectionKind: "single-model",
1612
- protocolPreference: "responses",
1739
+ protocolPreference: "chat_completions",
1613
1740
  defaultModel: "gpt-5.4",
1614
1741
  };
1615
1742
  const changes = previewProviderInstall({
@@ -1622,7 +1749,7 @@ describe("Provider install planning", () => {
1622
1749
  expect(change).toBeDefined();
1623
1750
  expect(change?.content).toBeDefined();
1624
1751
  const parsed = JSON.parse(change!.content!);
1625
- expect(parsed.provider.tokenbuddy.npm).toBe("@ai-sdk/openai-responses");
1752
+ expect(parsed.provider.tokenbuddy.npm).toBe("@ai-sdk/openai-compatible");
1626
1753
  expect(parsed.model).toBe("tokenbuddy/gpt-5.4");
1627
1754
  expect(parsed.provider.tokenbuddy.options.baseURL).toBe("http://127.0.0.1:17821/v1");
1628
1755
  });
@@ -1801,6 +1928,12 @@ describe("TokenBuddy CLI and Daemon Integration Tests", () => {
1801
1928
  if (body.stream) {
1802
1929
  res.writeHead(200, { "Content-Type": "text/event-stream" });
1803
1930
  res.write("data: {\"id\":\"chatcmpl-stream\",\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\n\n");
1931
+ if (body.requestId === "stream_req_slow_after_headers") {
1932
+ await new Promise((resolve) => setTimeout(resolve, 120));
1933
+ res.write("data: {\"id\":\"chatcmpl-stream\",\"choices\":[{\"delta\":{\"content\":\" later\"}}]}\n\n");
1934
+ res.end("data: [DONE]\n\n");
1935
+ return;
1936
+ }
1804
1937
  res.write("event: tokenbuddy.settlement\n");
1805
1938
  res.write(`data: ${JSON.stringify({
1806
1939
  requestId: body.requestId || "stream_req_mock",
@@ -2282,6 +2415,8 @@ describe("TokenBuddy CLI and Daemon Integration Tests", () => {
2282
2415
  const body = await response.text();
2283
2416
  expect(body).toContain("chatcmpl-stream");
2284
2417
  expect(body).toContain("[DONE]");
2418
+ expect(body).not.toContain("}data:");
2419
+ expect(body).toContain("}\n\ndata: [DONE]");
2285
2420
  expect(body).not.toContain("tokenbuddy.settlement");
2286
2421
  expect(sellerRequests.find((request) => request.url === "/v1/chat/completions" && request.body?.stream)?.idempotencyKey).toBe("idem-stream-preserved");
2287
2422
 
@@ -2302,6 +2437,35 @@ describe("TokenBuddy CLI and Daemon Integration Tests", () => {
2302
2437
  expect(publicOutput).not.toContain("chatcmpl-stream");
2303
2438
  });
2304
2439
 
2440
+ test("does not abort an active SSE stream after seller response headers arrive", async () => {
2441
+ const previousDeadline = process.env.TB_PROXYD_REQUEST_DEADLINE_MS;
2442
+ process.env.TB_PROXYD_REQUEST_DEADLINE_MS = "50";
2443
+ try {
2444
+ const response = await fetch(`http://127.0.0.1:${daemonProxyPort}/v1/chat/completions`, {
2445
+ method: "POST",
2446
+ headers: { "Content-Type": "application/json" },
2447
+ body: JSON.stringify({
2448
+ model: "gpt-4",
2449
+ stream: true,
2450
+ messages: [{ role: "user", content: "slow active stream" }],
2451
+ requestId: "stream_req_slow_after_headers"
2452
+ })
2453
+ });
2454
+
2455
+ expect(response.ok).toBe(true);
2456
+ const body = await response.text();
2457
+ expect(body).toContain("hello");
2458
+ expect(body).toContain(" later");
2459
+ expect(body).toContain("[DONE]");
2460
+ } finally {
2461
+ if (previousDeadline === undefined) {
2462
+ delete process.env.TB_PROXYD_REQUEST_DEADLINE_MS;
2463
+ } else {
2464
+ process.env.TB_PROXYD_REQUEST_DEADLINE_MS = previousDeadline;
2465
+ }
2466
+ }
2467
+ });
2468
+
2305
2469
  test("passes through responses SSE bytes unchanged for OpenAI Responses API clients", async () => {
2306
2470
  const response = await fetch(`http://127.0.0.1:${daemonProxyPort}/v1/responses`, {
2307
2471
  method: "POST",
@@ -2345,13 +2509,15 @@ describe("TokenBuddy CLI and Daemon Integration Tests", () => {
2345
2509
  });
2346
2510
  });
2347
2511
 
2348
- describe("TokenBuddy manual routing mode", () => {
2512
+ describe("TokenBuddy seller routing strategies", () => {
2349
2513
  let server: http.Server;
2350
2514
  let sellerPort: number;
2351
2515
  let daemon: TokenbuddyDaemon;
2352
2516
  let daemonProxyPort: number;
2353
2517
  let daemonControlPort: number;
2354
2518
  const events: Array<{ seller: string; url?: string }> = [];
2519
+ let primaryPurchaseSucceeds = false;
2520
+ let primaryInferenceFails = false;
2355
2521
  const dbPath = path.resolve(__dirname, "../../data-test/manual-routing-test.db");
2356
2522
 
2357
2523
  const readJsonBody = (req: http.IncomingMessage): Promise<any> => new Promise((resolve) => {
@@ -2419,11 +2585,48 @@ describe("TokenBuddy manual routing mode", () => {
2419
2585
  if (req.url === "/primary/purchase/create") {
2420
2586
  expect(body.paymentMethod).toBe("mock");
2421
2587
  events.push({ seller: "primary-seller", url: req.url });
2588
+ if (primaryPurchaseSucceeds) {
2589
+ res.end(JSON.stringify({
2590
+ purchaseId: "pur_primary_123",
2591
+ status: "pending",
2592
+ creditMicros: 2000000,
2593
+ currency: "USD",
2594
+ expiresAt: new Date(Date.now() + 86400 * 1000).toISOString()
2595
+ }));
2596
+ return;
2597
+ }
2422
2598
  res.statusCode = 503;
2423
2599
  res.end(JSON.stringify({ error: { code: "seller_unavailable" } }));
2424
2600
  return;
2425
2601
  }
2426
2602
 
2603
+ if (req.url === "/primary/purchase/complete") {
2604
+ events.push({ seller: "primary-seller", url: req.url });
2605
+ res.end(JSON.stringify({
2606
+ purchaseId: "pur_primary_123",
2607
+ status: "active",
2608
+ accessToken: "tok_primary_token_abc",
2609
+ tokenClass: "model:gpt-manual",
2610
+ creditMicros: 2000000,
2611
+ currency: "USD"
2612
+ }));
2613
+ return;
2614
+ }
2615
+
2616
+ if (req.url === "/primary/v1/chat/completions") {
2617
+ events.push({ seller: "primary-seller", url: req.url });
2618
+ if (primaryInferenceFails) {
2619
+ res.statusCode = 500;
2620
+ res.end(JSON.stringify({ error: { code: "upstream_failed", message: "primary seller failed" } }));
2621
+ return;
2622
+ }
2623
+ res.end(JSON.stringify({
2624
+ id: "primary-chat",
2625
+ usage: { prompt_tokens: 4, completion_tokens: 5 }
2626
+ }));
2627
+ return;
2628
+ }
2629
+
2427
2630
  if (req.url === "/backup/purchase/create") {
2428
2631
  expect(body.paymentMethod).toBe("mock");
2429
2632
  events.push({ seller: "backup-seller", url: req.url });
@@ -2481,6 +2684,8 @@ describe("TokenBuddy manual routing mode", () => {
2481
2684
 
2482
2685
  beforeEach(() => {
2483
2686
  events.length = 0;
2687
+ primaryPurchaseSucceeds = false;
2688
+ primaryInferenceFails = false;
2484
2689
  rmSqliteFiles(dbPath);
2485
2690
  const store = new BuyerStore({ dbPath });
2486
2691
  store.savePayment({
@@ -2496,7 +2701,11 @@ describe("TokenBuddy manual routing mode", () => {
2496
2701
  proxyPort: 0,
2497
2702
  dbPath,
2498
2703
  sellerRegistryUrl: `http://127.0.0.1:${sellerPort}/registry/sellers`,
2499
- selectionMode: "manual"
2704
+ sellerRouting: {
2705
+ mode: "fixed",
2706
+ sellerId: "primary-seller",
2707
+ scorer: "balanced"
2708
+ }
2500
2709
  });
2501
2710
  daemon.start();
2502
2711
  daemonControlPort = ((daemon as any).controlServer.address() as AddressInfo).port;
@@ -2508,9 +2717,11 @@ describe("TokenBuddy manual routing mode", () => {
2508
2717
  rmSqliteFiles(dbPath);
2509
2718
  });
2510
2719
 
2511
- test("uses only the default seller and does not prewarm or fail over to backup", async () => {
2720
+ test("fixed routing uses only the configured seller and does not fail over to backup", async () => {
2512
2721
  const status = await (await fetch(`http://127.0.0.1:${daemonControlPort}/status`)).json() as any;
2513
2722
  expect(status.selectionMode).toBe("manual");
2723
+ expect(status.sellerRoutingMode).toBe("fixed");
2724
+ expect(status.selectedSellerId).toBe("primary-seller");
2514
2725
 
2515
2726
  const response = await fetch(`http://127.0.0.1:${daemonProxyPort}/v1/chat/completions`, {
2516
2727
  method: "POST",
@@ -2537,7 +2748,7 @@ describe("TokenBuddy manual routing mode", () => {
2537
2748
  expect(inferences.inferences.some((entry: any) => entry.sellerKey === "backup-seller")).toBe(false);
2538
2749
  });
2539
2750
 
2540
- test("pins manual routing to the configured seller id when selectedSellerId is set", async () => {
2751
+ test("fixed routing pins to the configured seller id", async () => {
2541
2752
  daemon.stop();
2542
2753
  events.length = 0;
2543
2754
  daemon = new TokenbuddyDaemon({
@@ -2545,8 +2756,11 @@ describe("TokenBuddy manual routing mode", () => {
2545
2756
  proxyPort: 0,
2546
2757
  dbPath,
2547
2758
  sellerRegistryUrl: `http://127.0.0.1:${sellerPort}/registry/sellers`,
2548
- selectionMode: "manual",
2549
- selectedSellerId: "backup-seller"
2759
+ sellerRouting: {
2760
+ mode: "fixed",
2761
+ sellerId: "backup-seller",
2762
+ scorer: "balanced"
2763
+ }
2550
2764
  });
2551
2765
  daemon.start();
2552
2766
  daemonControlPort = ((daemon as any).controlServer.address() as AddressInfo).port;
@@ -2554,6 +2768,7 @@ describe("TokenBuddy manual routing mode", () => {
2554
2768
 
2555
2769
  const status = await (await fetch(`http://127.0.0.1:${daemonControlPort}/status`)).json() as any;
2556
2770
  expect(status.selectionMode).toBe("manual");
2771
+ expect(status.sellerRoutingMode).toBe("fixed");
2557
2772
  expect(status.selectedSellerId).toBe("backup-seller");
2558
2773
 
2559
2774
  const response = await fetch(`http://127.0.0.1:${daemonProxyPort}/v1/chat/completions`, {
@@ -2567,7 +2782,7 @@ describe("TokenBuddy manual routing mode", () => {
2567
2782
 
2568
2783
  expect(response.ok).toBe(true);
2569
2784
  // v1.2: the buyer no longer fetches the seller manifest per request.
2570
- // The backup-seller is selected via `selectedSellerId`; the manifest
2785
+ // The backup-seller is selected via the fixed seller routing config; the manifest
2571
2786
  // is sourced from the registry's `models` field.
2572
2787
  expect(events).toEqual([
2573
2788
  { seller: "backup-seller", url: "/backup/purchase/create" },
@@ -2575,4 +2790,203 @@ describe("TokenBuddy manual routing mode", () => {
2575
2790
  { seller: "backup-seller", url: "/backup/v1/chat/completions" }
2576
2791
  ]);
2577
2792
  });
2793
+
2794
+ test("daemon loads fixed routing from buyer runtime config", async () => {
2795
+ daemon.stop();
2796
+ events.length = 0;
2797
+ const store = new BuyerStore({ dbPath });
2798
+ store.saveDaemonRuntimeConfig("routing", {
2799
+ mode: "fixed",
2800
+ sellerId: "backup-seller",
2801
+ scorer: "balanced"
2802
+ });
2803
+ store.close();
2804
+
2805
+ daemon = new TokenbuddyDaemon({
2806
+ controlPort: 0,
2807
+ proxyPort: 0,
2808
+ dbPath,
2809
+ sellerRegistryUrl: `http://127.0.0.1:${sellerPort}/registry/sellers`
2810
+ });
2811
+ daemon.start();
2812
+ daemonControlPort = ((daemon as any).controlServer.address() as AddressInfo).port;
2813
+ daemonProxyPort = ((daemon as any).proxyServer.address() as AddressInfo).port;
2814
+
2815
+ const status = await (await fetch(`http://127.0.0.1:${daemonControlPort}/status`)).json() as any;
2816
+ expect(status.sellerRoutingMode).toBe("fixed");
2817
+ expect(status.selectedSellerId).toBe("backup-seller");
2818
+
2819
+ const response = await fetch(`http://127.0.0.1:${daemonProxyPort}/v1/chat/completions`, {
2820
+ method: "POST",
2821
+ headers: { "Content-Type": "application/json" },
2822
+ body: JSON.stringify({
2823
+ model: "gpt-manual",
2824
+ messages: [{ role: "user", content: "buyer store routing should be active" }]
2825
+ })
2826
+ });
2827
+
2828
+ expect(response.ok).toBe(true);
2829
+ expect(events).toEqual([
2830
+ { seller: "backup-seller", url: "/backup/purchase/create" },
2831
+ { seller: "backup-seller", url: "/backup/purchase/complete" },
2832
+ { seller: "backup-seller", url: "/backup/v1/chat/completions" }
2833
+ ]);
2834
+ });
2835
+
2836
+ test("fixedSet routing only uses sellers in the configured pool", async () => {
2837
+ daemon.stop();
2838
+ events.length = 0;
2839
+ daemon = new TokenbuddyDaemon({
2840
+ controlPort: 0,
2841
+ proxyPort: 0,
2842
+ dbPath,
2843
+ sellerRegistryUrl: `http://127.0.0.1:${sellerPort}/registry/sellers`,
2844
+ sellerRouting: {
2845
+ mode: "fixedSet",
2846
+ sellerIds: ["backup-seller"],
2847
+ scorer: "balanced"
2848
+ }
2849
+ });
2850
+ daemon.start();
2851
+ daemonControlPort = ((daemon as any).controlServer.address() as AddressInfo).port;
2852
+ daemonProxyPort = ((daemon as any).proxyServer.address() as AddressInfo).port;
2853
+
2854
+ const status = await (await fetch(`http://127.0.0.1:${daemonControlPort}/status`)).json() as any;
2855
+ expect(status.selectionMode).toBe("manual");
2856
+ expect(status.sellerRoutingMode).toBe("fixedSet");
2857
+ expect(status.selectedSellerId).toBeUndefined();
2858
+
2859
+ const response = await fetch(`http://127.0.0.1:${daemonProxyPort}/v1/chat/completions`, {
2860
+ method: "POST",
2861
+ headers: { "Content-Type": "application/json" },
2862
+ body: JSON.stringify({
2863
+ model: "gpt-manual",
2864
+ messages: [{ role: "user", content: "fixedSet should stay inside the configured pool" }]
2865
+ })
2866
+ });
2867
+
2868
+ expect(response.ok).toBe(true);
2869
+ expect(events).toEqual([
2870
+ { seller: "backup-seller", url: "/backup/purchase/create" },
2871
+ { seller: "backup-seller", url: "/backup/purchase/complete" },
2872
+ { seller: "backup-seller", url: "/backup/v1/chat/completions" }
2873
+ ]);
2874
+ });
2875
+
2876
+ test("fullAuto routing fails over from a 500 primary seller to the backup seller", async () => {
2877
+ daemon.stop();
2878
+ events.length = 0;
2879
+ primaryPurchaseSucceeds = true;
2880
+ primaryInferenceFails = true;
2881
+ const requestId = "auto_failover_500_log_detail";
2882
+ const rawPrompt = "raw prompt must stay out of tb-proxyd logs: tb-log-secret-500";
2883
+ daemon = new TokenbuddyDaemon({
2884
+ controlPort: 0,
2885
+ proxyPort: 0,
2886
+ dbPath,
2887
+ sellerRegistryUrl: `http://127.0.0.1:${sellerPort}/registry/sellers`,
2888
+ sellerRouting: {
2889
+ mode: "fullAuto",
2890
+ scorer: "balanced"
2891
+ }
2892
+ });
2893
+ daemon.start();
2894
+ daemonControlPort = ((daemon as any).controlServer.address() as AddressInfo).port;
2895
+ daemonProxyPort = ((daemon as any).proxyServer.address() as AddressInfo).port;
2896
+
2897
+ const status = await (await fetch(`http://127.0.0.1:${daemonControlPort}/status`)).json() as any;
2898
+ expect(status.selectionMode).toBe("auto");
2899
+ expect(status.sellerRoutingMode).toBe("fullAuto");
2900
+ expect(status.selectedSellerId).toBeUndefined();
2901
+
2902
+ const response = await fetch(`http://127.0.0.1:${daemonProxyPort}/v1/chat/completions`, {
2903
+ method: "POST",
2904
+ headers: { "Content-Type": "application/json" },
2905
+ body: JSON.stringify({
2906
+ model: "gpt-manual",
2907
+ messages: [{ role: "user", content: rawPrompt }],
2908
+ requestId
2909
+ })
2910
+ });
2911
+
2912
+ expect(response.ok).toBe(true);
2913
+ expect((await response.json() as any).id).toBe("backup-chat");
2914
+ expect(events).toEqual([
2915
+ { seller: "primary-seller", url: "/primary/purchase/create" },
2916
+ { seller: "primary-seller", url: "/primary/purchase/complete" },
2917
+ { seller: "primary-seller", url: "/primary/v1/chat/completions" },
2918
+ { seller: "primary-seller", url: "/primary/v1/chat/completions" },
2919
+ { seller: "primary-seller", url: "/primary/v1/chat/completions" },
2920
+ { seller: "backup-seller", url: "/backup/purchase/create" },
2921
+ { seller: "backup-seller", url: "/backup/purchase/complete" },
2922
+ { seller: "backup-seller", url: "/backup/v1/chat/completions" }
2923
+ ]);
2924
+
2925
+ await new Promise((resolve) => setTimeout(resolve, 100));
2926
+ const logFile = resolveModuleLogFile("tb-proxyd");
2927
+ const logs = fs.existsSync(logFile) ? fs.readFileSync(logFile, "utf8") : "";
2928
+ const requestLogs = logs
2929
+ .split("\n")
2930
+ .filter((line) => line.includes(`requestId=${requestId}`))
2931
+ .join("\n");
2932
+ expect(requestLogs).toContain("event=route.failover.retry_scheduled");
2933
+ expect(requestLogs).toContain("event=route.failover.triggered");
2934
+ expect(requestLogs).toContain("event=purchase.create.started");
2935
+ expect(requestLogs).toContain("event=purchase.ledger.recorded");
2936
+ expect(requestLogs).toContain("event=inference.ledger.recorded");
2937
+ expect(requestLogs).toContain("bodySummary=");
2938
+ expect(requestLogs).not.toContain("upstreamBody=");
2939
+ expect(logs).not.toContain(rawPrompt);
2940
+ });
2941
+
2942
+ test("fullAuto routing logs purchase failure failover before trying the backup seller", async () => {
2943
+ daemon.stop();
2944
+ events.length = 0;
2945
+ primaryPurchaseSucceeds = false;
2946
+ daemon = new TokenbuddyDaemon({
2947
+ controlPort: 0,
2948
+ proxyPort: 0,
2949
+ dbPath,
2950
+ sellerRegistryUrl: `http://127.0.0.1:${sellerPort}/registry/sellers`,
2951
+ sellerRouting: {
2952
+ mode: "fullAuto",
2953
+ scorer: "balanced"
2954
+ }
2955
+ });
2956
+ daemon.start();
2957
+ daemonControlPort = ((daemon as any).controlServer.address() as AddressInfo).port;
2958
+ daemonProxyPort = ((daemon as any).proxyServer.address() as AddressInfo).port;
2959
+
2960
+ const requestId = "auto_purchase_failover_log_detail";
2961
+ const response = await fetch(`http://127.0.0.1:${daemonProxyPort}/v1/chat/completions`, {
2962
+ method: "POST",
2963
+ headers: { "Content-Type": "application/json" },
2964
+ body: JSON.stringify({
2965
+ model: "gpt-manual",
2966
+ messages: [{ role: "user", content: "purchase failure should fail over" }],
2967
+ requestId
2968
+ })
2969
+ });
2970
+
2971
+ expect(response.ok).toBe(true);
2972
+ expect((await response.json() as any).id).toBe("backup-chat");
2973
+ expect(events).toEqual([
2974
+ { seller: "primary-seller", url: "/primary/purchase/create" },
2975
+ { seller: "backup-seller", url: "/backup/purchase/create" },
2976
+ { seller: "backup-seller", url: "/backup/purchase/complete" },
2977
+ { seller: "backup-seller", url: "/backup/v1/chat/completions" }
2978
+ ]);
2979
+
2980
+ await new Promise((resolve) => setTimeout(resolve, 100));
2981
+ const logFile = resolveModuleLogFile("tb-proxyd");
2982
+ const logs = fs.existsSync(logFile) ? fs.readFileSync(logFile, "utf8") : "";
2983
+ const requestLogs = logs
2984
+ .split("\n")
2985
+ .filter((line) => line.includes(`requestId=${requestId}`))
2986
+ .join("\n");
2987
+ expect(requestLogs).toContain("event=route.failover.triggered");
2988
+ expect(requestLogs).toContain("reason=purchase_failed");
2989
+ expect(requestLogs).toContain("controllerAction=retry_same_seller");
2990
+ expect(requestLogs).not.toContain("event=route.failover.retry_scheduled");
2991
+ });
2578
2992
  });