@tokenbuddy/tokenbuddy 1.0.12 → 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 -19
  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 -27
  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 +446 -34
  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,20 +1728,12 @@ 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 (chat completions) by default", () => {
1605
- // 锁住不变量:v1.0.12+ tb-proxy install opencode 必须默认用 @ai-sdk/openai。
1606
- // v1.0.11 改成 @ai-sdk/openai-responses 在 opencode 1.14.28 desktop 上
1607
- // 报 ProviderModelNotFoundError(SDK 是 dynamic model,opencode 1.14.x 期望静态
1608
- // languageModel 映射,schema mismatch),对 opencode 用户实际跑不通。
1609
- // 切回 @ai-sdk/openai 后走 chat completions 协议,opencode 用户能 work
1610
- // (走 tbs-719577/tbs-825edb 上游)。buyer 端 /v1/responses 协议支持仍完整,
1611
- // 等 opencode 1.15+ 支持 openai-responses custom provider 再切默认。
1736
+ test("opencode provider install uses @ai-sdk/openai-compatible for chat completions", () => {
1612
1737
  const config: ProviderRuntimeConfig = {
1613
1738
  selectionKind: "single-model",
1614
1739
  protocolPreference: "chat_completions",
@@ -1624,7 +1749,7 @@ describe("Provider install planning", () => {
1624
1749
  expect(change).toBeDefined();
1625
1750
  expect(change?.content).toBeDefined();
1626
1751
  const parsed = JSON.parse(change!.content!);
1627
- expect(parsed.provider.tokenbuddy.npm).toBe("@ai-sdk/openai");
1752
+ expect(parsed.provider.tokenbuddy.npm).toBe("@ai-sdk/openai-compatible");
1628
1753
  expect(parsed.model).toBe("tokenbuddy/gpt-5.4");
1629
1754
  expect(parsed.provider.tokenbuddy.options.baseURL).toBe("http://127.0.0.1:17821/v1");
1630
1755
  });
@@ -1803,6 +1928,12 @@ describe("TokenBuddy CLI and Daemon Integration Tests", () => {
1803
1928
  if (body.stream) {
1804
1929
  res.writeHead(200, { "Content-Type": "text/event-stream" });
1805
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
+ }
1806
1937
  res.write("event: tokenbuddy.settlement\n");
1807
1938
  res.write(`data: ${JSON.stringify({
1808
1939
  requestId: body.requestId || "stream_req_mock",
@@ -2284,6 +2415,8 @@ describe("TokenBuddy CLI and Daemon Integration Tests", () => {
2284
2415
  const body = await response.text();
2285
2416
  expect(body).toContain("chatcmpl-stream");
2286
2417
  expect(body).toContain("[DONE]");
2418
+ expect(body).not.toContain("}data:");
2419
+ expect(body).toContain("}\n\ndata: [DONE]");
2287
2420
  expect(body).not.toContain("tokenbuddy.settlement");
2288
2421
  expect(sellerRequests.find((request) => request.url === "/v1/chat/completions" && request.body?.stream)?.idempotencyKey).toBe("idem-stream-preserved");
2289
2422
 
@@ -2304,6 +2437,35 @@ describe("TokenBuddy CLI and Daemon Integration Tests", () => {
2304
2437
  expect(publicOutput).not.toContain("chatcmpl-stream");
2305
2438
  });
2306
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
+
2307
2469
  test("passes through responses SSE bytes unchanged for OpenAI Responses API clients", async () => {
2308
2470
  const response = await fetch(`http://127.0.0.1:${daemonProxyPort}/v1/responses`, {
2309
2471
  method: "POST",
@@ -2347,13 +2509,15 @@ describe("TokenBuddy CLI and Daemon Integration Tests", () => {
2347
2509
  });
2348
2510
  });
2349
2511
 
2350
- describe("TokenBuddy manual routing mode", () => {
2512
+ describe("TokenBuddy seller routing strategies", () => {
2351
2513
  let server: http.Server;
2352
2514
  let sellerPort: number;
2353
2515
  let daemon: TokenbuddyDaemon;
2354
2516
  let daemonProxyPort: number;
2355
2517
  let daemonControlPort: number;
2356
2518
  const events: Array<{ seller: string; url?: string }> = [];
2519
+ let primaryPurchaseSucceeds = false;
2520
+ let primaryInferenceFails = false;
2357
2521
  const dbPath = path.resolve(__dirname, "../../data-test/manual-routing-test.db");
2358
2522
 
2359
2523
  const readJsonBody = (req: http.IncomingMessage): Promise<any> => new Promise((resolve) => {
@@ -2421,11 +2585,48 @@ describe("TokenBuddy manual routing mode", () => {
2421
2585
  if (req.url === "/primary/purchase/create") {
2422
2586
  expect(body.paymentMethod).toBe("mock");
2423
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
+ }
2424
2598
  res.statusCode = 503;
2425
2599
  res.end(JSON.stringify({ error: { code: "seller_unavailable" } }));
2426
2600
  return;
2427
2601
  }
2428
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
+
2429
2630
  if (req.url === "/backup/purchase/create") {
2430
2631
  expect(body.paymentMethod).toBe("mock");
2431
2632
  events.push({ seller: "backup-seller", url: req.url });
@@ -2483,6 +2684,8 @@ describe("TokenBuddy manual routing mode", () => {
2483
2684
 
2484
2685
  beforeEach(() => {
2485
2686
  events.length = 0;
2687
+ primaryPurchaseSucceeds = false;
2688
+ primaryInferenceFails = false;
2486
2689
  rmSqliteFiles(dbPath);
2487
2690
  const store = new BuyerStore({ dbPath });
2488
2691
  store.savePayment({
@@ -2498,7 +2701,11 @@ describe("TokenBuddy manual routing mode", () => {
2498
2701
  proxyPort: 0,
2499
2702
  dbPath,
2500
2703
  sellerRegistryUrl: `http://127.0.0.1:${sellerPort}/registry/sellers`,
2501
- selectionMode: "manual"
2704
+ sellerRouting: {
2705
+ mode: "fixed",
2706
+ sellerId: "primary-seller",
2707
+ scorer: "balanced"
2708
+ }
2502
2709
  });
2503
2710
  daemon.start();
2504
2711
  daemonControlPort = ((daemon as any).controlServer.address() as AddressInfo).port;
@@ -2510,9 +2717,11 @@ describe("TokenBuddy manual routing mode", () => {
2510
2717
  rmSqliteFiles(dbPath);
2511
2718
  });
2512
2719
 
2513
- 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 () => {
2514
2721
  const status = await (await fetch(`http://127.0.0.1:${daemonControlPort}/status`)).json() as any;
2515
2722
  expect(status.selectionMode).toBe("manual");
2723
+ expect(status.sellerRoutingMode).toBe("fixed");
2724
+ expect(status.selectedSellerId).toBe("primary-seller");
2516
2725
 
2517
2726
  const response = await fetch(`http://127.0.0.1:${daemonProxyPort}/v1/chat/completions`, {
2518
2727
  method: "POST",
@@ -2539,7 +2748,7 @@ describe("TokenBuddy manual routing mode", () => {
2539
2748
  expect(inferences.inferences.some((entry: any) => entry.sellerKey === "backup-seller")).toBe(false);
2540
2749
  });
2541
2750
 
2542
- 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 () => {
2543
2752
  daemon.stop();
2544
2753
  events.length = 0;
2545
2754
  daemon = new TokenbuddyDaemon({
@@ -2547,8 +2756,11 @@ describe("TokenBuddy manual routing mode", () => {
2547
2756
  proxyPort: 0,
2548
2757
  dbPath,
2549
2758
  sellerRegistryUrl: `http://127.0.0.1:${sellerPort}/registry/sellers`,
2550
- selectionMode: "manual",
2551
- selectedSellerId: "backup-seller"
2759
+ sellerRouting: {
2760
+ mode: "fixed",
2761
+ sellerId: "backup-seller",
2762
+ scorer: "balanced"
2763
+ }
2552
2764
  });
2553
2765
  daemon.start();
2554
2766
  daemonControlPort = ((daemon as any).controlServer.address() as AddressInfo).port;
@@ -2556,6 +2768,7 @@ describe("TokenBuddy manual routing mode", () => {
2556
2768
 
2557
2769
  const status = await (await fetch(`http://127.0.0.1:${daemonControlPort}/status`)).json() as any;
2558
2770
  expect(status.selectionMode).toBe("manual");
2771
+ expect(status.sellerRoutingMode).toBe("fixed");
2559
2772
  expect(status.selectedSellerId).toBe("backup-seller");
2560
2773
 
2561
2774
  const response = await fetch(`http://127.0.0.1:${daemonProxyPort}/v1/chat/completions`, {
@@ -2569,7 +2782,7 @@ describe("TokenBuddy manual routing mode", () => {
2569
2782
 
2570
2783
  expect(response.ok).toBe(true);
2571
2784
  // v1.2: the buyer no longer fetches the seller manifest per request.
2572
- // The backup-seller is selected via `selectedSellerId`; the manifest
2785
+ // The backup-seller is selected via the fixed seller routing config; the manifest
2573
2786
  // is sourced from the registry's `models` field.
2574
2787
  expect(events).toEqual([
2575
2788
  { seller: "backup-seller", url: "/backup/purchase/create" },
@@ -2577,4 +2790,203 @@ describe("TokenBuddy manual routing mode", () => {
2577
2790
  { seller: "backup-seller", url: "/backup/v1/chat/completions" }
2578
2791
  ]);
2579
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
+ });
2580
2992
  });