@tokenbuddy/tokenbuddy 1.0.36 → 1.0.37

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 (143) hide show
  1. package/dist/src/buyer-store.d.ts +6 -1
  2. package/dist/src/buyer-store.js +43 -4
  3. package/dist/src/cli.js +2 -2
  4. package/dist/src/daemon.d.ts +12 -0
  5. package/dist/src/daemon.js +791 -61
  6. package/dist/src/doctor-diagnostics.js +1 -6
  7. package/dist/src/provider-install.d.ts +2 -2
  8. package/dist/src/provider-install.js +248 -2
  9. package/dist/src/seller-catalog.d.ts +21 -0
  10. package/dist/src/seller-catalog.js +17 -0
  11. package/dist/src/seller-route-planner.d.ts +4 -1
  12. package/dist/src/seller-route-planner.js +3 -0
  13. package/dist/src/seller-routing-strategy.d.ts +3 -0
  14. package/dist/src/terminal-detect.d.ts +1 -1
  15. package/dist/src/terminal-detect.js +3 -2
  16. package/package.json +15 -2
  17. package/static/ui/assets/index-Djfl9tw5.js +271 -0
  18. package/static/ui/assets/index-DkfztCkn.css +1 -0
  19. package/static/ui/index.html +2 -2
  20. package/dist/src/buyer-store.d.ts.map +0 -1
  21. package/dist/src/buyer-store.js.map +0 -1
  22. package/dist/src/clawtip-bootstrap.d.ts.map +0 -1
  23. package/dist/src/clawtip-bootstrap.js.map +0 -1
  24. package/dist/src/cli.d.ts.map +0 -1
  25. package/dist/src/cli.js.map +0 -1
  26. package/dist/src/credit-tracker.d.ts.map +0 -1
  27. package/dist/src/credit-tracker.js.map +0 -1
  28. package/dist/src/daemon.d.ts.map +0 -1
  29. package/dist/src/daemon.js.map +0 -1
  30. package/dist/src/doctor-clawtip-wallet.d.ts.map +0 -1
  31. package/dist/src/doctor-clawtip-wallet.js.map +0 -1
  32. package/dist/src/doctor-diagnostics.d.ts.map +0 -1
  33. package/dist/src/doctor-diagnostics.js.map +0 -1
  34. package/dist/src/index.d.ts.map +0 -1
  35. package/dist/src/index.js.map +0 -1
  36. package/dist/src/init-clawtip-activation.d.ts.map +0 -1
  37. package/dist/src/init-clawtip-activation.js.map +0 -1
  38. package/dist/src/init-payment-options.d.ts.map +0 -1
  39. package/dist/src/init-payment-options.js.map +0 -1
  40. package/dist/src/init-setup.d.ts.map +0 -1
  41. package/dist/src/init-setup.js.map +0 -1
  42. package/dist/src/model-index.d.ts.map +0 -1
  43. package/dist/src/model-index.js.map +0 -1
  44. package/dist/src/package-update.d.ts.map +0 -1
  45. package/dist/src/package-update.js.map +0 -1
  46. package/dist/src/prewarm-cache.d.ts.map +0 -1
  47. package/dist/src/prewarm-cache.js.map +0 -1
  48. package/dist/src/prewarm-scheduler.d.ts.map +0 -1
  49. package/dist/src/prewarm-scheduler.js.map +0 -1
  50. package/dist/src/provider-install.d.ts.map +0 -1
  51. package/dist/src/provider-install.js.map +0 -1
  52. package/dist/src/provider-routing-config.d.ts.map +0 -1
  53. package/dist/src/provider-routing-config.js.map +0 -1
  54. package/dist/src/registry-trust.d.ts.map +0 -1
  55. package/dist/src/registry-trust.js.map +0 -1
  56. package/dist/src/route-failover.d.ts.map +0 -1
  57. package/dist/src/route-failover.js.map +0 -1
  58. package/dist/src/seller-catalog.d.ts.map +0 -1
  59. package/dist/src/seller-catalog.js.map +0 -1
  60. package/dist/src/seller-concurrency-limiter.d.ts.map +0 -1
  61. package/dist/src/seller-concurrency-limiter.js.map +0 -1
  62. package/dist/src/seller-metadata-cache.d.ts.map +0 -1
  63. package/dist/src/seller-metadata-cache.js.map +0 -1
  64. package/dist/src/seller-pool.d.ts.map +0 -1
  65. package/dist/src/seller-pool.js.map +0 -1
  66. package/dist/src/seller-route-planner.d.ts.map +0 -1
  67. package/dist/src/seller-route-planner.js.map +0 -1
  68. package/dist/src/seller-routing-config.d.ts.map +0 -1
  69. package/dist/src/seller-routing-config.js.map +0 -1
  70. package/dist/src/seller-routing-strategy.d.ts.map +0 -1
  71. package/dist/src/seller-routing-strategy.js.map +0 -1
  72. package/dist/src/stream-failover.d.ts.map +0 -1
  73. package/dist/src/stream-failover.js.map +0 -1
  74. package/dist/src/tb-clawtip-proof.d.ts.map +0 -1
  75. package/dist/src/tb-clawtip-proof.js.map +0 -1
  76. package/dist/src/tb-proxyd.d.ts.map +0 -1
  77. package/dist/src/tb-proxyd.js.map +0 -1
  78. package/dist/src/terminal-detect.d.ts.map +0 -1
  79. package/dist/src/terminal-detect.js.map +0 -1
  80. package/dist/src/terminal-image.d.ts.map +0 -1
  81. package/dist/src/terminal-image.js.map +0 -1
  82. package/src/buyer-store.ts +0 -1090
  83. package/src/clawtip-bootstrap.ts +0 -65
  84. package/src/cli.ts +0 -2243
  85. package/src/credit-tracker.ts +0 -295
  86. package/src/daemon.ts +0 -5475
  87. package/src/doctor-clawtip-wallet.ts +0 -95
  88. package/src/doctor-diagnostics.ts +0 -1026
  89. package/src/index.ts +0 -16
  90. package/src/init-clawtip-activation.ts +0 -695
  91. package/src/init-payment-options.ts +0 -373
  92. package/src/init-setup.ts +0 -165
  93. package/src/model-index.ts +0 -278
  94. package/src/package-update.ts +0 -311
  95. package/src/prewarm-cache.ts +0 -485
  96. package/src/prewarm-scheduler.ts +0 -675
  97. package/src/provider-install.ts +0 -1006
  98. package/src/provider-routing-config.ts +0 -410
  99. package/src/registry-trust.ts +0 -51
  100. package/src/route-failover.ts +0 -304
  101. package/src/seller-catalog.ts +0 -505
  102. package/src/seller-concurrency-limiter.ts +0 -161
  103. package/src/seller-metadata-cache.ts +0 -91
  104. package/src/seller-pool.ts +0 -557
  105. package/src/seller-route-planner.ts +0 -513
  106. package/src/seller-routing-config.ts +0 -211
  107. package/src/seller-routing-strategy.ts +0 -362
  108. package/src/stream-failover.ts +0 -152
  109. package/src/tb-clawtip-proof.ts +0 -28
  110. package/src/tb-proxyd.ts +0 -101
  111. package/src/terminal-detect.ts +0 -333
  112. package/src/terminal-image.ts +0 -228
  113. package/static/ui/assets/index-0MVXD7bH.css +0 -1
  114. package/static/ui/assets/index-BVbeDEwq.js +0 -271
  115. package/static/ui/assets/index-BVbeDEwq.js.map +0 -1
  116. package/tests/cli-routing.test.ts +0 -363
  117. package/tests/control-plane-ui-endpoints.test.ts +0 -1630
  118. package/tests/credit-tracker.test.ts +0 -165
  119. package/tests/daemon-413-fallback.test.ts +0 -92
  120. package/tests/daemon-classify.test.ts +0 -452
  121. package/tests/daemon-roles.test.ts +0 -92
  122. package/tests/daemon-trusted-registry-cache.test.ts +0 -132
  123. package/tests/e2e.test.ts +0 -366
  124. package/tests/image-generation-e2e.test.ts +0 -230
  125. package/tests/model-index.test.ts +0 -198
  126. package/tests/package-update.test.ts +0 -147
  127. package/tests/prewarm-cache.test.ts +0 -296
  128. package/tests/prewarm-scheduler.test.ts +0 -367
  129. package/tests/provider-routing-config.test.ts +0 -150
  130. package/tests/registry-trust.test.ts +0 -28
  131. package/tests/route-failover.test.ts +0 -222
  132. package/tests/seller-catalog-413.test.ts +0 -120
  133. package/tests/seller-catalog-utilities.test.ts +0 -124
  134. package/tests/seller-concurrency-limiter.test.ts +0 -83
  135. package/tests/seller-metadata-cache.test.ts +0 -89
  136. package/tests/seller-pool.test.ts +0 -365
  137. package/tests/seller-route-planner.test.ts +0 -312
  138. package/tests/seller-routing-config.test.ts +0 -124
  139. package/tests/seller-routing-strategy.test.ts +0 -167
  140. package/tests/stream-failover.test.ts +0 -52
  141. package/tests/thousand-seller.test.ts +0 -151
  142. package/tests/tokenbuddy.test.ts +0 -4043
  143. package/tsconfig.json +0 -8
@@ -1,4043 +0,0 @@
1
- import { TokenbuddyDaemon } from "../src/daemon.js";
2
- import { BuyerStore, resolveBuyerStorePath, type PaymentConfig } from "../src/buyer-store.js";
3
- import type { ProviderRuntimeConfig } from "../src/provider-install.js";
4
- import {
5
- buildLaunchdPlistContent,
6
- buildCli,
7
- fetchClawtipBootstrap,
8
- installLaunchAgentWithRunner,
9
- normalizeClawtipBootstrapResourceUrl,
10
- restartLaunchAgent,
11
- runWebInitLauncher,
12
- } from "../src/cli.js";
13
- import {
14
- checkOpenClawRuntime,
15
- parseClawtipCliOutput,
16
- readClawtipPayCredential,
17
- resolveNpxCommand,
18
- resolveClawtipQrMediaPath,
19
- createClawtipPaymentProof,
20
- startClawtipWalletBootstrap,
21
- waitForClawtipActivationConfirmation,
22
- writeClawtipOrderFile,
23
- } from "../src/init-clawtip-activation.js";
24
- import {
25
- applyProviderInstall,
26
- detectProviders,
27
- previewProviderInstall,
28
- rollbackProviderInstall
29
- } from "../src/provider-install.js";
30
- import { PROVIDER_MODE_CONFIG_KEY } from "../src/provider-routing-config.js";
31
- import {
32
- detectTerminals,
33
- rewriteHermes,
34
- rewriteOpenclaw,
35
- } from "../src/terminal-detect.js";
36
- import {
37
- buildInitSuccessMessage,
38
- buildInitTerminalSelectionState,
39
- detectExistingClawtipBinding,
40
- inspectClawtipWalletReadiness,
41
- inspectOpenClawWalletConfig,
42
- INIT_COMING_SOON_PAYMENT_OPTIONS,
43
- INIT_PAYMENT_OPTIONS,
44
- noteInitComingSoonPayments,
45
- OTHER_TERMINAL_OPTION,
46
- validateInitTerminalSelection,
47
- } from "../src/init-payment-options.js";
48
- import { printDoctorClawtipWallet } from "../src/doctor-clawtip-wallet.js";
49
- import {
50
- detectTerminalImageDisplay,
51
- displayTerminalImage,
52
- } from "../src/terminal-image.js";
53
- import * as path from "path";
54
- import * as fs from "fs";
55
- import http from "http";
56
- import { AddressInfo } from "net";
57
- import zlib from "zlib";
58
- import { resolveModuleLogFile } from "@tokenbuddy/logging";
59
-
60
- const TEMP_BUYER_DB = path.resolve(__dirname, "../../data-test/buyer-cache-test.db");
61
- const TEMP_STORE_ROOT = path.resolve(__dirname, "../../data-test/buyer-store-test");
62
- const INSPECTION_STORE_ROOT = path.resolve(__dirname, "../../data-test/json-inspection-store");
63
- const INSPECTION_HOME = path.resolve(__dirname, "../../data-test/json-inspection-home");
64
- const PACKAGE_JSON = path.resolve(__dirname, "../package.json");
65
-
66
- function rmSqliteFiles(dbPath: string): void {
67
- for (const fileName of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) {
68
- fs.rmSync(fileName, { force: true });
69
- }
70
- }
71
-
72
- function rmDir(dirPath: string): void {
73
- fs.rmSync(dirPath, { recursive: true, force: true });
74
- }
75
-
76
- describe("TokenBuddy CLI command surface", () => {
77
- test("tb root help only exposes the approved user commands", () => {
78
- const program = buildCli();
79
- const commandNames = program.commands
80
- .map(command => command.name())
81
- .filter(command => command !== "help")
82
- .sort();
83
-
84
- expect(commandNames).toEqual(["daemon", "doctor", "init", "models", "payment", "routing", "ui", "update"]);
85
- });
86
-
87
- test("tb daemon help exposes restart", () => {
88
- const program = buildCli();
89
- const daemon = program.commands.find(command => command.name() === "daemon");
90
-
91
- expect(daemon).toBeDefined();
92
- expect(daemon!.commands.map(command => command.name()).sort()).toEqual(["restart"]);
93
- });
94
-
95
- test("tb payment help only exposes list, add, and remove", () => {
96
- const program = buildCli();
97
- const payment = program.commands.find(command => command.name() === "payment");
98
-
99
- expect(payment).toBeDefined();
100
- const help = payment!.helpInformation();
101
-
102
- for (const command of ["list", "add", "remove"]) {
103
- expect(help).toContain(command);
104
- }
105
-
106
- for (const removedCommand of ["enable", "disable", "doctor", "default"]) {
107
- expect(help).not.toContain(` ${removedCommand}`);
108
- }
109
- });
110
-
111
- test("removed top-level commands are unreachable", () => {
112
- for (const command of ["proxy", "seller", "admin", "config", "ledger"]) {
113
- const program = buildCli();
114
- program.exitOverride();
115
- program.configureOutput({ writeErr: () => undefined });
116
-
117
- expect(() => program.parse(["node", "tb", command])).toThrow(/unknown command/i);
118
- }
119
- });
120
-
121
- test("package exposes tb and tb-proxyd bins", () => {
122
- const packageJson = JSON.parse(fs.readFileSync(PACKAGE_JSON, "utf8"));
123
-
124
- expect(packageJson.bin).toEqual({
125
- tb: "bin/tb.js",
126
- "tb-proxyd": "bin/tb-proxyd.js",
127
- "tb-clawtip-proof": "bin/tb-clawtip-proof.js"
128
- });
129
- });
130
-
131
- test("tb version follows package version", () => {
132
- const packageJson = JSON.parse(fs.readFileSync(PACKAGE_JSON, "utf8"));
133
- const program = buildCli();
134
-
135
- expect(program.version()).toBe(packageJson.version);
136
- });
137
-
138
- test("launchd plist pins tb-proxyd ports and seller registry", () => {
139
- const plist = buildLaunchdPlistContent({
140
- label: "com.tokenbuddy.proxyd",
141
- nodePath: "/opt/homebrew/bin/node",
142
- scriptPath: "/opt/homebrew/bin/tb-proxyd.js",
143
- stdoutPath: "/Users/example/.tokenbuddy-store/tb-proxyd.stdout.log",
144
- stderrPath: "/Users/example/.tokenbuddy-store/tb-proxyd.stderr.log",
145
- controlPort: 17820,
146
- proxyPort: 17821,
147
- sellerRegistryUrl: "https://registry.tokenbuddy.ai/v1/registry.json",
148
- pathEnv: "/usr/bin:/bin",
149
- });
150
-
151
- expect(plist).toContain("<key>EnvironmentVariables</key>");
152
- expect(plist).toContain("<key>TB_PROXYD_CONTROL_PORT</key>");
153
- expect(plist).toContain("<string>17820</string>");
154
- expect(plist).toContain("<key>TB_PROXYD_PROXY_PORT</key>");
155
- expect(plist).toContain("<string>17821</string>");
156
- expect(plist).toContain("<key>TB_PROXYD_SELLER_REGISTRY_URL</key>");
157
- expect(plist).toContain("<string>https://registry.tokenbuddy.ai/v1/registry.json</string>");
158
- expect(plist).toContain("<key>PATH</key>");
159
- expect(plist).toContain("<string>/opt/homebrew/bin:/usr/bin:/bin</string>");
160
- });
161
-
162
- test("launchd plist can inject the ClawTip proof provider without embedding proofs", () => {
163
- const plist = buildLaunchdPlistContent({
164
- label: "com.tokenbuddy.proxyd",
165
- nodePath: "/opt/homebrew/bin/node",
166
- scriptPath: "/opt/homebrew/bin/tb-proxyd.js",
167
- stdoutPath: "/Users/example/.tokenbuddy-store/tb-proxyd.stdout.log",
168
- stderrPath: "/Users/example/.tokenbuddy-store/tb-proxyd.stderr.log",
169
- controlPort: 17820,
170
- proxyPort: 17821,
171
- sellerRegistryUrl: "https://registry.tokenbuddy.ai/v1/registry.json",
172
- clawtipProofCommand: "/Users/example/bin/tokenbuddy-clawtip-proof-openclaw.sh",
173
- clawtipProofTimeoutMs: 180000,
174
- });
175
-
176
- expect(plist).toContain("<key>TB_PROXYD_CLAWTIP_PROOF_COMMAND</key>");
177
- expect(plist).toContain("<string>/Users/example/bin/tokenbuddy-clawtip-proof-openclaw.sh</string>");
178
- expect(plist).toContain("<key>TB_PROXYD_CLAWTIP_PROOF_TIMEOUT_MS</key>");
179
- expect(plist).toContain("<string>180000</string>");
180
- expect(plist).not.toContain("payCredential");
181
- expect(plist).not.toContain("PAYMENT_PROOF");
182
- });
183
-
184
- test("web init launcher installs the default ClawTip proof provider into launchd", async () => {
185
- const writtenFiles: Array<{ filePath: string; content: string }> = [];
186
- const result = await runWebInitLauncher({
187
- platform: "darwin",
188
- controlPort: 3210,
189
- proxyPort: 3211,
190
- sellerRegistryUrl: "https://registry.example.test/sellers",
191
- homeDir: "/Users/example",
192
- nodePath: "/opt/node",
193
- scriptPath: "/opt/tokenbuddy/dist/src/tb-proxyd.js",
194
- pathEnv: "/usr/bin:/bin",
195
- mkdirSync: () => undefined,
196
- writeFileSync: (filePath, content) => {
197
- writtenFiles.push({ filePath, content });
198
- },
199
- installLaunchAgent: () => undefined,
200
- waitForDaemonStatus: async () => ({
201
- running: true,
202
- status: { pid: 42, controlPort: 3210, proxyPort: 3211 }
203
- }),
204
- fetchInitState: async () => ({
205
- freshMachine: true,
206
- setup: { status: "not_started", version: 1, completedSteps: [] }
207
- }),
208
- launchControlUi: () => "http://127.0.0.1:3210/init"
209
- });
210
-
211
- expect(result.serviceInstalled).toBe(true);
212
- expect(writtenFiles[0].content).toContain("<key>TB_PROXYD_CLAWTIP_PROOF_COMMAND</key>");
213
- expect(writtenFiles[0].content).toContain("tb-clawtip-proof.js");
214
- });
215
-
216
- test("installLaunchAgent reloads an existing LaunchAgent so new plist env takes effect", () => {
217
- const calls: Array<{ args: string[]; ignoreFailure?: boolean }> = [];
218
- let bootstrapAttempts = 0;
219
- installLaunchAgentWithRunner("/Users/example/Library/LaunchAgents/com.tokenbuddy.proxyd.plist", "com.tokenbuddy.proxyd", (args, ignoreFailure) => {
220
- calls.push({ args, ignoreFailure });
221
- if (args[0] === "bootstrap") {
222
- bootstrapAttempts += 1;
223
- if (bootstrapAttempts === 1) {
224
- throw new Error("launchctl bootstrap transient failure");
225
- }
226
- }
227
- });
228
-
229
- expect(calls).toEqual([
230
- { args: ["bootout", expect.stringMatching(/^gui\/\d+\/com\.tokenbuddy\.tb-proxyd$/)], ignoreFailure: true },
231
- { args: ["bootout", expect.stringMatching(/^gui\/\d+\/homebrew\.mxcl\.tokenbuddy$/)], ignoreFailure: true },
232
- { args: ["print", expect.stringMatching(/^gui\/\d+\/com\.tokenbuddy\.proxyd$/)], ignoreFailure: undefined },
233
- { args: ["bootout", expect.stringMatching(/^gui\/\d+\/com\.tokenbuddy\.proxyd$/)], ignoreFailure: true },
234
- { args: ["bootstrap", expect.stringMatching(/^gui\/\d+$/), "/Users/example/Library/LaunchAgents/com.tokenbuddy.proxyd.plist"], ignoreFailure: undefined },
235
- { args: ["bootstrap", expect.stringMatching(/^gui\/\d+$/), "/Users/example/Library/LaunchAgents/com.tokenbuddy.proxyd.plist"], ignoreFailure: undefined },
236
- { args: ["kickstart", "-k", expect.stringMatching(/^gui\/\d+\/com\.tokenbuddy\.proxyd$/)], ignoreFailure: undefined },
237
- ]);
238
- expect(bootstrapAttempts).toBe(2);
239
- });
240
-
241
- test("restartLaunchAgent kickstarts the installed LaunchAgent and waits for readiness", async () => {
242
- const launchctlCalls: string[][] = [];
243
- const result = await restartLaunchAgent(17820, {
244
- platform: "darwin",
245
- plistPath: "/Users/example/Library/LaunchAgents/com.tokenbuddy.proxyd.plist",
246
- existsSync: () => true,
247
- runLaunchctl: (args) => {
248
- launchctlCalls.push(args);
249
- },
250
- probeDaemonStatus: async () => ({
251
- running: true,
252
- status: { pid: 100, controlPort: 17820, proxyPort: 17821 }
253
- }),
254
- waitForDaemonStatus: async () => ({
255
- running: true,
256
- status: { pid: 200, controlPort: 17820, proxyPort: 17821 }
257
- })
258
- });
259
-
260
- expect(result).toMatchObject({
261
- attempted: true,
262
- restarted: true,
263
- method: "launchd",
264
- plistPath: "/Users/example/Library/LaunchAgents/com.tokenbuddy.proxyd.plist",
265
- after: {
266
- running: true,
267
- status: { pid: 200 }
268
- }
269
- });
270
- expect(launchctlCalls).toEqual([[
271
- "kickstart",
272
- "-k",
273
- expect.stringMatching(/^gui\/\d+\/com\.tokenbuddy\.proxyd$/)
274
- ]]);
275
- });
276
-
277
- test("restartLaunchAgent reports missing LaunchAgent plist without calling launchctl", async () => {
278
- const launchctlCalls: string[][] = [];
279
- const result = await restartLaunchAgent(17820, {
280
- platform: "darwin",
281
- plistPath: "/Users/example/Library/LaunchAgents/com.tokenbuddy.proxyd.plist",
282
- existsSync: () => false,
283
- runLaunchctl: (args) => {
284
- launchctlCalls.push(args);
285
- },
286
- probeDaemonStatus: async () => ({ running: false, error: "offline" }),
287
- waitForDaemonStatus: async () => ({ running: false, error: "not called" })
288
- });
289
-
290
- expect(result).toMatchObject({
291
- attempted: false,
292
- restarted: false,
293
- method: "launchd",
294
- error: expect.stringContaining("tb init")
295
- });
296
- expect(launchctlCalls).toEqual([]);
297
- });
298
-
299
- test("web init launcher installs the macOS LaunchAgent and opens /init", async () => {
300
- const writtenFiles: Array<{ filePath: string; content: string }> = [];
301
- const installed: Array<{ plistPath: string; label: string }> = [];
302
- const createdDirs: string[] = [];
303
- const opened: Array<{ controlPort: number; pathname?: string }> = [];
304
- const result = await runWebInitLauncher({
305
- platform: "darwin",
306
- controlPort: 3210,
307
- proxyPort: 3211,
308
- sellerRegistryUrl: "https://registry.example.test/sellers",
309
- homeDir: "/Users/example",
310
- nodePath: "/opt/node",
311
- scriptPath: "/opt/tb-proxyd.js",
312
- pathEnv: "/usr/bin:/bin",
313
- mkdirSync: (dirPath) => {
314
- createdDirs.push(dirPath);
315
- },
316
- writeFileSync: (filePath, content) => {
317
- writtenFiles.push({ filePath, content });
318
- },
319
- installLaunchAgent: (plistPath, label) => {
320
- installed.push({ plistPath, label });
321
- },
322
- waitForDaemonStatus: async () => ({
323
- running: true,
324
- status: { pid: 42, controlPort: 3210, proxyPort: 3211 }
325
- }),
326
- fetchInitState: async () => ({ freshMachine: true, setup: { status: "not_started" } }),
327
- launchControlUi: (controlPort, pathname) => {
328
- opened.push({ controlPort, pathname });
329
- return `http://127.0.0.1:${controlPort}${pathname}`;
330
- }
331
- });
332
-
333
- expect(result).toMatchObject({
334
- method: "launchd",
335
- controlPort: 3210,
336
- proxyPort: 3211,
337
- serviceInstalled: true,
338
- url: "http://127.0.0.1:3210/init",
339
- probe: { running: true }
340
- });
341
- expect(result.plistPath).toBe("/Users/example/Library/LaunchAgents/com.tokenbuddy.proxyd.plist");
342
- expect(installed).toEqual([{
343
- plistPath: "/Users/example/Library/LaunchAgents/com.tokenbuddy.proxyd.plist",
344
- label: "com.tokenbuddy.proxyd"
345
- }]);
346
- expect(createdDirs).toEqual(expect.arrayContaining([
347
- "/Users/example/Library/LaunchAgents",
348
- "/Users/example/.tokenbuddy-store"
349
- ]));
350
- expect(writtenFiles[0].content).toContain("<string>3210</string>");
351
- expect(writtenFiles[0].content).toContain("<string>3211</string>");
352
- expect(writtenFiles[0].content).toContain("https://registry.example.test/sellers");
353
- expect(writtenFiles[0].content).toContain("<key>PATH</key>");
354
- expect(writtenFiles[0].content).toContain("<string>/opt:/usr/bin:/bin</string>");
355
- expect(opened).toEqual([{ controlPort: 3210, pathname: "/init" }]);
356
- });
357
-
358
- test("web init launcher recovers when launchctl fails after the service becomes ready", async () => {
359
- const opened: Array<{ controlPort: number; pathname?: string }> = [];
360
- const result = await runWebInitLauncher({
361
- platform: "darwin",
362
- controlPort: 3220,
363
- proxyPort: 3221,
364
- homeDir: "/Users/example",
365
- nodePath: "/opt/node",
366
- scriptPath: "/opt/tb-proxyd.js",
367
- mkdirSync: () => undefined,
368
- writeFileSync: () => undefined,
369
- installLaunchAgent: () => {
370
- throw new Error("launchctl bootstrap failed");
371
- },
372
- waitForDaemonStatus: async () => ({
373
- running: true,
374
- status: { pid: 43, controlPort: 3220, proxyPort: 3221 }
375
- }),
376
- fetchInitState: async () => ({ freshMachine: false, repairMode: false, setup: { status: "completed" } }),
377
- launchControlUi: (controlPort, pathname) => {
378
- opened.push({ controlPort, pathname });
379
- return `http://127.0.0.1:${controlPort}${pathname}`;
380
- }
381
- });
382
-
383
- expect(result).toMatchObject({
384
- method: "launchd",
385
- controlPort: 3220,
386
- proxyPort: 3221,
387
- serviceInstalled: true,
388
- url: "http://127.0.0.1:3220/overview",
389
- probe: { running: true }
390
- });
391
- expect(result.error).toBeUndefined();
392
- expect(opened).toEqual([{ controlPort: 3220, pathname: "/overview" }]);
393
- });
394
-
395
- test("web init launcher opens overview after setup is completed", async () => {
396
- const opened: Array<{ controlPort: number; pathname?: string }> = [];
397
- const result = await runWebInitLauncher({
398
- platform: "linux",
399
- controlPort: 4340,
400
- proxyPort: 4341,
401
- repairDaemon: async () => ({
402
- repair: { attempted: false, fixed: false },
403
- probe: { running: true, status: { pid: 101 } }
404
- }),
405
- fetchInitState: async () => ({ freshMachine: false, repairMode: false, setup: { status: "completed" } }),
406
- launchControlUi: (controlPort, pathname) => {
407
- opened.push({ controlPort, pathname });
408
- return `http://127.0.0.1:${controlPort}${pathname}`;
409
- }
410
- });
411
-
412
- expect(result).toMatchObject({
413
- method: "detached",
414
- controlPort: 4340,
415
- proxyPort: 4341,
416
- serviceInstalled: true,
417
- url: "http://127.0.0.1:4340/overview",
418
- probe: { running: true }
419
- });
420
- expect(opened).toEqual([{ controlPort: 4340, pathname: "/overview" }]);
421
- });
422
-
423
- test("web init launcher reopens /init when completed setup needs repair", async () => {
424
- const opened: Array<{ controlPort: number; pathname?: string }> = [];
425
- const result = await runWebInitLauncher({
426
- platform: "linux",
427
- controlPort: 4350,
428
- proxyPort: 4351,
429
- repairDaemon: async () => ({
430
- repair: { attempted: false, fixed: false },
431
- probe: { running: true, status: { pid: 102 } }
432
- }),
433
- fetchInitState: async () => ({
434
- freshMachine: false,
435
- repairMode: true,
436
- setup: { status: "completed" }
437
- }),
438
- launchControlUi: (controlPort, pathname) => {
439
- opened.push({ controlPort, pathname });
440
- return `http://127.0.0.1:${controlPort}${pathname}`;
441
- }
442
- });
443
-
444
- expect(result).toMatchObject({
445
- method: "detached",
446
- controlPort: 4350,
447
- proxyPort: 4351,
448
- serviceInstalled: true,
449
- url: "http://127.0.0.1:4350/init",
450
- probe: { running: true }
451
- });
452
- expect(opened).toEqual([{ controlPort: 4350, pathname: "/init" }]);
453
- });
454
-
455
- test("web init launcher uses detached startup on non-macOS and reports failures without opening a browser", async () => {
456
- const opened: Array<{ controlPort: number; pathname?: string }> = [];
457
- const failed = await runWebInitLauncher({
458
- platform: "linux",
459
- controlPort: 4320,
460
- proxyPort: 4321,
461
- repairDaemon: async () => ({
462
- repair: { attempted: true, fixed: false, error: "spawn failed" },
463
- probe: { running: false, error: "offline" }
464
- }),
465
- launchControlUi: (controlPort, pathname) => {
466
- opened.push({ controlPort, pathname });
467
- return `http://127.0.0.1:${controlPort}${pathname}`;
468
- }
469
- });
470
-
471
- expect(failed).toMatchObject({
472
- method: "detached",
473
- controlPort: 4320,
474
- proxyPort: 4321,
475
- serviceInstalled: false,
476
- url: "http://127.0.0.1:4320/init",
477
- probe: { running: false },
478
- error: "spawn failed"
479
- });
480
- expect(opened).toEqual([]);
481
-
482
- const openedAfterSuccess: Array<{ controlPort: number; pathname?: string }> = [];
483
- const succeeded = await runWebInitLauncher({
484
- platform: "linux",
485
- controlPort: 4330,
486
- proxyPort: 4331,
487
- repairDaemon: async () => ({
488
- repair: { attempted: true, fixed: true, pid: 99 },
489
- probe: { running: true, status: { pid: 99 } }
490
- }),
491
- fetchInitState: async () => ({ freshMachine: true, setup: { status: "not_started" } }),
492
- launchControlUi: (controlPort, pathname) => {
493
- openedAfterSuccess.push({ controlPort, pathname });
494
- return `http://127.0.0.1:${controlPort}${pathname}`;
495
- }
496
- });
497
-
498
- expect(succeeded).toMatchObject({
499
- method: "detached",
500
- controlPort: 4330,
501
- proxyPort: 4331,
502
- serviceInstalled: true,
503
- url: "http://127.0.0.1:4330/init",
504
- probe: { running: true },
505
- repair: { attempted: true, fixed: true, pid: 99 }
506
- });
507
- expect(openedAfterSuccess).toEqual([{ controlPort: 4330, pathname: "/init" }]);
508
- });
509
- });
510
-
511
- describe("BuyerStore safe SQLite persistence", () => {
512
- let store: BuyerStore;
513
-
514
- beforeEach(() => {
515
- rmDir(TEMP_STORE_ROOT);
516
- store = new BuyerStore({ root: TEMP_STORE_ROOT });
517
- });
518
-
519
- afterEach(() => {
520
- store.close();
521
- rmDir(TEMP_STORE_ROOT);
522
- });
523
-
524
- test("resolves TOKENBUDDY_BUYER_STORE and enables WAL", () => {
525
- const previousStoreRoot = process.env.TOKENBUDDY_BUYER_STORE;
526
- process.env.TOKENBUDDY_BUYER_STORE = TEMP_STORE_ROOT;
527
- try {
528
- expect(resolveBuyerStorePath()).toBe(path.join(TEMP_STORE_ROOT, "buyer-store.db"));
529
- } finally {
530
- if (previousStoreRoot === undefined) {
531
- delete process.env.TOKENBUDDY_BUYER_STORE;
532
- } else {
533
- process.env.TOKENBUDDY_BUYER_STORE = previousStoreRoot;
534
- }
535
- }
536
-
537
- expect(store.journalMode()).toBe("wal");
538
- expect(fs.existsSync(path.join(TEMP_STORE_ROOT, "buyer-store.db"))).toBe(true);
539
- });
540
-
541
- test("keeps token cache behavior behind the buyer store boundary", () => {
542
- expect(store.getToken("seller-a")).toBeUndefined();
543
-
544
- store.saveToken("seller-a", "raw-token-secret", "model:gpt-4", 500000, "2030-01-01T00:00:00.000Z");
545
- expect(store.getToken("seller-a")).toMatchObject({
546
- token: "raw-token-secret",
547
- balanceMicros: 500000,
548
- reservedMicros: 0,
549
- spentMicros: 0,
550
- balanceSource: "purchase_complete"
551
- });
552
-
553
- store.reconcileTokenBalance({
554
- sellerKey: "seller-a",
555
- balanceMicros: 499890,
556
- reservedMicros: 0,
557
- spentMicros: 110,
558
- balanceSource: "seller_settlement_summary"
559
- });
560
- expect(store.getToken("seller-a")).toMatchObject({
561
- token: "raw-token-secret",
562
- balanceMicros: 499890,
563
- reservedMicros: 0,
564
- spentMicros: 110,
565
- balanceSource: "seller_settlement_summary"
566
- });
567
- });
568
-
569
- test("getToken surfaces expiresAt so the daemon can reject stale tokens", () => {
570
- const futureIso = "2030-01-01T00:00:00.000Z";
571
- store.saveToken("seller-exp", "raw-token-secret", "model:gpt-4", 1_000_000, futureIso);
572
- expect(store.getToken("seller-exp")?.expiresAt).toBe(futureIso);
573
-
574
- // v1.2 PR-fix: when `saveToken` is invoked, `expiresAt` is
575
- // persisted; the daemon reads it via `getToken().expiresAt` to
576
- // refuse cached tokens inside the safety margin. This test pins
577
- // the field name so a future rename can't silently drop the
578
- // buyer-side expiry check.
579
- expect(store.getToken("seller-exp")).toMatchObject({
580
- token: "raw-token-secret",
581
- expiresAt: futureIso
582
- });
583
- });
584
-
585
- test("returns stable empty state for payments, pending purchases, and ledgers", () => {
586
- expect(store.listPayments()).toEqual([]);
587
- expect(store.listPendingPurchases()).toEqual([]);
588
- expect(store.listPurchaseLedger()).toEqual([]);
589
- expect(store.listInferenceLedger()).toEqual([]);
590
- expect(store.summary()).toMatchObject({
591
- providerRuntimeConfigCount: 0,
592
- daemonRuntimeConfigCount: 0,
593
- });
594
- });
595
-
596
- test("stores provider runtime config and daemon routing config in the buyer store", () => {
597
- store.saveProviderRuntimeConfig("opencode", {
598
- selectionKind: "single-model",
599
- protocolPreference: "responses",
600
- defaultModel: "gpt-5.5",
601
- });
602
- store.saveDaemonRuntimeConfig("routing", {
603
- mode: "fixed",
604
- sellerId: "seller-a",
605
- });
606
-
607
- expect(store.getProviderRuntimeConfig("opencode")).toMatchObject({
608
- providerId: "opencode",
609
- config: expect.objectContaining({
610
- defaultModel: "gpt-5.5",
611
- }),
612
- });
613
- expect(store.getDaemonRuntimeConfig("routing")).toMatchObject({
614
- configKey: "routing",
615
- config: expect.objectContaining({
616
- mode: "fixed",
617
- sellerId: "seller-a",
618
- }),
619
- });
620
- expect(store.summary()).toMatchObject({
621
- providerRuntimeConfigCount: 1,
622
- daemonRuntimeConfigCount: 1,
623
- });
624
-
625
- expect(store.removeProviderRuntimeConfig("opencode")).toBe(true);
626
- expect(store.removeDaemonRuntimeConfig("routing")).toBe(true);
627
- expect(store.getProviderRuntimeConfig("opencode")).toBeUndefined();
628
- expect(store.getDaemonRuntimeConfig("routing")).toBeUndefined();
629
- });
630
-
631
- test("stores payment config and pending purchases with safe references", () => {
632
- store.savePayment({
633
- method: "mock",
634
- enabled: true,
635
- isDefault: true,
636
- config: { channel: "developer" }
637
- });
638
- store.upsertPendingPurchase({
639
- purchaseId: "pur_pending_1",
640
- sellerKey: "seller-a",
641
- modelId: "gpt-4",
642
- paymentMethod: "mock",
643
- amountUsdMicros: 1000000,
644
- status: "pending",
645
- paymentReference: "payCredential-secret",
646
- expiresAt: "2030-01-01T00:00:00.000Z"
647
- });
648
-
649
- expect(store.listPayments()).toMatchObject([
650
- { method: "mock", enabled: true, isDefault: true, config: { channel: "developer" } }
651
- ]);
652
- const pendingPurchases = store.listPendingPurchases();
653
- expect(pendingPurchases).toMatchObject([
654
- {
655
- purchaseId: "pur_pending_1",
656
- sellerKey: "seller-a"
657
- }
658
- ]);
659
- expect(pendingPurchases[0]).not.toHaveProperty("paymentReference");
660
- const serialized = JSON.stringify(pendingPurchases);
661
- expect(serialized).not.toContain("payCredential-secret");
662
- expect(serialized).toContain("paymentReferenceHash");
663
- });
664
-
665
- test("fetches and removes payment config by method", () => {
666
- store.savePayment({
667
- method: "mock",
668
- enabled: true,
669
- isDefault: true,
670
- config: { channel: "developer" }
671
- });
672
-
673
- expect(store.getPayment("mock")).toMatchObject({
674
- method: "mock",
675
- enabled: true,
676
- isDefault: true,
677
- config: { channel: "developer" }
678
- });
679
- expect(store.removePayment("mock")).toBe(true);
680
- expect(store.getPayment("mock")).toBeUndefined();
681
- expect(store.removePayment("mock")).toBe(false);
682
- });
683
-
684
- test("redacts raw proof, prompt, and response from safe ledger output", () => {
685
- store.recordPurchaseLedger({
686
- purchaseId: "pur_done_1",
687
- sellerKey: "seller-a",
688
- modelId: "gpt-4",
689
- paymentMethod: "mock",
690
- status: "funded",
691
- creditMicros: 1000000,
692
- currency: "USD",
693
- paymentReference: "raw-payment-proof-secret"
694
- });
695
- store.recordInferenceLedger({
696
- requestId: "req_1",
697
- sellerKey: "seller-a",
698
- modelId: "gpt-4",
699
- endpoint: "/v1/chat/completions",
700
- status: "settled",
701
- promptTokens: 10,
702
- completionTokens: 20,
703
- cacheReadTokens: 4,
704
- billedMicros: 70,
705
- settledUsdMicros: 35,
706
- inputPriceMicrosPer1m: 5_000_000,
707
- outputPriceMicrosPer1m: 30_000_000,
708
- cacheReadPriceMicrosPer1m: 500_000,
709
- inputCostMicros: 50,
710
- outputCostMicros: 600,
711
- cacheReadCostMicros: 2,
712
- originalUsdMicros: 652,
713
- billingMultiplier: 0.1,
714
- serviceTier: "Standard",
715
- prompt: "raw prompt secret",
716
- response: "raw model response secret"
717
- });
718
-
719
- const publicOutput = JSON.stringify({
720
- purchases: store.listPurchaseLedger(),
721
- inferences: store.listInferenceLedger()
722
- });
723
-
724
- for (const secret of [
725
- "raw-payment-proof-secret",
726
- "raw prompt secret",
727
- "raw model response secret",
728
- "payCredential"
729
- ]) {
730
- expect(publicOutput).not.toContain(secret);
731
- }
732
- expect(publicOutput).toContain("paymentReferenceHash");
733
- expect(publicOutput).toContain("promptHash");
734
- expect(publicOutput).toContain("responseHash");
735
- expect(publicOutput).toContain("\"cacheReadTokens\":4");
736
- expect(publicOutput).toContain("\"inputPriceMicrosPer1m\":5000000");
737
- expect(publicOutput).toContain("\"outputCostMicros\":600");
738
- expect(publicOutput).toContain("\"originalUsdMicros\":652");
739
- expect(publicOutput).toContain("\"billingMultiplier\":0.1");
740
- expect(publicOutput).toContain("\"serviceTier\":\"Standard\"");
741
- });
742
- });
743
-
744
- describe("TokenBuddy payment CLI", () => {
745
- const CLI_STORE_ROOT = path.resolve(__dirname, "../../data-test/payment-cli-store");
746
- let previousBuyerStore: string | undefined;
747
- let previousExitCode: string | number | undefined;
748
- let previousControlPort: string | undefined;
749
- let previousProxyPort: string | undefined;
750
- let controlServer: http.Server;
751
- let controlServerOpen = false;
752
- let controlPort: number;
753
-
754
- beforeEach((done) => {
755
- rmDir(CLI_STORE_ROOT);
756
- previousBuyerStore = process.env.TOKENBUDDY_BUYER_STORE;
757
- previousExitCode = process.exitCode;
758
- previousControlPort = process.env.TB_PROXYD_CONTROL_PORT;
759
- previousProxyPort = process.env.TB_PROXYD_PROXY_PORT;
760
- process.env.TOKENBUDDY_BUYER_STORE = CLI_STORE_ROOT;
761
- process.exitCode = undefined;
762
- controlServer = http.createServer((req, res) => {
763
- res.setHeader("Content-Type", "application/json");
764
- if (req.url === "/status") {
765
- res.end(JSON.stringify({
766
- status: "running",
767
- pid: 12345,
768
- controlPort,
769
- proxyPort: 45678
770
- }));
771
- return;
772
- }
773
- res.statusCode = 404;
774
- res.end(JSON.stringify({ error: "not_found" }));
775
- });
776
- controlServer.listen(0, "127.0.0.1", () => {
777
- controlServerOpen = true;
778
- controlPort = (controlServer.address() as AddressInfo).port;
779
- process.env.TB_PROXYD_CONTROL_PORT = String(controlPort);
780
- process.env.TB_PROXYD_PROXY_PORT = "45678";
781
- done();
782
- });
783
- });
784
-
785
- afterEach((done) => {
786
- if (previousBuyerStore === undefined) {
787
- delete process.env.TOKENBUDDY_BUYER_STORE;
788
- } else {
789
- process.env.TOKENBUDDY_BUYER_STORE = previousBuyerStore;
790
- }
791
- if (previousControlPort === undefined) {
792
- delete process.env.TB_PROXYD_CONTROL_PORT;
793
- } else {
794
- process.env.TB_PROXYD_CONTROL_PORT = previousControlPort;
795
- }
796
- if (previousProxyPort === undefined) {
797
- delete process.env.TB_PROXYD_PROXY_PORT;
798
- } else {
799
- process.env.TB_PROXYD_PROXY_PORT = previousProxyPort;
800
- }
801
- process.exitCode = previousExitCode;
802
- rmDir(CLI_STORE_ROOT);
803
- jest.restoreAllMocks();
804
- if (controlServerOpen) {
805
- controlServer.close(() => {
806
- controlServerOpen = false;
807
- done();
808
- });
809
- } else {
810
- done();
811
- }
812
- });
813
-
814
- test("ordinary commands fail closed when tb-proxyd is not running", async () => {
815
- await new Promise<void>((resolve) => controlServer.close(() => {
816
- controlServerOpen = false;
817
- resolve();
818
- }));
819
- const errors: string[] = [];
820
- const program = buildCli();
821
- program.exitOverride();
822
- program.configureOutput({ writeErr: () => undefined });
823
- jest.spyOn(console, "error").mockImplementation((message?: unknown) => {
824
- errors.push(String(message));
825
- });
826
-
827
- await expect(program.parseAsync(["node", "tb", "payment", "list", "--json"])).rejects.toThrow("tb-proxyd is not running");
828
- expect(errors.join("\n")).toContain("tb doctor --fix");
829
- expect(errors.join("\n")).toContain("tb init");
830
- });
831
-
832
- test("payment list --json emits pure JSON with supported methods", async () => {
833
- const store = new BuyerStore({ root: CLI_STORE_ROOT });
834
- store.savePayment({
835
- method: "mock",
836
- enabled: true,
837
- isDefault: true,
838
- config: { channel: "developer", explicitOptIn: true }
839
- });
840
- store.close();
841
-
842
- const output: string[] = [];
843
- jest.spyOn(console, "log").mockImplementation((message?: unknown) => {
844
- output.push(String(message));
845
- });
846
-
847
- const program = buildCli();
848
- await program.parseAsync(["node", "tb", "payment", "list", "--json"]);
849
-
850
- expect(output).toHaveLength(1);
851
- const parsed = JSON.parse(output[0]) as any;
852
- expect(parsed.payments).toEqual(expect.arrayContaining([
853
- expect.objectContaining({
854
- method: "mock",
855
- supported: true,
856
- configured: true,
857
- enabled: true,
858
- isDefault: true
859
- }),
860
- expect.objectContaining({
861
- method: "clawtip",
862
- supported: true,
863
- configured: false
864
- })
865
- ]));
866
- expect(output[0]).not.toContain("payCredential");
867
- expect(output[0]).not.toContain("encryptedData");
868
- });
869
-
870
- test("payment add/remove mock updates the buyer store", async () => {
871
- const logs: string[] = [];
872
- jest.spyOn(console, "log").mockImplementation((message?: unknown) => {
873
- logs.push(String(message));
874
- });
875
-
876
- await buildCli().parseAsync(["node", "tb", "payment", "add", "mock"]);
877
- let store = new BuyerStore({ root: CLI_STORE_ROOT });
878
- expect(store.getPayment("mock")).toMatchObject({
879
- method: "mock",
880
- enabled: true,
881
- isDefault: true,
882
- config: { channel: "developer", explicitOptIn: true }
883
- });
884
- store.close();
885
-
886
- await buildCli().parseAsync(["node", "tb", "payment", "remove", "mock"]);
887
- store = new BuyerStore({ root: CLI_STORE_ROOT });
888
- expect(store.getPayment("mock")).toBeUndefined();
889
- store.close();
890
- expect(logs.join("\n")).toContain("Mock payment method registered");
891
- expect(logs.join("\n")).toContain("removed");
892
- });
893
-
894
- test("routing set/show updates buyer routing config without requiring tb-proxyd", async () => {
895
- await new Promise<void>((resolve) => controlServer.close(() => {
896
- controlServerOpen = false;
897
- resolve();
898
- }));
899
- const output: string[] = [];
900
- jest.spyOn(console, "log").mockImplementation((message?: unknown) => {
901
- output.push(String(message));
902
- });
903
-
904
- await buildCli().parseAsync([
905
- "node",
906
- "tb",
907
- "routing",
908
- "set",
909
- "fixedSet",
910
- "--seller-set",
911
- "seller-a,seller-b",
912
- "--scorer",
913
- "speed"
914
- ]);
915
-
916
- const store = new BuyerStore({ root: CLI_STORE_ROOT });
917
- expect(store.getDaemonRuntimeConfig("routing")).toMatchObject({
918
- config: {
919
- mode: "fixedSet",
920
- sellerIds: ["seller-a", "seller-b"],
921
- scorer: "speed"
922
- }
923
- });
924
- store.close();
925
-
926
- output.length = 0;
927
- await buildCli().parseAsync(["node", "tb", "routing", "show", "--json"]);
928
- const parsed = JSON.parse(output[0]) as any;
929
- expect(parsed.routing).toEqual({
930
- mode: "fixedSet",
931
- sellerIds: ["seller-a", "seller-b"],
932
- scorer: "speed"
933
- });
934
-
935
- await buildCli().parseAsync([
936
- "node",
937
- "tb",
938
- "routing",
939
- "set",
940
- "fixed",
941
- "--seller",
942
- "seller-c",
943
- "--scorer",
944
- "discount"
945
- ]);
946
- let refreshed = new BuyerStore({ root: CLI_STORE_ROOT });
947
- expect(refreshed.getDaemonRuntimeConfig("routing")).toMatchObject({
948
- config: {
949
- mode: "fixed",
950
- sellerId: "seller-c",
951
- scorer: "discount"
952
- }
953
- });
954
- refreshed.close();
955
-
956
- await buildCli().parseAsync([
957
- "node",
958
- "tb",
959
- "routing",
960
- "set",
961
- "fullAuto",
962
- "--scorer",
963
- "balanced"
964
- ]);
965
- refreshed = new BuyerStore({ root: CLI_STORE_ROOT });
966
- expect(refreshed.getDaemonRuntimeConfig("routing")).toMatchObject({
967
- config: {
968
- mode: "fullAuto",
969
- scorer: "balanced"
970
- }
971
- });
972
- refreshed.close();
973
- });
974
- });
975
-
976
- describe("TokenBuddy init payment options", () => {
977
- test("init hides mock payment and shows coming-soon agent wallets as unavailable", () => {
978
- const noteMessages: string[] = [];
979
- noteInitComingSoonPayments((message?: string) => {
980
- noteMessages.push(String(message || ""));
981
- });
982
-
983
- expect(INIT_PAYMENT_OPTIONS).toEqual([
984
- expect.objectContaining({ value: "clawtip" })
985
- ]);
986
- expect(INIT_PAYMENT_OPTIONS.map((option) => option.value)).not.toContain("mock");
987
-
988
- const notes = noteMessages.join("\n");
989
- expect(INIT_COMING_SOON_PAYMENT_OPTIONS).toEqual(expect.arrayContaining([
990
- expect.objectContaining({ id: "wechat-pay", label: "WeChat Pay" }),
991
- expect.objectContaining({ id: "alipay-agent-payment", label: "Alipay Agent Payment" }),
992
- expect.objectContaining({ id: "coinbase-smart-wallet", label: "Coinbase Smart Wallet" })
993
- ]));
994
- expect(notes).toContain("WeChat Pay(接入中)");
995
- expect(notes).toContain("Alipay Agent Payment(接入中)");
996
- expect(notes).toContain("Coinbase Smart Wallet(接入中)");
997
- });
998
-
999
- test("detects an existing local clawtip binding from saved payment config", () => {
1000
- const binding = detectExistingClawtipBinding({
1001
- method: "clawtip",
1002
- enabled: false,
1003
- isDefault: false,
1004
- updatedAt: "2026-05-30T00:00:00.000Z",
1005
- config: {
1006
- orderNo: "order_123",
1007
- resourceUrl: "https://example.test/pay"
1008
- }
1009
- });
1010
-
1011
- expect(binding).toEqual(expect.objectContaining({
1012
- orderNo: "order_123",
1013
- resourceUrl: "https://example.test/pay",
1014
- config: expect.objectContaining({
1015
- orderNo: "order_123",
1016
- resourceUrl: "https://example.test/pay"
1017
- })
1018
- }));
1019
- });
1020
-
1021
- test("requires the OpenClaw wallet config before reusing ClawTip metadata", () => {
1022
- const payment: PaymentConfig = {
1023
- method: "clawtip",
1024
- enabled: true,
1025
- isDefault: true,
1026
- updatedAt: "2026-05-30T00:00:00.000Z",
1027
- config: {
1028
- orderNo: "order_123",
1029
- resourceUrl: "https://example.test/pay"
1030
- }
1031
- };
1032
-
1033
- const missingWallet = inspectClawtipWalletReadiness(payment, {
1034
- expectedPath: "/tmp/home/.openclaw/configs/config.json",
1035
- configsDirExists: true,
1036
- exists: false,
1037
- alternatePaths: ["/tmp/home/.openclaw/configs/config.json.bak"]
1038
- });
1039
- expect(missingWallet.status).toBe("metadata_missing_wallet");
1040
- expect(missingWallet.savedBinding?.orderNo).toBe("order_123");
1041
- expect(missingWallet.reusableBinding).toBeUndefined();
1042
-
1043
- const readyWallet = inspectClawtipWalletReadiness(payment, {
1044
- expectedPath: "/tmp/home/.openclaw/configs/config.json",
1045
- configsDirExists: true,
1046
- exists: true,
1047
- alternatePaths: []
1048
- });
1049
- expect(readyWallet.status).toBe("ready");
1050
- expect(readyWallet.reusableBinding?.orderNo).toBe("order_123");
1051
- });
1052
-
1053
- test("detects renamed OpenClaw wallet config files as nearby files", () => {
1054
- const home = path.join(TEMP_STORE_ROOT, "openclaw-home");
1055
- const configsDir = path.join(home, ".openclaw", "configs");
1056
- rmDir(home);
1057
- fs.mkdirSync(configsDir, { recursive: true });
1058
- fs.writeFileSync(path.join(configsDir, "config.json.bak"), "{}", "utf8");
1059
-
1060
- const wallet = inspectOpenClawWalletConfig(home);
1061
-
1062
- expect(wallet.exists).toBe(false);
1063
- expect(wallet.configsDirExists).toBe(true);
1064
- expect(wallet.expectedPath).toBe(path.join(configsDir, "config.json"));
1065
- expect(wallet.alternatePaths).toEqual([
1066
- path.join(configsDir, "config.json.bak")
1067
- ]);
1068
- });
1069
-
1070
- test("builds a clear init success message with summary lines", () => {
1071
- const message = buildInitSuccessMessage([
1072
- "2 programming terminals configured for TokenBuddy.",
1073
- "ClawTip wallet already bound locally; activation skipped."
1074
- ]);
1075
-
1076
- expect(message).toContain("✅ TokenBuddy setup completed successfully.");
1077
- expect(message).toContain("- 2 programming terminals configured for TokenBuddy.");
1078
- expect(message).toContain("- ClawTip wallet already bound locally; activation skipped.");
1079
- expect(message).toContain("Run `tb doctor` to audit status anytime.");
1080
- });
1081
-
1082
- test("builds terminal selection with configured entries separated from selectable options", () => {
1083
- const selection = buildInitTerminalSelectionState([
1084
- {
1085
- id: "codex",
1086
- name: "Codex CLI",
1087
- detected: true,
1088
- configured: true,
1089
- status: "configured",
1090
- configPath: "/tmp/codex.toml",
1091
- reason: "Configured at ~/.codex/config.toml"
1092
- },
1093
- {
1094
- id: "hermes",
1095
- name: "Hermes Terminal",
1096
- detected: true,
1097
- configured: false,
1098
- status: "installed",
1099
- configPath: "/tmp/hermes.json",
1100
- reason: "Installed, TokenBuddy config missing"
1101
- }
1102
- ]);
1103
-
1104
- expect(selection.installed).toEqual([
1105
- expect.objectContaining({
1106
- value: "codex",
1107
- label: "Codex CLI(已安装)"
1108
- })
1109
- ]);
1110
- expect(selection.options).toEqual([
1111
- expect.objectContaining({
1112
- value: "hermes",
1113
- label: "Hermes Terminal"
1114
- }),
1115
- expect.objectContaining({
1116
- value: OTHER_TERMINAL_OPTION.value,
1117
- label: OTHER_TERMINAL_OPTION.label
1118
- })
1119
- ]);
1120
- });
1121
-
1122
- test("requires at least one terminal choice", () => {
1123
- expect(validateInitTerminalSelection([])).toBe("Select at least one terminal or choose Other.");
1124
- expect(validateInitTerminalSelection(["other"])).toBeUndefined();
1125
- expect(validateInitTerminalSelection(["hermes"])).toBeUndefined();
1126
- });
1127
-
1128
- test("waits for ClawTip scan confirmation until the wallet config appears", async () => {
1129
- let attempts = 0;
1130
- const inspectWalletConfig = jest.fn(() => {
1131
- attempts += 1;
1132
- return {
1133
- expectedPath: "/tmp/home/.openclaw/configs/config.json",
1134
- configsDirExists: true,
1135
- exists: attempts >= 3,
1136
- alternatePaths: []
1137
- };
1138
- });
1139
- const sleep = jest.fn(async () => undefined);
1140
-
1141
- await expect(waitForClawtipActivationConfirmation({
1142
- inspectWalletConfig,
1143
- pollIntervalMs: 10,
1144
- sleep,
1145
- })).resolves.toBe(true);
1146
-
1147
- expect(inspectWalletConfig).toHaveBeenCalledTimes(3);
1148
- expect(sleep).toHaveBeenCalledTimes(2);
1149
- });
1150
-
1151
- test("cancels ClawTip scan confirmation on Ctrl+C", async () => {
1152
- const cancelled = jest.fn();
1153
- await expect(
1154
- waitForClawtipActivationConfirmation({
1155
- inspectWalletConfig: () => ({
1156
- expectedPath: "/tmp/home/.openclaw/configs/config.json",
1157
- configsDirExists: true,
1158
- exists: false,
1159
- alternatePaths: []
1160
- }),
1161
- isCancelled: () => true,
1162
- cancel: cancelled,
1163
- pollIntervalMs: 10,
1164
- sleep: async () => undefined,
1165
- })
1166
- ).resolves.toBe(false);
1167
-
1168
- expect(cancelled).toHaveBeenCalledWith("ClawTip activation cancelled.");
1169
- });
1170
-
1171
- test("parses ClawTip auth URL and clawtipId from pay CLI output", () => {
1172
- const parsed = parseClawtipCliOutput(
1173
- "请扫码 https://clawtip.jd.com/qrcode?foo=1&clawtipId=device-789 完成授权"
1174
- );
1175
-
1176
- expect(parsed.requiresWalletAuth).toBe(true);
1177
- expect(parsed.authUrl).toBe("https://clawtip.jd.com/qrcode?foo=1&clawtipId=device-789");
1178
- expect(parsed.clawtipId).toBe("device-789");
1179
- });
1180
-
1181
- test("ignores bootstrap env endpoint noise when parsing the real ClawTip auth URL", () => {
1182
- const parsed = parseClawtipCliOutput([
1183
- "process.env.CLAWTIP_PAY https://ms.jr.jd.com/gw2/generic/hyqy/h5/m/clawtipPay",
1184
- "请扫码 https://idt.jd.com/unifiedAuthM/plat/safeMonitor/?authFlowUid=abc 完成授权",
1185
- ].join("\n"));
1186
-
1187
- expect(parsed.authUrl).toBe(
1188
- "https://idt.jd.com/unifiedAuthM/plat/safeMonitor/?authFlowUid=abc"
1189
- );
1190
- });
1191
-
1192
- test("parses the official ClawTip wallet QR image path", () => {
1193
- const parsed = parseClawtipCliOutput([
1194
- "从 .env.prod 加载环境变量",
1195
- "/Users/test/.openclaw/workspace/clawtip/qrcode/clawtip-index-code.png",
1196
- ].join("\n"));
1197
-
1198
- expect(parsed.mediaPath).toBe("/Users/test/.openclaw/workspace/clawtip/qrcode/clawtip-index-code.png");
1199
- expect(parsed.requiresWalletAuth).toBe(true);
1200
- });
1201
-
1202
- test("resolves npx next to the Node executable when PATH is sparse", () => {
1203
- const resolved = resolveNpxCommand({
1204
- execPath: "/opt/homebrew/Cellar/node/26.0.0/bin/node",
1205
- envPath: "/usr/bin:/bin",
1206
- exists: (filePath) => filePath === "/opt/homebrew/Cellar/node/26.0.0/bin/npx",
1207
- });
1208
-
1209
- expect(resolved).toBe("/opt/homebrew/Cellar/node/26.0.0/bin/npx");
1210
- });
1211
-
1212
- test("falls back to npx command name when no absolute candidate exists", () => {
1213
- expect(resolveNpxCommand({
1214
- execPath: "/missing/bin/node",
1215
- envPath: "/usr/bin:/bin",
1216
- exists: () => false,
1217
- })).toBe("npx");
1218
- });
1219
-
1220
- test("surfaces ClawTip upstream payment errors directly", async () => {
1221
- const parsed = parseClawtipCliOutput("ClawTip 返回错误:商家信息有误");
1222
-
1223
- expect(parsed.failureMessage).toContain("商家信息有误");
1224
- await expect(startClawtipWalletBootstrap({
1225
- orderNo: "order_error",
1226
- amountFen: 1,
1227
- payTo: "pay-to-test",
1228
- encryptedData: "ciphertext",
1229
- indicator: "indicator_error",
1230
- slug: "tb-registry",
1231
- skillId: "si-tb-registry",
1232
- description: "TokenBuddy ClawTip wallet activation",
1233
- resourceUrl: "https://tb-registry.fly.dev"
1234
- }, {
1235
- home: path.join(TEMP_STORE_ROOT, "clawtip-error-home"),
1236
- runClawtipCommand: async () => "ClawTip 返回错误:商家信息有误",
1237
- })).rejects.toThrow("ClawTip pay failed: ClawTip 返回错误:商家信息有误");
1238
- });
1239
-
1240
- test("treats ClawTip returned payment failure messages as failed proofs", async () => {
1241
- const parsed = parseClawtipCliOutput("返回消息: 收付款方账户不能相同\n已获取到支付凭证");
1242
-
1243
- expect(parsed.failureMessage).toContain("收付款方账户不能相同");
1244
- await expect(createClawtipPaymentProof({
1245
- paymentInstructions: {
1246
- method: "clawtip",
1247
- clawtip: {
1248
- orderNo: "order_same_account",
1249
- amountFen: 2,
1250
- payTo: "pay-to-test",
1251
- encryptedData: "ciphertext",
1252
- indicator: "indicator_same_account",
1253
- slug: "tb-seller",
1254
- skillId: "si-tb-seller",
1255
- description: "TokenBuddy inference purchase",
1256
- resourceUrl: "https://seller.example.test"
1257
- }
1258
- }
1259
- }, {
1260
- home: path.join(TEMP_STORE_ROOT, "clawtip-same-account-home"),
1261
- runClawtipCommand: async () => "返回消息: 收付款方账户不能相同\n已获取到支付凭证",
1262
- })).rejects.toThrow("ClawTip pay failed: 返回消息: 收付款方账户不能相同");
1263
- });
1264
-
1265
- test("accepts ClawTip returned success messages with payment details", () => {
1266
- const parsed = parseClawtipCliOutput("返回消息: 本次交易在授权范围内,ClawTip付费成功。支付0.02元,余额0.50元\n已获取到支付凭证");
1267
-
1268
- expect(parsed.failureMessage).toBeUndefined();
1269
- });
1270
-
1271
- test("uses only ClawTip CLI media paths for QR resolution", () => {
1272
- expect(resolveClawtipQrMediaPath({
1273
- authUrl: "https://clawtip.jd.com/qrcode?clawtipId=device-789",
1274
- clawtipId: "device-789",
1275
- mediaPath: "/tmp/clawtip/qrcode-1.png",
1276
- requiresWalletAuth: true,
1277
- walletReady: false,
1278
- }, "/tmp/orders/order-1.json")).toBe("/tmp/clawtip/qrcode-1.png");
1279
- });
1280
-
1281
- test("fails ClawTip QR resolution when pay CLI emits no QR source", () => {
1282
- expect(() => resolveClawtipQrMediaPath({
1283
- authUrl: undefined,
1284
- clawtipId: undefined,
1285
- mediaPath: undefined,
1286
- requiresWalletAuth: false,
1287
- walletReady: false,
1288
- }, "/tmp/orders/order-1.json")).toThrow("ClawTip pay did not return a QR media file.");
1289
- });
1290
-
1291
- test("checks OpenClaw before ClawTip wallet bootstrap", async () => {
1292
- const calls: string[][] = [];
1293
- const openClawVersion = await checkOpenClawRuntime({
1294
- runOpenClawCommand: async (args) => {
1295
- calls.push(["openclaw", ...args]);
1296
- return "OpenClaw 2026.5.18";
1297
- },
1298
- });
1299
-
1300
- expect(openClawVersion).toBe("OpenClaw 2026.5.18");
1301
- expect(calls).toEqual([
1302
- ["openclaw", "--version"],
1303
- ]);
1304
- });
1305
-
1306
- test("normalizes the bootstrap resource URL away from the public registry endpoint", () => {
1307
- expect(normalizeClawtipBootstrapResourceUrl(
1308
- "https://tb-registry.fly.dev",
1309
- "https://tb-registry.fly.dev/registry/sellers"
1310
- )).toBe("https://tb-registry.fly.dev");
1311
-
1312
- expect(normalizeClawtipBootstrapResourceUrl(
1313
- "https://tb-registry.fly.dev/base",
1314
- "https://tb-registry.fly.dev/registry/sellers"
1315
- )).toBe("https://tb-registry.fly.dev/base");
1316
-
1317
- expect(normalizeClawtipBootstrapResourceUrl(
1318
- "https://tb-registry.fly.dev",
1319
- "https://example.test/pay"
1320
- )).toBe("https://example.test/pay");
1321
- });
1322
-
1323
- test("rejects a bootstrap response that still uses the placeholder ClawTip payTo", async () => {
1324
- const originalFetch = global.fetch;
1325
- global.fetch = jest.fn(async () => new Response(JSON.stringify({
1326
- activationFeeFen: 1,
1327
- payment: {
1328
- orderNo: "order_placeholder",
1329
- indicator: "indicator_placeholder",
1330
- payTo: "bootstrap-pay-to",
1331
- resourceUrl: "https://registry.tokenbuddy.ai/v1/registry.json",
1332
- }
1333
- }), {
1334
- status: 200,
1335
- headers: { "Content-Type": "application/json" }
1336
- })) as typeof fetch;
1337
-
1338
- try {
1339
- await expect(fetchClawtipBootstrap("https://tb-registry.fly.dev")).rejects.toThrow(
1340
- "ClawTip bootstrap service is misconfigured: payTo is still the placeholder"
1341
- );
1342
- } finally {
1343
- global.fetch = originalFetch;
1344
- }
1345
- });
1346
-
1347
- test("writes the Rust-compatible ClawTip order file shape", () => {
1348
- const home = path.join(TEMP_STORE_ROOT, "clawtip-order-home");
1349
- rmDir(home);
1350
-
1351
- const orderFile = writeClawtipOrderFile({
1352
- orderNo: "order_123",
1353
- amountFen: 1,
1354
- payTo: "pay-to-test",
1355
- encryptedData: "ciphertext",
1356
- indicator: "indicator_123",
1357
- slug: "tb-registry",
1358
- skillId: "si-tb-registry",
1359
- description: "TokenBuddy ClawTip wallet activation",
1360
- resourceUrl: "https://tb-registry.fly.dev"
1361
- }, home);
1362
-
1363
- expect(orderFile).toBe(path.join(
1364
- home,
1365
- ".openclaw",
1366
- "skills",
1367
- "orders",
1368
- "indicator_123",
1369
- "order_123.json"
1370
- ));
1371
-
1372
- const saved = JSON.parse(fs.readFileSync(orderFile, "utf8"));
1373
- expect(saved).toEqual(expect.objectContaining({
1374
- "skill-id": "si-tb-registry",
1375
- order_no: "order_123",
1376
- amount: 1,
1377
- encrypted_data: "ciphertext",
1378
- pay_to: "pay-to-test",
1379
- slug: "tb-registry",
1380
- resource_url: "https://tb-registry.fly.dev"
1381
- }));
1382
- });
1383
-
1384
- test("starts ClawTip payment activation and reads payCredential from the order file", async () => {
1385
- const home = path.join(TEMP_STORE_ROOT, "clawtip-bootstrap-home");
1386
- rmDir(home);
1387
-
1388
- const activation = await startClawtipWalletBootstrap({
1389
- orderNo: "order_456",
1390
- amountFen: 1,
1391
- payTo: "pay-to-test",
1392
- encryptedData: "ciphertext",
1393
- indicator: "indicator_456",
1394
- slug: "tb-registry",
1395
- skillId: "si-tb-registry",
1396
- description: "TokenBuddy ClawTip wallet activation",
1397
- resourceUrl: "https://tb-registry.fly.dev"
1398
- }, {
1399
- home,
1400
- runClawtipCommand: async () => {
1401
- const orderFile = path.join(
1402
- home,
1403
- ".openclaw",
1404
- "skills",
1405
- "orders",
1406
- "indicator_456",
1407
- "order_456.json"
1408
- );
1409
- const order = JSON.parse(fs.readFileSync(orderFile, "utf8"));
1410
- order.payCredential = "credential_456";
1411
- fs.writeFileSync(orderFile, JSON.stringify(order, null, 2), "utf8");
1412
- return "已获取到支付凭证";
1413
- }
1414
- });
1415
-
1416
- expect(activation.orderFile).toBe(path.join(
1417
- home,
1418
- ".openclaw",
1419
- "skills",
1420
- "orders",
1421
- "indicator_456",
1422
- "order_456.json"
1423
- ));
1424
- expect(activation.payCredential).toBe("credential_456");
1425
- expect(readClawtipPayCredential(activation.orderFile)).toBe("credential_456");
1426
- });
1427
-
1428
- test("creates a ClawTip payment proof from seller payment instructions", async () => {
1429
- const home = path.join(TEMP_STORE_ROOT, "clawtip-proof-home");
1430
- rmDir(home);
1431
-
1432
- const proof = await createClawtipPaymentProof({
1433
- paymentInstructions: {
1434
- method: "clawtip",
1435
- clawtip: {
1436
- orderNo: "order_789",
1437
- amountFen: 2,
1438
- payTo: "pay-to-test",
1439
- encryptedData: "ciphertext",
1440
- indicator: "indicator_789",
1441
- slug: "tb-seller",
1442
- skillId: "si-tb-seller",
1443
- description: "TokenBuddy inference purchase",
1444
- resourceUrl: "https://seller.example.test"
1445
- }
1446
- }
1447
- }, {
1448
- home,
1449
- runClawtipCommand: async (args) => {
1450
- expect(args).toEqual([
1451
- "--yes",
1452
- "@clawtip/clawtip-cli@1.0.4",
1453
- "pay",
1454
- "-o",
1455
- "order_789",
1456
- "-i",
1457
- "indicator_789",
1458
- "-v",
1459
- "1.0.12"
1460
- ]);
1461
- const orderFile = path.join(
1462
- home,
1463
- ".openclaw",
1464
- "skills",
1465
- "orders",
1466
- "indicator_789",
1467
- "order_789.json"
1468
- );
1469
- const order = JSON.parse(fs.readFileSync(orderFile, "utf8"));
1470
- expect(order).toEqual(expect.objectContaining({
1471
- order_no: "order_789",
1472
- amount: 2,
1473
- encrypted_data: "ciphertext",
1474
- pay_to: "pay-to-test",
1475
- slug: "tb-seller",
1476
- resource_url: "https://seller.example.test"
1477
- }));
1478
- order.payCredential = "credential_789";
1479
- fs.writeFileSync(orderFile, JSON.stringify(order, null, 2), "utf8");
1480
- return "已获取到支付凭证";
1481
- }
1482
- });
1483
-
1484
- expect(proof).toBe("credential_789");
1485
- });
1486
-
1487
- test("recovers the latest generated ClawTip QR media path when pay output omits MEDIA", async () => {
1488
- const home = path.join(TEMP_STORE_ROOT, "clawtip-bootstrap-qr-home");
1489
- rmDir(home);
1490
-
1491
- const activation = await startClawtipWalletBootstrap({
1492
- orderNo: "order_789",
1493
- amountFen: 1,
1494
- payTo: "pay-to-test",
1495
- encryptedData: "ciphertext",
1496
- indicator: "indicator_789",
1497
- slug: "tb-registry",
1498
- skillId: "si-tb-registry",
1499
- description: "TokenBuddy ClawTip wallet activation",
1500
- resourceUrl: "https://tb-registry.fly.dev"
1501
- }, {
1502
- home,
1503
- runClawtipCommand: async () => {
1504
- const qrDir = path.join(home, ".openclaw", "workspace", "clawtip", "qrcode");
1505
- fs.mkdirSync(qrDir, { recursive: true });
1506
- const qrPath = path.join(qrDir, "qrcode-generated.png");
1507
- fs.writeFileSync(qrPath, "png", "utf8");
1508
- return [
1509
- "process.env.CLAWTIP_PAY https://ms.jr.jd.com/gw2/generic/hyqy/h5/m/clawtipPay",
1510
- "请扫码 https://idt.jd.com/unifiedAuthM/plat/safeMonitor/?authFlowUid=abc 完成授权",
1511
- ].join("\n");
1512
- }
1513
- });
1514
-
1515
- expect(activation.parsedOutput.authUrl).toBe(
1516
- "https://idt.jd.com/unifiedAuthM/plat/safeMonitor/?authFlowUid=abc"
1517
- );
1518
- expect(activation.parsedOutput.mediaPath).toBe(
1519
- path.join(home, ".openclaw", "workspace", "clawtip", "qrcode", "qrcode-generated.png")
1520
- );
1521
- });
1522
-
1523
- test("recovers ClawTip QR media even when pay output only writes payCredential", async () => {
1524
- const home = path.join(TEMP_STORE_ROOT, "clawtip-bootstrap-credential-and-qr-home");
1525
- rmDir(home);
1526
-
1527
- const activation = await startClawtipWalletBootstrap({
1528
- orderNo: "order_credential_qr",
1529
- amountFen: 1,
1530
- payTo: "pay-to-test",
1531
- encryptedData: "ciphertext",
1532
- indicator: "indicator_credential_qr",
1533
- slug: "tb-registry",
1534
- skillId: "si-tb-registry",
1535
- description: "TokenBuddy ClawTip wallet activation",
1536
- resourceUrl: "https://tb-registry.fly.dev"
1537
- }, {
1538
- home,
1539
- runClawtipCommand: async () => {
1540
- const orderFile = path.join(
1541
- home,
1542
- ".openclaw",
1543
- "skills",
1544
- "orders",
1545
- "indicator_credential_qr",
1546
- "order_credential_qr.json"
1547
- );
1548
- const order = JSON.parse(fs.readFileSync(orderFile, "utf8"));
1549
- order.payCredential = "credential_without_wallet_config";
1550
- fs.writeFileSync(orderFile, JSON.stringify(order, null, 2), "utf8");
1551
-
1552
- const qrDir = path.join(home, ".openclaw", "workspace", "clawtip", "qrcode");
1553
- fs.mkdirSync(qrDir, { recursive: true });
1554
- const qrPath = path.join(qrDir, "qrcode-credential.png");
1555
- fs.writeFileSync(qrPath, "png", "utf8");
1556
- return "已获取到支付凭证";
1557
- }
1558
- });
1559
-
1560
- expect(activation.payCredential).toBe("credential_without_wallet_config");
1561
- expect(activation.parsedOutput.requiresWalletAuth).toBe(true);
1562
- expect(activation.parsedOutput.mediaPath).toBe(
1563
- path.join(home, ".openclaw", "workspace", "clawtip", "qrcode", "qrcode-credential.png")
1564
- );
1565
- });
1566
-
1567
- test("doctor prompts tb init when no ClawTip wallet is available", () => {
1568
- const lines: string[] = [];
1569
-
1570
- printDoctorClawtipWallet({
1571
- status: "missing",
1572
- ready: false,
1573
- paymentMetadataPresent: false,
1574
- walletConfigPresent: false,
1575
- configsDirExists: false,
1576
- expectedPath: "/tmp/home/.openclaw/configs/config.json",
1577
- alternatePaths: [],
1578
- message: "ClawTip payment metadata and local OpenClaw wallet config are not configured.",
1579
- }, (line) => {
1580
- lines.push(line);
1581
- });
1582
-
1583
- expect(lines.join("\n")).toContain(
1584
- "Action: Run `tb init` and choose ClawTip to bind a wallet before using ClawTip-backed purchases."
1585
- );
1586
- });
1587
- });
1588
-
1589
- describe("TokenBuddy terminal image display", () => {
1590
- const imagePath = "/Users/test/.openclaw/workspace/clawtip/qrcode/clawtip-index-code.png";
1591
-
1592
- test("detects inline image capable terminals only when stdout is a TTY", () => {
1593
- expect(detectTerminalImageDisplay({
1594
- env: { TERM_PROGRAM: "iTerm.app" },
1595
- stdoutIsTTY: true,
1596
- })).toBe("iterm");
1597
- expect(detectTerminalImageDisplay({
1598
- env: { WEZTERM_EXECUTABLE: "/Applications/WezTerm.app/wezterm" },
1599
- stdoutIsTTY: true,
1600
- })).toBe("iterm");
1601
- expect(detectTerminalImageDisplay({
1602
- env: { KITTY_WINDOW_ID: "1" },
1603
- stdoutIsTTY: true,
1604
- })).toBe("kitty");
1605
- expect(detectTerminalImageDisplay({
1606
- env: { TERM_PROGRAM: "iTerm.app" },
1607
- stdoutIsTTY: false,
1608
- })).toBeUndefined();
1609
- });
1610
-
1611
- test("renders the ClawTip QR image inline for iTerm2-compatible terminals", async () => {
1612
- const writes: string[] = [];
1613
- const runCommand = jest.fn(async () => undefined);
1614
-
1615
- const result = await displayTerminalImage(imagePath, {
1616
- env: { TERM_PROGRAM: "iTerm.app" },
1617
- stdoutIsTTY: true,
1618
- fileExists: () => true,
1619
- readFile: () => Buffer.from("png-bytes"),
1620
- write: (chunk) => {
1621
- writes.push(chunk);
1622
- },
1623
- runCommand,
1624
- });
1625
-
1626
- expect(result).toEqual(expect.objectContaining({
1627
- method: "inline-iterm",
1628
- displayed: true,
1629
- }));
1630
- expect(writes.join("")).toContain("\u001B]1337;File=");
1631
- expect(writes.join("")).toContain("inline=1");
1632
- expect(runCommand).not.toHaveBeenCalled();
1633
- });
1634
-
1635
- test("renders the ClawTip QR image inline for Kitty terminals", async () => {
1636
- const writes: string[] = [];
1637
-
1638
- const result = await displayTerminalImage(imagePath, {
1639
- env: { TERM: "xterm-kitty" },
1640
- stdoutIsTTY: true,
1641
- fileExists: () => true,
1642
- write: (chunk) => {
1643
- writes.push(chunk);
1644
- },
1645
- });
1646
-
1647
- expect(result).toEqual(expect.objectContaining({
1648
- method: "inline-kitty",
1649
- displayed: true,
1650
- }));
1651
- expect(writes.join("")).toContain("\u001B_Ga=T,t=f,f=100,c=60;");
1652
- });
1653
-
1654
- test("opens the ClawTip QR image with the system viewer when inline images are unsupported", async () => {
1655
- const runCommand = jest.fn(async () => undefined);
1656
-
1657
- const result = await displayTerminalImage(imagePath, {
1658
- env: { TERM_PROGRAM: "Apple_Terminal" },
1659
- platform: "darwin",
1660
- stdoutIsTTY: true,
1661
- fileExists: () => true,
1662
- runCommand,
1663
- });
1664
-
1665
- expect(result).toEqual(expect.objectContaining({
1666
- method: "system-open",
1667
- displayed: true,
1668
- fallbackCommand: `open ${imagePath}`,
1669
- }));
1670
- expect(runCommand).toHaveBeenCalledWith("open", [imagePath]);
1671
- });
1672
-
1673
- test("falls back to a manual open command when the system viewer fails", async () => {
1674
- const result = await displayTerminalImage(imagePath, {
1675
- env: {},
1676
- platform: "darwin",
1677
- stdoutIsTTY: true,
1678
- fileExists: () => true,
1679
- runCommand: async () => {
1680
- throw new Error("no gui session");
1681
- },
1682
- });
1683
-
1684
- expect(result).toEqual(expect.objectContaining({
1685
- method: "manual",
1686
- displayed: false,
1687
- fallbackCommand: `open ${imagePath}`,
1688
- }));
1689
- expect(result.message).toContain("no gui session");
1690
- });
1691
- });
1692
-
1693
- describe("TokenBuddy JSON inspection commands", () => {
1694
- let controlServer: http.Server;
1695
- let proxyServer: http.Server;
1696
- let controlPort: number;
1697
- let proxyPort: number;
1698
- let previousControlPort: string | undefined;
1699
- let previousProxyPort: string | undefined;
1700
- let previousBuyerStoreRoot: string | undefined;
1701
- let previousHome: string | undefined;
1702
-
1703
- beforeEach((done) => {
1704
- previousControlPort = process.env.TB_PROXYD_CONTROL_PORT;
1705
- previousProxyPort = process.env.TB_PROXYD_PROXY_PORT;
1706
- previousBuyerStoreRoot = process.env.TOKENBUDDY_BUYER_STORE;
1707
- previousHome = process.env.HOME;
1708
- rmDir(INSPECTION_STORE_ROOT);
1709
- rmDir(INSPECTION_HOME);
1710
- process.env.TOKENBUDDY_BUYER_STORE = INSPECTION_STORE_ROOT;
1711
- process.env.HOME = INSPECTION_HOME;
1712
- fs.mkdirSync(path.join(INSPECTION_HOME, ".openclaw", "configs"), { recursive: true });
1713
- fs.writeFileSync(path.join(INSPECTION_HOME, ".openclaw", "configs", "config.json.bak"), "{}", "utf8");
1714
- const store = new BuyerStore();
1715
- store.savePayment({
1716
- method: "clawtip",
1717
- enabled: true,
1718
- isDefault: true,
1719
- config: {
1720
- orderNo: "order_json",
1721
- resourceUrl: "https://example.test/pay"
1722
- }
1723
- });
1724
- store.close();
1725
- proxyServer = http.createServer((req, res) => {
1726
- res.setHeader("Content-Type", "application/json");
1727
- if (req.url === "/v1/models") {
1728
- res.end(JSON.stringify({
1729
- object: "list",
1730
- data: [
1731
- { id: "gpt-4", sellerId: "json-test-seller" }
1732
- ]
1733
- }));
1734
- return;
1735
- }
1736
- res.statusCode = 404;
1737
- res.end(JSON.stringify({ error: "not_found" }));
1738
- });
1739
- controlServer = http.createServer((req, res) => {
1740
- res.setHeader("Content-Type", "application/json");
1741
- if (req.url === "/health") {
1742
- res.end(JSON.stringify({
1743
- status: "ok",
1744
- controlPort,
1745
- proxyPort
1746
- }));
1747
- return;
1748
- }
1749
- if (req.url === "/status") {
1750
- res.end(JSON.stringify({
1751
- status: "running",
1752
- pid: 12345,
1753
- controlPort,
1754
- proxyPort
1755
- }));
1756
- return;
1757
- }
1758
- if (req.url === "/sellers") {
1759
- res.end(JSON.stringify({
1760
- registryUrl: "https://example.test/registry/sellers",
1761
- version: 7,
1762
- defaultSeller: "json-test-seller",
1763
- sellers: [
1764
- {
1765
- id: "json-test-seller",
1766
- name: "JSON Seller",
1767
- url: "https://seller.example.test",
1768
- supportedProtocols: ["responses"],
1769
- paymentMethods: ["mock"],
1770
- discountRatio: 0.25,
1771
- status: "configured"
1772
- }
1773
- ]
1774
- }));
1775
- return;
1776
- }
1777
- if (req.url === "/models") {
1778
- res.end(JSON.stringify({
1779
- object: "list",
1780
- registryUrl: "https://example.test/registry/sellers",
1781
- data: [
1782
- {
1783
- id: "gpt-4",
1784
- sellerId: "json-test-seller",
1785
- sellerName: "JSON Seller",
1786
- sellerUrl: "https://seller.example.test",
1787
- supportedProtocols: ["responses"],
1788
- paymentMethods: ["mock"],
1789
- inputPriceMicrosPer1m: 1000000,
1790
- outputPriceMicrosPer1m: 3000000
1791
- }
1792
- ],
1793
- sellers: [
1794
- {
1795
- id: "json-test-seller",
1796
- name: "JSON Seller",
1797
- url: "https://seller.example.test",
1798
- supportedProtocols: ["responses"],
1799
- paymentMethods: ["mock"],
1800
- discountRatio: 0.25,
1801
- status: "ok",
1802
- modelCount: 1
1803
- }
1804
- ]
1805
- }));
1806
- return;
1807
- }
1808
- res.statusCode = 404;
1809
- res.end(JSON.stringify({ error: "not_found" }));
1810
- });
1811
- proxyServer.listen(0, "127.0.0.1", () => {
1812
- proxyPort = (proxyServer.address() as AddressInfo).port;
1813
- controlServer.listen(0, "127.0.0.1", () => {
1814
- controlPort = (controlServer.address() as AddressInfo).port;
1815
- process.env.TB_PROXYD_CONTROL_PORT = String(controlPort);
1816
- process.env.TB_PROXYD_PROXY_PORT = String(proxyPort);
1817
- done();
1818
- });
1819
- });
1820
- });
1821
-
1822
- afterEach((done) => {
1823
- if (previousControlPort === undefined) {
1824
- delete process.env.TB_PROXYD_CONTROL_PORT;
1825
- } else {
1826
- process.env.TB_PROXYD_CONTROL_PORT = previousControlPort;
1827
- }
1828
- if (previousProxyPort === undefined) {
1829
- delete process.env.TB_PROXYD_PROXY_PORT;
1830
- } else {
1831
- process.env.TB_PROXYD_PROXY_PORT = previousProxyPort;
1832
- }
1833
- if (previousBuyerStoreRoot === undefined) {
1834
- delete process.env.TOKENBUDDY_BUYER_STORE;
1835
- } else {
1836
- process.env.TOKENBUDDY_BUYER_STORE = previousBuyerStoreRoot;
1837
- }
1838
- if (previousHome === undefined) {
1839
- delete process.env.HOME;
1840
- } else {
1841
- process.env.HOME = previousHome;
1842
- }
1843
- jest.restoreAllMocks();
1844
- controlServer.close(() => proxyServer.close(() => {
1845
- rmDir(INSPECTION_STORE_ROOT);
1846
- rmDir(INSPECTION_HOME);
1847
- done();
1848
- }));
1849
- });
1850
-
1851
- test("doctor --json reports daemon and provider state", async () => {
1852
- const output: string[] = [];
1853
- jest.spyOn(console, "log").mockImplementation((message?: unknown) => {
1854
- output.push(String(message));
1855
- });
1856
-
1857
- await buildCli().parseAsync(["node", "tb", "doctor", "--json"]);
1858
-
1859
- expect(output).toHaveLength(1);
1860
- const parsed = JSON.parse(output[0]) as any;
1861
- expect(parsed.daemon).toMatchObject({
1862
- running: true,
1863
- controlPort,
1864
- proxyPort,
1865
- fixAvailable: true
1866
- });
1867
- expect(parsed.repair).toMatchObject({
1868
- requested: false,
1869
- attempted: false,
1870
- fixed: false
1871
- });
1872
- expect(parsed.access).toMatchObject({
1873
- token: "TOKENBUDDY_PROXY",
1874
- controlBaseUrl: `http://127.0.0.1:${controlPort}`,
1875
- proxyBaseUrl: `http://127.0.0.1:${proxyPort}`,
1876
- });
1877
- expect(parsed.access.endpoints).toEqual(expect.arrayContaining([
1878
- expect.objectContaining({ id: "control.health", available: true }),
1879
- expect.objectContaining({ id: "proxy.openai", available: true, token: "TOKENBUDDY_PROXY" })
1880
- ]));
1881
- expect(parsed.sellers).toMatchObject({
1882
- available: true,
1883
- registryUrl: "https://example.test/registry/sellers",
1884
- defaultSeller: "json-test-seller",
1885
- });
1886
- expect(parsed.models).toMatchObject({
1887
- available: true,
1888
- count: 1,
1889
- registryUrl: "https://example.test/registry/sellers",
1890
- });
1891
- expect(parsed.clawtipWallet).toMatchObject({
1892
- status: "metadata_missing_wallet",
1893
- ready: false,
1894
- paymentMetadataPresent: true,
1895
- walletConfigPresent: false,
1896
- expectedPath: path.join(INSPECTION_HOME, ".openclaw", "configs", "config.json"),
1897
- alternatePaths: [
1898
- path.join(INSPECTION_HOME, ".openclaw", "configs", "config.json.bak")
1899
- ]
1900
- });
1901
- expect(parsed.providers).toEqual(expect.arrayContaining([
1902
- expect.objectContaining({ id: "codex" }),
1903
- expect.objectContaining({ id: "claude-code" })
1904
- ]));
1905
- });
1906
-
1907
- test("doctor prints progress messages without repeating the model list", async () => {
1908
- const output: string[] = [];
1909
- jest.spyOn(console, "log").mockImplementation((message?: unknown) => {
1910
- output.push(String(message));
1911
- });
1912
-
1913
- await buildCli().parseAsync(["node", "tb", "doctor"]);
1914
-
1915
- const joined = output.join("\n");
1916
- expect(joined).toContain("--- ClawTip Wallet ---");
1917
- expect(joined).toContain("❌ ClawTip Wallet [metadata_missing_wallet]");
1918
- expect(joined).toContain("Payment metadata: present");
1919
- expect(joined).toContain("Wallet config: missing");
1920
- expect(joined).toContain("Checking local control plane and proxy endpoints...");
1921
- expect(joined).toContain("Refreshing seller registry...");
1922
- expect(joined).toContain("Model catalog hidden in `tb doctor`. Run `tb models` for the current model summary.");
1923
- expect(joined).not.toContain("Unique models:");
1924
- });
1925
-
1926
- test("models --json returns daemon model data", async () => {
1927
- const output: string[] = [];
1928
- jest.spyOn(console, "log").mockImplementation((message?: unknown) => {
1929
- output.push(String(message));
1930
- });
1931
-
1932
- await buildCli().parseAsync(["node", "tb", "models", "--json"]);
1933
-
1934
- expect(output).toHaveLength(1);
1935
- const parsed = JSON.parse(output[0]) as any;
1936
- expect(parsed.grouped).toEqual([
1937
- expect.objectContaining({
1938
- id: "gpt-4",
1939
- sellerCount: 1,
1940
- discountRange: "2.5折",
1941
- priceRange: "in $1 / out $3"
1942
- })
1943
- ]);
1944
- });
1945
-
1946
- test("models prints the doctor-style grouped model summary", async () => {
1947
- const output: string[] = [];
1948
- jest.spyOn(console, "log").mockImplementation((message?: unknown) => {
1949
- output.push(String(message));
1950
- });
1951
-
1952
- await buildCli().parseAsync(["node", "tb", "models"]);
1953
-
1954
- const joined = output.join("\n");
1955
- expect(joined).toContain("Model catalog refresh complete.");
1956
- expect(joined).toContain("Unique models: 1");
1957
- expect(joined).toContain("Seller offers: 1");
1958
- expect(joined).toContain("Model ID");
1959
- expect(joined).toContain("Seller Count");
1960
- expect(joined).toContain("Discount Range");
1961
- expect(joined).toContain("Price Range");
1962
- expect(joined).toContain("gpt-4");
1963
- expect(joined).toContain("2.5折");
1964
- expect(joined).toContain("$1");
1965
- expect(joined).toContain("$3");
1966
- });
1967
- });
1968
-
1969
- describe("Provider install planning", () => {
1970
- const PROVIDER_HOME = path.resolve(__dirname, "../../data-test/provider-home");
1971
- const PROVIDER_STORE_ROOT = path.resolve(__dirname, "../../data-test/provider-store");
1972
- const PROVIDER_BIN_ROOT = path.resolve(__dirname, "../../data-test/provider-bin");
1973
- const proxyUrl = "http://127.0.0.1:17821";
1974
- let previousPath: string | undefined;
1975
-
1976
- function writeExecutable(name: string): void {
1977
- const executablePath = path.join(PROVIDER_BIN_ROOT, name);
1978
- fs.writeFileSync(executablePath, "#!/bin/sh\nexit 0\n", "utf8");
1979
- fs.chmodSync(executablePath, 0o755);
1980
- }
1981
-
1982
- beforeEach(() => {
1983
- rmDir(PROVIDER_HOME);
1984
- rmDir(PROVIDER_STORE_ROOT);
1985
- rmDir(PROVIDER_BIN_ROOT);
1986
- fs.mkdirSync(path.join(PROVIDER_HOME, ".codex"), { recursive: true });
1987
- fs.mkdirSync(path.join(PROVIDER_HOME, ".claude"), { recursive: true });
1988
- fs.mkdirSync(path.join(PROVIDER_HOME, ".openclaw"), { recursive: true });
1989
- fs.mkdirSync(path.join(PROVIDER_HOME, ".config", "opencode"), { recursive: true });
1990
- fs.mkdirSync(PROVIDER_BIN_ROOT, { recursive: true });
1991
- writeExecutable("codex");
1992
- writeExecutable("claude");
1993
- writeExecutable("openclaw");
1994
- writeExecutable("opencode");
1995
- writeExecutable("hermes");
1996
- previousPath = process.env.PATH;
1997
- process.env.PATH = `${PROVIDER_BIN_ROOT}${path.delimiter}${previousPath || ""}`;
1998
- fs.writeFileSync(path.join(PROVIDER_HOME, ".codex", "config.toml"), "approval_policy = \"never\"\n", "utf8");
1999
- fs.writeFileSync(path.join(PROVIDER_HOME, ".claude", "settings.json"), JSON.stringify({ theme: "dark" }, null, 2), "utf8");
2000
- fs.writeFileSync(path.join(PROVIDER_HOME, ".openclaw", "openclaw.json"), JSON.stringify({
2001
- keep: "field",
2002
- models: {
2003
- providers: {
2004
- existing: {
2005
- baseUrl: "https://example.invalid/v1",
2006
- models: [{ id: "existing-model", name: "existing-model" }],
2007
- },
2008
- },
2009
- },
2010
- agents: {
2011
- defaults: {
2012
- model: "existing/existing-model",
2013
- },
2014
- },
2015
- }, null, 2), "utf8");
2016
- fs.writeFileSync(path.join(PROVIDER_HOME, ".config", "opencode", "opencode.json"), JSON.stringify({ share: "disabled" }, null, 2), "utf8");
2017
- });
2018
-
2019
- afterEach(() => {
2020
- rmDir(PROVIDER_HOME);
2021
- rmDir(PROVIDER_STORE_ROOT);
2022
- rmDir(PROVIDER_BIN_ROOT);
2023
- if (previousPath === undefined) {
2024
- delete process.env.PATH;
2025
- } else {
2026
- process.env.PATH = previousPath;
2027
- }
2028
- });
2029
-
2030
- test("detects providers and previews without mutating files", () => {
2031
- const codexBefore = fs.readFileSync(path.join(PROVIDER_HOME, ".codex", "config.toml"), "utf8");
2032
- const providers = detectProviders({ home: PROVIDER_HOME });
2033
- expect(providers).toEqual(expect.arrayContaining([
2034
- expect.objectContaining({ id: "codex", status: "configured", configured: true }),
2035
- expect.objectContaining({ id: "claude-code", status: "configured", configured: true }),
2036
- expect.objectContaining({ id: "openclaw", status: "installed", configured: false }),
2037
- expect.objectContaining({ id: "hermes", status: "installed", configured: false })
2038
- ]));
2039
-
2040
- const changes = previewProviderInstall({
2041
- providers: ["codex", "claude-code", "openclaw", "hermes"],
2042
- proxyUrl,
2043
- providerSelections: {
2044
- codex: {
2045
- selectionKind: "single-model",
2046
- protocolPreference: "responses",
2047
- defaultModel: "gpt-4",
2048
- },
2049
- "claude-code": {
2050
- selectionKind: "claude-role-mapping",
2051
- protocolPreference: "messages",
2052
- fallbackModel: "MiniMax-M2.7-highspeed",
2053
- roles: {
2054
- sonnet: {
2055
- upstreamModel: "MiniMax-M2.7-highspeed",
2056
- displayName: "MiniMax-M2.7-highspeed",
2057
- declareOneM: true,
2058
- },
2059
- },
2060
- },
2061
- openclaw: {
2062
- selectionKind: "single-model",
2063
- protocolPreference: "chat_completions",
2064
- defaultModel: "gpt-4",
2065
- },
2066
- hermes: {
2067
- selectionKind: "single-model",
2068
- protocolPreference: "chat_completions",
2069
- defaultModel: "gpt-4",
2070
- },
2071
- },
2072
- home: PROVIDER_HOME
2073
- });
2074
-
2075
- expect(changes).toEqual(expect.arrayContaining([
2076
- expect.objectContaining({ providerId: "codex", action: "update" }),
2077
- expect.objectContaining({ providerId: "hermes", action: "create" })
2078
- ]));
2079
- expect(fs.readFileSync(path.join(PROVIDER_HOME, ".codex", "config.toml"), "utf8")).toBe(codexBefore);
2080
- });
2081
-
2082
- test("reports installed-only providers when executable or native config hints exist", () => {
2083
- fs.rmSync(path.join(PROVIDER_HOME, ".openclaw", "openclaw.json"), { force: true });
2084
- fs.writeFileSync(path.join(PROVIDER_HOME, ".openclaw", "config.json"), JSON.stringify({ profile: "default" }, null, 2), "utf8");
2085
- fs.mkdirSync(path.join(PROVIDER_HOME, ".hermes"), { recursive: true });
2086
- fs.writeFileSync(path.join(PROVIDER_HOME, ".hermes", "settings.json"), JSON.stringify({ openai: { model: "gpt-4" } }, null, 2), "utf8");
2087
-
2088
- const providers = detectProviders({ home: PROVIDER_HOME });
2089
- expect(providers).toEqual(expect.arrayContaining([
2090
- expect.objectContaining({
2091
- id: "openclaw",
2092
- status: "installed",
2093
- configured: false,
2094
- executablePath: expect.stringContaining(path.join("provider-bin", "openclaw")),
2095
- observedPaths: expect.arrayContaining([path.join(PROVIDER_HOME, ".openclaw", "config.json")]),
2096
- }),
2097
- expect.objectContaining({
2098
- id: "hermes",
2099
- status: "installed",
2100
- configured: false,
2101
- executablePath: expect.stringContaining(path.join("provider-bin", "hermes")),
2102
- observedPaths: expect.arrayContaining([path.join(PROVIDER_HOME, ".hermes", "settings.json")]),
2103
- }),
2104
- ]));
2105
- });
2106
-
2107
- test("treats existing non-TokenBuddy OpenCode config as installed, not configured", () => {
2108
- const providers = detectProviders({ home: PROVIDER_HOME });
2109
-
2110
- expect(providers).toEqual(expect.arrayContaining([
2111
- expect.objectContaining({
2112
- id: "opencode",
2113
- status: "installed",
2114
- configured: false,
2115
- executablePath: expect.stringContaining(path.join("provider-bin", "opencode")),
2116
- }),
2117
- ]));
2118
- });
2119
-
2120
- test("applies provider config and rolls back existing and created files", () => {
2121
- const store = new BuyerStore({ root: PROVIDER_STORE_ROOT });
2122
- try {
2123
- const applied = applyProviderInstall({
2124
- providers: ["codex", "claude-code", "openclaw", "opencode", "hermes"],
2125
- proxyUrl,
2126
- providerSelections: {
2127
- codex: {
2128
- selectionKind: "single-model",
2129
- protocolPreference: "responses",
2130
- defaultModel: "gpt-4",
2131
- },
2132
- "claude-code": {
2133
- selectionKind: "claude-role-mapping",
2134
- protocolPreference: "messages",
2135
- fallbackModel: "MiniMax-M2.7-highspeed",
2136
- roles: {
2137
- sonnet: {
2138
- upstreamModel: "MiniMax-M2.7-highspeed",
2139
- displayName: "MiniMax-M2.7-highspeed",
2140
- declareOneM: true,
2141
- },
2142
- opus: {
2143
- upstreamModel: "MiniMax-M2.7-highspeed",
2144
- displayName: "MiniMax-M2.7-highspeed",
2145
- declareOneM: true,
2146
- },
2147
- haiku: {
2148
- upstreamModel: "MiniMax-M2.7-highspeed",
2149
- displayName: "MiniMax-M2.7-highspeed",
2150
- declareOneM: false,
2151
- },
2152
- },
2153
- },
2154
- openclaw: {
2155
- selectionKind: "single-model",
2156
- protocolPreference: "chat_completions",
2157
- defaultModel: "gpt-4",
2158
- },
2159
- opencode: {
2160
- selectionKind: "single-model",
2161
- protocolPreference: "responses",
2162
- defaultModel: "gpt-4",
2163
- },
2164
- hermes: {
2165
- selectionKind: "single-model",
2166
- protocolPreference: "chat_completions",
2167
- defaultModel: "gpt-4",
2168
- },
2169
- },
2170
- home: PROVIDER_HOME
2171
- }, store);
2172
- expect(applied).toEqual(expect.arrayContaining([
2173
- expect.objectContaining({ providerId: "codex", action: "updated" }),
2174
- expect.objectContaining({ providerId: "hermes", action: "created" })
2175
- ]));
2176
-
2177
- const codex = fs.readFileSync(path.join(PROVIDER_HOME, ".codex", "config.toml"), "utf8");
2178
- expect(codex).toContain("approval_policy = \"never\"");
2179
- expect(codex).toContain("[tokenbuddy]");
2180
- expect(codex).toContain(`proxy_url = "${proxyUrl}"`);
2181
-
2182
- const claude = JSON.parse(fs.readFileSync(path.join(PROVIDER_HOME, ".claude", "settings.json"), "utf8"));
2183
- expect(claude.theme).toBe("dark");
2184
- expect(claude.env.ANTHROPIC_BASE_URL).toBe(proxyUrl);
2185
- expect(claude.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe("claude-sonnet-4-6[1M]");
2186
- expect(claude.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe("claude-opus-4-7[1M]");
2187
- expect(claude.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe("claude-haiku-4-5");
2188
- expect(claude.env.ANTHROPIC_DEFAULT_SONNET_MODEL_NAME).toBe("MiniMax-M2.7-highspeed");
2189
- expect(store.getProviderRuntimeConfig("claude-code")).toBeDefined();
2190
- expect(store.getDaemonRuntimeConfig("routing")).toBeUndefined();
2191
- expect(store.getProviderRuntimeConfig("opencode")?.config).not.toHaveProperty("sellerId");
2192
-
2193
- const openclaw = JSON.parse(fs.readFileSync(path.join(PROVIDER_HOME, ".openclaw", "openclaw.json"), "utf8"));
2194
- expect(openclaw.keep).toBe("field");
2195
- expect(openclaw.models.providers.existing.baseUrl).toBe("https://example.invalid/v1");
2196
- expect(openclaw.models.providers.tokenbuddy.baseUrl).toBe(`${proxyUrl}/v1`);
2197
- expect(openclaw.models.providers.tokenbuddy.apiKey).toBe("TOKENBUDDY_PROXY");
2198
- expect(openclaw.models.providers.tokenbuddy.models).toEqual(expect.arrayContaining([
2199
- expect.objectContaining({ id: "gpt-4", name: "gpt-4", api: "openai-completions" }),
2200
- ]));
2201
- expect(openclaw.agents.defaults.model).toBe("tokenbuddy/gpt-4");
2202
- const opencode = JSON.parse(fs.readFileSync(path.join(PROVIDER_HOME, ".config", "opencode", "opencode.json"), "utf8"));
2203
- expect(opencode.share).toBe("disabled");
2204
- expect(JSON.stringify(opencode)).not.toContain("sellerId");
2205
- expect(opencode.provider.tokenbuddy.options.baseURL).toBe(`${proxyUrl}/v1`);
2206
- expect(opencode.provider.tokenbuddy.models["gpt-4"].name).toBe("gpt-4");
2207
- expect(detectProviders({ home: PROVIDER_HOME })).toEqual(expect.arrayContaining([
2208
- expect.objectContaining({
2209
- id: "opencode",
2210
- status: "configured",
2211
- configured: true,
2212
- }),
2213
- ]));
2214
- const hermesConfig = fs.readFileSync(path.join(PROVIDER_HOME, ".hermes", "config.yaml"), "utf8");
2215
- expect(hermesConfig).toContain("model:");
2216
- expect(hermesConfig).toContain("default: gpt-4");
2217
- expect(hermesConfig).toContain("provider: custom");
2218
- expect(hermesConfig).toContain(`base_url: "${proxyUrl}/v1"`);
2219
- expect(hermesConfig).toContain("api_key: TOKENBUDDY_PROXY");
2220
- expect(hermesConfig).toContain("api_mode: chat_completions");
2221
- expect(store.getProviderInstallSnapshot("codex")).toBeDefined();
2222
-
2223
- const rolledBack = rollbackProviderInstall({
2224
- providers: ["codex", "claude-code", "openclaw", "opencode", "hermes"],
2225
- home: PROVIDER_HOME
2226
- }, store);
2227
-
2228
- expect(rolledBack).toEqual(expect.arrayContaining([
2229
- expect.objectContaining({ providerId: "codex", action: "restored" }),
2230
- expect.objectContaining({ providerId: "hermes", action: "removed" })
2231
- ]));
2232
- expect(fs.readFileSync(path.join(PROVIDER_HOME, ".codex", "config.toml"), "utf8")).toBe("approval_policy = \"never\"\n");
2233
- expect(JSON.parse(fs.readFileSync(path.join(PROVIDER_HOME, ".openclaw", "openclaw.json"), "utf8"))).toMatchObject({
2234
- keep: "field",
2235
- agents: {
2236
- defaults: {
2237
- model: "existing/existing-model",
2238
- },
2239
- },
2240
- });
2241
- expect(JSON.parse(fs.readFileSync(path.join(PROVIDER_HOME, ".config", "opencode", "opencode.json"), "utf8"))).toEqual({ share: "disabled" });
2242
- expect(fs.existsSync(path.join(PROVIDER_HOME, ".hermes", "config.yaml"))).toBe(false);
2243
- expect(store.getProviderInstallSnapshot("codex")).toBeUndefined();
2244
- expect(store.getProviderRuntimeConfig("claude-code")).toBeUndefined();
2245
- } finally {
2246
- store.close();
2247
- }
2248
- });
2249
-
2250
- test("opencode provider install uses @ai-sdk/openai-compatible for chat completions", () => {
2251
- const config: ProviderRuntimeConfig = {
2252
- selectionKind: "single-model",
2253
- protocolPreference: "chat_completions",
2254
- defaultModel: "gpt-5.4",
2255
- };
2256
- const changes = previewProviderInstall({
2257
- providers: ["opencode"],
2258
- proxyUrl: "http://127.0.0.1:17821",
2259
- providerSelections: { opencode: config },
2260
- home: PROVIDER_HOME,
2261
- });
2262
- const change = changes.find((c) => c.providerId === "opencode");
2263
- expect(change).toBeDefined();
2264
- expect(change?.content).toBeDefined();
2265
- const parsed = JSON.parse(change!.content!);
2266
- expect(parsed.provider.tokenbuddy.npm).toBe("@ai-sdk/openai-compatible");
2267
- expect(parsed.model).toBe("tokenbuddy/gpt-5.4");
2268
- expect(parsed.provider.tokenbuddy.options.baseURL).toBe("http://127.0.0.1:17821/v1");
2269
- });
2270
-
2271
- test("hermes install preserves existing config and writes active config.yaml model section", () => {
2272
- fs.mkdirSync(path.join(PROVIDER_HOME, ".hermes"), { recursive: true });
2273
- fs.writeFileSync(path.join(PROVIDER_HOME, ".hermes", "config.yaml"), [
2274
- "display:",
2275
- " compact: false",
2276
- "model:",
2277
- " default: existing-model",
2278
- " provider: existing-provider",
2279
- " base_url: https://existing.invalid/v1",
2280
- "fallback_providers: []",
2281
- "",
2282
- ].join("\n"), "utf8");
2283
-
2284
- const changes = previewProviderInstall({
2285
- providers: ["hermes"],
2286
- proxyUrl,
2287
- providerSelections: {
2288
- hermes: {
2289
- selectionKind: "single-model",
2290
- protocolPreference: "chat_completions",
2291
- defaultModel: "gpt-5.4",
2292
- },
2293
- },
2294
- home: PROVIDER_HOME,
2295
- });
2296
-
2297
- const parsed = changes.find((change) => change.providerId === "hermes")?.content || "";
2298
- expect(parsed).toContain("display:");
2299
- expect(parsed).toContain("compact: false");
2300
- expect(parsed).toContain("fallback_providers:");
2301
- expect(parsed).toContain("default: gpt-5.4");
2302
- expect(parsed).toContain("provider: custom");
2303
- expect(parsed).toContain(`base_url: "${proxyUrl}/v1"`);
2304
- expect(parsed).toContain("api_key: TOKENBUDDY_PROXY");
2305
- expect(parsed).toContain("api_mode: chat_completions");
2306
- });
2307
-
2308
- test("legacy terminal rewrite helpers use active OpenClaw and Hermes config files", () => {
2309
- const helperHome = path.join(PROVIDER_HOME, "terminal-helper-home");
2310
- const openclawPath = path.join(helperHome, ".openclaw", "openclaw.json");
2311
- const hermesPath = path.join(helperHome, ".hermes", "config.yaml");
2312
- fs.mkdirSync(path.dirname(openclawPath), { recursive: true });
2313
- fs.mkdirSync(path.dirname(hermesPath), { recursive: true });
2314
- fs.writeFileSync(openclawPath, JSON.stringify({
2315
- models: {
2316
- providers: {
2317
- existing: {
2318
- baseUrl: "https://example.invalid/v1",
2319
- models: [{ id: "existing-model", name: "existing-model" }],
2320
- },
2321
- },
2322
- },
2323
- agents: {
2324
- defaults: {
2325
- model: "existing/existing-model",
2326
- },
2327
- },
2328
- }, null, 2), "utf8");
2329
- fs.writeFileSync(hermesPath, [
2330
- "display:",
2331
- " compact: false",
2332
- "model:",
2333
- " default: existing-model",
2334
- " provider: existing-provider",
2335
- "fallback_providers: []",
2336
- "",
2337
- ].join("\n"), "utf8");
2338
-
2339
- rewriteOpenclaw(openclawPath, proxyUrl, "gpt-5.4");
2340
- rewriteHermes(hermesPath, proxyUrl, "gpt-5.4");
2341
-
2342
- const openclaw = JSON.parse(fs.readFileSync(openclawPath, "utf8"));
2343
- expect(openclaw.models.providers.existing.baseUrl).toBe("https://example.invalid/v1");
2344
- expect(openclaw.models.providers.tokenbuddy.baseUrl).toBe(`${proxyUrl}/v1`);
2345
- expect(openclaw.models.providers.tokenbuddy.apiKey).toBe("TOKENBUDDY_PROXY");
2346
- expect(openclaw.models.providers.tokenbuddy.models).toEqual(expect.arrayContaining([
2347
- expect.objectContaining({ id: "gpt-5.4", name: "gpt-5.4", api: "openai-completions" }),
2348
- ]));
2349
- expect(openclaw.agents.defaults.model).toBe("tokenbuddy/gpt-5.4");
2350
-
2351
- const hermes = fs.readFileSync(hermesPath, "utf8");
2352
- expect(hermes).toContain("display:");
2353
- expect(hermes).toContain("compact: false");
2354
- expect(hermes).toContain("fallback_providers:");
2355
- expect(hermes).toContain("default: gpt-5.4");
2356
- expect(hermes).toContain("provider: custom");
2357
- expect(hermes).toContain(`base_url: "${proxyUrl}/v1"`);
2358
- expect(hermes).toContain("api_key: TOKENBUDDY_PROXY");
2359
- expect(hermes).toContain("api_mode: chat_completions");
2360
- });
2361
- });
2362
-
2363
- describe("TokenBuddy CLI and Daemon Integration Tests", () => {
2364
- let daemon: TokenbuddyDaemon;
2365
- let mockSellerServer: http.Server;
2366
- let sellerReqCount = 0;
2367
- let completeReqCount = 0;
2368
- let balanceReqCount = 0;
2369
- let mockSellerPort: number;
2370
- let daemonControlPort: number;
2371
- let daemonProxyPort: number;
2372
- const insufficientFundsAttempts = new Map<string, number>();
2373
- const sellerRequests: Array<{
2374
- url?: string;
2375
- authorization?: string;
2376
- idempotencyKey?: string;
2377
- paymentMethod?: string;
2378
- body?: any;
2379
- }> = [];
2380
-
2381
- const readJsonBody = (req: http.IncomingMessage): Promise<any> => new Promise((resolve) => {
2382
- let body = "";
2383
- req.on("data", (chunk) => {
2384
- body += chunk.toString();
2385
- });
2386
- req.on("end", () => {
2387
- resolve(body ? JSON.parse(body) : {});
2388
- });
2389
- });
2390
-
2391
- function setSettlementHeader(res: http.ServerResponse, requestId: string, settledMicros: number, remainingCreditMicros: number): void {
2392
- res.setHeader("X-TokenBuddy-Settlement", JSON.stringify({
2393
- requestId,
2394
- request_id: requestId,
2395
- settledMicros,
2396
- settled_micros: settledMicros,
2397
- settledUsdMicros: settledMicros,
2398
- settled_usd_micros: settledMicros,
2399
- remainingCreditMicros,
2400
- remaining_credit_micros: remainingCreditMicros,
2401
- reservedBalanceMicros: 0,
2402
- reserved_balance_micros: 0,
2403
- spentMicros: settledMicros,
2404
- spent_micros: settledMicros,
2405
- priceVersion: "openrouter_usd.v1",
2406
- price_version: "openrouter_usd.v1"
2407
- }));
2408
- }
2409
-
2410
- beforeAll((done) => {
2411
- mockSellerServer = http.createServer(async (req, res) => {
2412
- res.setHeader("Content-Type", "application/json");
2413
-
2414
- if (req.url === "/registry/sellers") {
2415
- res.end(JSON.stringify({
2416
- version: 1,
2417
- defaultSeller: "mock-seller",
2418
- sellers: [
2419
- {
2420
- id: "incompatible-seller",
2421
- name: "Incompatible Seller",
2422
- url: `http://127.0.0.1:${mockSellerPort}/incompatible`,
2423
- supportedProtocols: ["chat_completions"],
2424
- paymentMethods: ["mock"],
2425
- models: ["incompatible-only"]
2426
- },
2427
- {
2428
- id: "mock-seller",
2429
- name: "Mock Seller",
2430
- url: `http://127.0.0.1:${mockSellerPort}`,
2431
- supportedProtocols: ["chat_completions", "responses", "messages"],
2432
- paymentMethods: ["mock"],
2433
- models: ["gpt-4", "gpt-4.1-mini", "claude-3-5-sonnet"]
2434
- }
2435
- ]
2436
- }));
2437
- return;
2438
- }
2439
-
2440
- if (req.url === "/incompatible/manifest") {
2441
- res.end(JSON.stringify({
2442
- sellerId: "incompatible-seller",
2443
- supportedProtocols: ["chat_completions"],
2444
- paymentMethods: ["mock"],
2445
- models: [
2446
- { id: "other-model" }
2447
- ]
2448
- }));
2449
- return;
2450
- }
2451
-
2452
- if (req.url === "/manifest") {
2453
- res.end(JSON.stringify({
2454
- sellerId: "mock-seller",
2455
- supportedProtocols: ["chat_completions", "responses", "messages"],
2456
- paymentMethods: ["mock"],
2457
- models: [
2458
- { id: "gpt-4" },
2459
- { id: "gpt-4.1-mini" },
2460
- { id: "claude-3-5-sonnet" }
2461
- ]
2462
- }));
2463
- return;
2464
- }
2465
-
2466
- if (req.url === "/purchase/create") {
2467
- const body = await readJsonBody(req);
2468
- sellerRequests.push({ url: req.url, paymentMethod: body.paymentMethod, body });
2469
- sellerReqCount++;
2470
- res.end(JSON.stringify({
2471
- purchaseId: "pur_mock_123",
2472
- status: "pending",
2473
- creditMicros: 2000000,
2474
- currency: "USD",
2475
- expiresAt: new Date(Date.now() + 86400 * 1000).toISOString()
2476
- }));
2477
- return;
2478
- }
2479
-
2480
- if (req.url === "/purchase/complete") {
2481
- const body = await readJsonBody(req);
2482
- sellerRequests.push({ url: req.url, paymentMethod: body.paymentMethod, body });
2483
- completeReqCount++;
2484
- res.end(JSON.stringify({
2485
- purchaseId: "pur_mock_123",
2486
- status: "active",
2487
- accessToken: "tok_mock_token_abc",
2488
- tokenClass: "model:gpt-4",
2489
- creditMicros: 2000000,
2490
- currency: "USD"
2491
- }));
2492
- return;
2493
- }
2494
-
2495
- if (req.url === "/v1/balance") {
2496
- balanceReqCount++;
2497
- sellerRequests.push({
2498
- url: req.url,
2499
- authorization: req.headers.authorization
2500
- });
2501
- res.end(JSON.stringify({
2502
- tokenId: "cred_mock",
2503
- creditMicros: 1000,
2504
- reservedMicros: 0,
2505
- spentMicros: 1999000,
2506
- currency: "Micros"
2507
- }));
2508
- return;
2509
- }
2510
-
2511
- if (req.url === "/v1/chat/completions") {
2512
- const body = await readJsonBody(req);
2513
- sellerRequests.push({
2514
- url: req.url,
2515
- authorization: req.headers.authorization,
2516
- idempotencyKey: req.headers["idempotency-key"] as string | undefined,
2517
- body
2518
- });
2519
- if (body.requestId === "chat_req_402_retry") {
2520
- const attempts = insufficientFundsAttempts.get(body.requestId) || 0;
2521
- insufficientFundsAttempts.set(body.requestId, attempts + 1);
2522
- if (attempts === 0) {
2523
- res.statusCode = 402;
2524
- res.end(JSON.stringify({
2525
- error: {
2526
- code: "insufficient_funds",
2527
- message: "Insufficient funds"
2528
- }
2529
- }));
2530
- return;
2531
- }
2532
- }
2533
- if (body.stream) {
2534
- res.writeHead(200, { "Content-Type": "text/event-stream" });
2535
- res.write("data: {\"id\":\"chatcmpl-stream\",\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\n\n");
2536
- if (body.requestId === "stream_req_slow_after_headers") {
2537
- await new Promise((resolve) => setTimeout(resolve, 120));
2538
- res.write("data: {\"id\":\"chatcmpl-stream\",\"choices\":[{\"delta\":{\"content\":\" later\"}}]}\n\n");
2539
- res.end("data: [DONE]\n\n");
2540
- return;
2541
- }
2542
- res.write("event: tokenbuddy.settlement\n");
2543
- res.write(`data: ${JSON.stringify({
2544
- requestId: body.requestId || "stream_req_mock",
2545
- settledMicros: 110,
2546
- settledUsdMicros: 110,
2547
- remainingCreditMicros: 1999890,
2548
- reservedBalanceMicros: 0,
2549
- spentMicros: 110,
2550
- priceVersion: "openrouter_usd.v1"
2551
- })}\n\n`);
2552
- res.end("data: [DONE]\n\n");
2553
- return;
2554
- }
2555
- setSettlementHeader(res, body.requestId || "chat_req_mock", 110, 1999890);
2556
- res.end(JSON.stringify({
2557
- id: "chatcmpl-mock",
2558
- usage: { prompt_tokens: 10, completion_tokens: 10 }
2559
- }));
2560
- return;
2561
- }
2562
-
2563
- if (req.url === "/v1/responses") {
2564
- const body = await readJsonBody(req);
2565
- sellerRequests.push({
2566
- url: req.url,
2567
- authorization: req.headers.authorization,
2568
- idempotencyKey: req.headers["idempotency-key"] as string | undefined,
2569
- body
2570
- });
2571
- if (body.requestId === "responses_req_stream_shape") {
2572
- res.writeHead(200, { "Content-Type": "text/event-stream" });
2573
- 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');
2574
- 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');
2575
- 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');
2576
- res.write('event: response.output_text.done\ndata: {"type":"response.output_text.done","item_id":"item_stream_shape","sequence_number":3}\n\n');
2577
- 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');
2578
- 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');
2579
- return;
2580
- }
2581
- if (body.requestId === "responses_req_br") {
2582
- const compressed = zlib.brotliCompressSync(Buffer.from(JSON.stringify({
2583
- id: "resp-br",
2584
- usage: { input_tokens: 7, output_tokens: 9 }
2585
- })));
2586
- res.writeHead(200, {
2587
- "Content-Type": "application/json",
2588
- "Content-Encoding": "br",
2589
- "Content-Length": compressed.byteLength
2590
- });
2591
- res.end(compressed);
2592
- return;
2593
- }
2594
- setSettlementHeader(res, body.requestId || "responses_req_mock", 64, 1999936);
2595
- res.end(JSON.stringify({
2596
- id: "resp-mock",
2597
- usage: { input_tokens: 7, output_tokens: 9 }
2598
- }));
2599
- return;
2600
- }
2601
-
2602
- if (req.url === "/v1/messages" || req.url === "/messages") {
2603
- const body = await readJsonBody(req);
2604
- sellerRequests.push({
2605
- url: req.url,
2606
- authorization: req.headers.authorization,
2607
- idempotencyKey: req.headers["idempotency-key"] as string | undefined,
2608
- body
2609
- });
2610
- setSettlementHeader(res, body.requestId || "messages_req_mock", 44, 1999956);
2611
- res.end(JSON.stringify({
2612
- id: "msg-mock",
2613
- usage: { input_tokens: 5, output_tokens: 6 }
2614
- }));
2615
- return;
2616
- }
2617
-
2618
- res.end("{}");
2619
- });
2620
-
2621
- mockSellerServer.listen(0, "127.0.0.1", () => {
2622
- mockSellerPort = (mockSellerServer.address() as AddressInfo).port;
2623
- done();
2624
- });
2625
- });
2626
-
2627
- afterAll((done) => {
2628
- mockSellerServer.close(done);
2629
- });
2630
-
2631
- beforeEach(() => {
2632
- rmSqliteFiles(TEMP_BUYER_DB);
2633
- sellerReqCount = 0;
2634
- completeReqCount = 0;
2635
- balanceReqCount = 0;
2636
- insufficientFundsAttempts.clear();
2637
- sellerRequests.length = 0;
2638
- const seedStore = new BuyerStore({ dbPath: TEMP_BUYER_DB });
2639
- seedStore.savePayment({
2640
- method: "mock",
2641
- enabled: true,
2642
- isDefault: true,
2643
- config: { channel: "control-plane-test" }
2644
- });
2645
- seedStore.saveDaemonRuntimeConfig(PROVIDER_MODE_CONFIG_KEY, {
2646
- mode: "auto",
2647
- updatedAt: new Date().toISOString()
2648
- });
2649
- seedStore.recordPurchaseLedger({
2650
- purchaseId: "pur_control_1",
2651
- sellerKey: "mock-seller",
2652
- modelId: "gpt-4",
2653
- paymentMethod: "mock",
2654
- status: "funded",
2655
- creditMicros: 1000000,
2656
- currency: "USD",
2657
- paymentReference: "raw-control-payment-proof"
2658
- });
2659
- seedStore.recordInferenceLedger({
2660
- requestId: "req_control_1",
2661
- sellerKey: "mock-seller",
2662
- modelId: "gpt-4",
2663
- endpoint: "/v1/chat/completions",
2664
- status: "settled",
2665
- promptTokens: 10,
2666
- completionTokens: 20,
2667
- cacheReadTokens: 4,
2668
- billedMicros: 70,
2669
- prompt: "raw control prompt",
2670
- response: "raw control response"
2671
- });
2672
- seedStore.saveProviderRuntimeConfig("claude-code", {
2673
- selectionKind: "claude-role-mapping",
2674
- protocolPreference: "messages",
2675
- fallbackModel: "claude-3-5-sonnet",
2676
- roles: {
2677
- sonnet: {
2678
- upstreamModel: "claude-3-5-sonnet",
2679
- displayName: "Claude Sonnet Mock",
2680
- declareOneM: true
2681
- },
2682
- opus: {
2683
- upstreamModel: "claude-3-5-sonnet",
2684
- displayName: "Claude Opus Mock",
2685
- declareOneM: true
2686
- },
2687
- haiku: {
2688
- upstreamModel: "claude-3-5-sonnet",
2689
- displayName: "Claude Haiku Mock",
2690
- declareOneM: false
2691
- }
2692
- }
2693
- });
2694
- seedStore.close();
2695
-
2696
- daemon = new TokenbuddyDaemon({
2697
- controlPort: 0,
2698
- proxyPort: 0,
2699
- dbPath: TEMP_BUYER_DB,
2700
- sellerRegistryUrl: `http://127.0.0.1:${mockSellerPort}/registry/sellers`
2701
- });
2702
- daemon.start();
2703
- daemonControlPort = ((daemon as any).controlServer.address() as AddressInfo).port;
2704
- daemonProxyPort = ((daemon as any).proxyServer.address() as AddressInfo).port;
2705
- });
2706
-
2707
- afterEach(() => {
2708
- daemon.stop();
2709
- rmSqliteFiles(TEMP_BUYER_DB);
2710
- });
2711
-
2712
- test("Terminal detection Candidates works without errors", () => {
2713
- const list = detectTerminals();
2714
- expect(list.length).toBeGreaterThan(0);
2715
- expect(list.some(c => c.id === "claude-code")).toBe(true);
2716
- });
2717
-
2718
- test("control plane exposes health, registry-backed models, payments, and safe ledgers", async () => {
2719
- const controlUrl = `http://127.0.0.1:${daemonControlPort}`;
2720
-
2721
- const health = await (await fetch(`${controlUrl}/health`)).json() as any;
2722
- expect(health).toMatchObject({
2723
- status: "ok",
2724
- controlPort: daemonControlPort,
2725
- proxyPort: daemonProxyPort,
2726
- store: { journalMode: "wal" }
2727
- });
2728
-
2729
- const status = await (await fetch(`${controlUrl}/status`)).json() as any;
2730
- expect(status).toMatchObject({
2731
- status: "running",
2732
- controlPort: daemonControlPort,
2733
- proxyPort: daemonProxyPort,
2734
- sellerRegistryUrl: `http://127.0.0.1:${mockSellerPort}/registry/sellers`,
2735
- store: {
2736
- paymentsCount: 1,
2737
- purchaseLedgerCount: 1,
2738
- inferenceLedgerCount: 1
2739
- }
2740
- });
2741
-
2742
- const sellers = await (await fetch(`${controlUrl}/sellers`)).json() as any;
2743
- expect(sellers.sellers).toEqual(expect.arrayContaining([
2744
- expect.objectContaining({
2745
- id: "mock-seller",
2746
- status: "configured",
2747
- paymentMethods: ["mock"]
2748
- })
2749
- ]));
2750
-
2751
- const models = await (await fetch(`${controlUrl}/models`)).json() as any;
2752
- expect(models.data).toEqual(expect.arrayContaining([
2753
- expect.objectContaining({
2754
- id: "gpt-4",
2755
- sellerId: "mock-seller",
2756
- paymentMethods: ["mock"]
2757
- })
2758
- ]));
2759
- expect(models.sellers).toEqual(expect.arrayContaining([
2760
- expect.objectContaining({ id: "mock-seller", status: "ok" })
2761
- ]));
2762
-
2763
- const payments = await (await fetch(`${controlUrl}/payments`)).json() as any;
2764
- expect(payments.payments).toMatchObject([
2765
- { method: "mock", enabled: true, isDefault: true }
2766
- ]);
2767
-
2768
- const purchases = await (await fetch(`${controlUrl}/ledger/purchases`)).json() as any;
2769
- const inferences = await (await fetch(`${controlUrl}/ledger/inferences`)).json() as any;
2770
- const publicOutput = JSON.stringify({ purchases, inferences, payments, models, sellers, status, health });
2771
-
2772
- expect(purchases.purchases).toHaveLength(1);
2773
- expect(inferences.inferences).toHaveLength(1);
2774
- expect(publicOutput).toContain("paymentReferenceHash");
2775
- expect(publicOutput).toContain("promptHash");
2776
- expect(publicOutput).toContain("responseHash");
2777
- for (const secret of [
2778
- "raw-control-payment-proof",
2779
- "raw control prompt",
2780
- "raw control response",
2781
- "payCredential"
2782
- ]) {
2783
- expect(publicOutput).not.toContain(secret);
2784
- }
2785
- });
2786
-
2787
- test("coalesces concurrent chat requests and preserves purchase/proxy headers", async () => {
2788
- const chatReq = {
2789
- model: "gpt-4",
2790
- messages: [{ role: "user", content: "raw concurrent prompt secret" }],
2791
- requestId: "chat_req_parallel"
2792
- };
2793
-
2794
- const sendRequest = () => fetch(`http://127.0.0.1:${daemonProxyPort}/v1/chat/completions`, {
2795
- method: "POST",
2796
- headers: {
2797
- "Content-Type": "application/json",
2798
- "Idempotency-Key": "idem-chat-preserved"
2799
- },
2800
- body: JSON.stringify(chatReq)
2801
- });
2802
-
2803
- const responses = await Promise.all([
2804
- sendRequest(), sendRequest(), sendRequest(), sendRequest(), sendRequest(),
2805
- sendRequest(), sendRequest(), sendRequest(), sendRequest(), sendRequest()
2806
- ]);
2807
-
2808
- for (const r of responses) {
2809
- expect(r.ok).toBe(true);
2810
- const data: any = await r.json();
2811
- expect(data.id).toBe("chatcmpl-mock");
2812
- }
2813
-
2814
- expect(sellerReqCount).toBe(1);
2815
- expect(completeReqCount).toBe(1);
2816
- expect(sellerRequests.find((request) => request.url === "/purchase/create")).toMatchObject({
2817
- paymentMethod: "mock"
2818
- });
2819
- const chatForwards = sellerRequests.filter((request) => request.url === "/v1/chat/completions");
2820
- expect(chatForwards).toHaveLength(10);
2821
- expect(chatForwards.every((request) => request.authorization === "Bearer tok_mock_token_abc")).toBe(true);
2822
- expect(chatForwards.every((request) => request.idempotencyKey === "idem-chat-preserved")).toBe(true);
2823
-
2824
- const controlUrl = `http://127.0.0.1:${daemonControlPort}`;
2825
- const purchases = await (await fetch(`${controlUrl}/ledger/purchases`)).json() as any;
2826
- const inferences = await (await fetch(`${controlUrl}/ledger/inferences`)).json() as any;
2827
- expect(purchases.purchases.some((entry: any) => entry.purchaseId === "pur_mock_123" && entry.paymentMethod === "mock")).toBe(true);
2828
- expect(inferences.inferences.filter((entry: any) => entry.endpoint === "/v1/chat/completions")).toHaveLength(11);
2829
- const chatLedgers = inferences.inferences.filter((entry: any) => entry.requestId === "chat_req_parallel");
2830
- expect(chatLedgers).toHaveLength(10);
2831
- expect(chatLedgers).toEqual(expect.arrayContaining([
2832
- expect.objectContaining({
2833
- billedMicros: 110,
2834
- estimatedMicros: 80,
2835
- settledMicros: 110,
2836
- settledUsdMicros: 110,
2837
- priceVersion: "openrouter_usd.v1",
2838
- balanceSnapshotMicros: 1999890,
2839
- balanceSource: "seller_authoritative"
2840
- })
2841
- ]));
2842
- expect(balanceReqCount).toBe(0);
2843
- const store = new BuyerStore({ dbPath: TEMP_BUYER_DB });
2844
- try {
2845
- expect(store.getToken("mock-seller")).toMatchObject({
2846
- balanceMicros: 1999890,
2847
- reservedMicros: 0,
2848
- spentMicros: 110,
2849
- balanceSource: "seller_settlement_summary"
2850
- });
2851
- } finally {
2852
- store.close();
2853
- }
2854
- const publicOutput = JSON.stringify({ purchases, inferences });
2855
- expect(publicOutput).not.toContain("raw concurrent prompt secret");
2856
- expect(publicOutput).not.toContain("tok_mock_token_abc");
2857
- });
2858
-
2859
- test("refreshes balance, auto-purchases, and retries once after seller 402 insufficient funds", async () => {
2860
- const store = new BuyerStore({ dbPath: TEMP_BUYER_DB });
2861
- store.saveToken("mock-seller", "tok_existing_high_cache", "model:gpt-4", 900000, "2030-01-01T00:00:00.000Z");
2862
- store.close();
2863
-
2864
- const response = await fetch(`http://127.0.0.1:${daemonProxyPort}/v1/chat/completions`, {
2865
- method: "POST",
2866
- headers: {
2867
- "Content-Type": "application/json",
2868
- "Idempotency-Key": "idem-402-retry"
2869
- },
2870
- body: JSON.stringify({
2871
- model: "gpt-4",
2872
- messages: [{ role: "user", content: "trigger 402" }],
2873
- requestId: "chat_req_402_retry"
2874
- })
2875
- });
2876
-
2877
- expect(response.ok).toBe(true);
2878
- expect((await response.json() as any).id).toBe("chatcmpl-mock");
2879
- expect(balanceReqCount).toBe(1);
2880
- expect(sellerRequests.filter((request) => request.url === "/v1/chat/completions" && request.body?.requestId === "chat_req_402_retry")).toHaveLength(2);
2881
- expect(sellerRequests.filter((request) => request.url === "/purchase/create")).toHaveLength(1);
2882
- expect(sellerRequests.filter((request) => request.url === "/purchase/complete")).toHaveLength(1);
2883
- expect(sellerRequests.filter((request) => request.url === "/v1/chat/completions" && request.body?.requestId === "chat_req_402_retry")
2884
- .every((request) => request.idempotencyKey === "idem-402-retry")).toBe(true);
2885
-
2886
- const inferences = await (await fetch(`http://127.0.0.1:${daemonControlPort}/ledger/inferences`)).json() as any;
2887
- expect(inferences.inferences).toEqual(expect.arrayContaining([
2888
- expect.objectContaining({
2889
- requestId: "chat_req_402_retry",
2890
- billedMicros: 110,
2891
- estimatedMicros: 80,
2892
- settledMicros: 110,
2893
- balanceSource: "seller_authoritative"
2894
- })
2895
- ]));
2896
- });
2897
-
2898
- test("proxies models, responses, and anthropic message endpoints through compatible seller manifests", async () => {
2899
- const proxyUrl = `http://127.0.0.1:${daemonProxyPort}`;
2900
-
2901
- const models = await (await fetch(`${proxyUrl}/v1/models`)).json() as any;
2902
- expect(models.data).toEqual(expect.arrayContaining([
2903
- expect.objectContaining({
2904
- id: "gpt-4",
2905
- sellerId: "mock-seller"
2906
- })
2907
- ]));
2908
-
2909
- const responses = await fetch(`${proxyUrl}/v1/responses`, {
2910
- method: "POST",
2911
- headers: {
2912
- "Content-Type": "application/json",
2913
- "Idempotency-Key": "idem-responses-preserved"
2914
- },
2915
- body: JSON.stringify({
2916
- model: "gpt-4.1-mini",
2917
- input: "raw responses prompt secret",
2918
- requestId: "responses_req_1"
2919
- })
2920
- });
2921
- expect(responses.ok).toBe(true);
2922
- expect((await responses.json() as any).id).toBe("resp-mock");
2923
-
2924
- const largeInput = `raw large responses prompt secret ${"x".repeat(160 * 1024)}`;
2925
- const largeResponses = await fetch(`${proxyUrl}/v1/responses`, {
2926
- method: "POST",
2927
- headers: {
2928
- "Content-Type": "application/json",
2929
- "Idempotency-Key": "idem-large-responses-preserved"
2930
- },
2931
- body: JSON.stringify({
2932
- model: "gpt-4.1-mini",
2933
- input: largeInput,
2934
- requestId: "responses_req_large"
2935
- })
2936
- });
2937
- expect(largeResponses.ok).toBe(true);
2938
- expect((await largeResponses.json() as any).id).toBe("resp-mock");
2939
-
2940
- const compressedResponses = await fetch(`${proxyUrl}/v1/responses`, {
2941
- method: "POST",
2942
- headers: { "Content-Type": "application/json" },
2943
- body: JSON.stringify({
2944
- model: "gpt-4.1-mini",
2945
- input: "raw compressed responses prompt secret",
2946
- requestId: "responses_req_br"
2947
- })
2948
- });
2949
- expect(compressedResponses.ok).toBe(true);
2950
- expect(compressedResponses.headers.get("content-encoding")).toBeNull();
2951
- expect((await compressedResponses.json() as any).id).toBe("resp-br");
2952
-
2953
- for (const endpoint of ["/v1/messages", "/messages"]) {
2954
- const message = await fetch(`${proxyUrl}${endpoint}`, {
2955
- method: "POST",
2956
- headers: { "Content-Type": "application/json" },
2957
- body: JSON.stringify({
2958
- model: "claude-3-5-sonnet",
2959
- messages: [{ role: "user", content: "raw anthropic prompt secret" }]
2960
- })
2961
- });
2962
- expect(message.ok).toBe(true);
2963
- expect((await message.json() as any).id).toBe("msg-mock");
2964
- }
2965
-
2966
- expect(sellerRequests.find((request) => request.url === "/v1/responses")?.idempotencyKey).toBe("idem-responses-preserved");
2967
- expect(sellerRequests.find((request) => request.idempotencyKey === "idem-large-responses-preserved")?.body.input).toHaveLength(largeInput.length);
2968
- expect(sellerRequests.some((request) => request.url === "/v1/messages")).toBe(true);
2969
- expect(sellerRequests.some((request) => request.url === "/messages")).toBe(true);
2970
-
2971
- const inferences = await (await fetch(`http://127.0.0.1:${daemonControlPort}/ledger/inferences`)).json() as any;
2972
- expect(inferences.inferences).toEqual(expect.arrayContaining([
2973
- expect.objectContaining({ endpoint: "/v1/responses", promptTokens: 7, completionTokens: 9 }),
2974
- expect.objectContaining({ endpoint: "/v1/responses", requestId: "responses_req_large" }),
2975
- expect.objectContaining({ endpoint: "/v1/messages", promptTokens: 5, completionTokens: 6 }),
2976
- expect.objectContaining({ endpoint: "/messages", promptTokens: 5, completionTokens: 6 })
2977
- ]));
2978
- const publicOutput = JSON.stringify(inferences);
2979
- expect(publicOutput).not.toContain("raw responses prompt secret");
2980
- expect(publicOutput).not.toContain("raw large responses prompt secret");
2981
- expect(publicOutput).not.toContain("raw anthropic prompt secret");
2982
- });
2983
-
2984
- test("maps Claude role aliases to upstream models before message routing", async () => {
2985
- for (const model of ["sonnet", "claude-sonnet-4-6[1M]"]) {
2986
- const message = await fetch(`http://127.0.0.1:${daemonProxyPort}/v1/messages`, {
2987
- method: "POST",
2988
- headers: { "Content-Type": "application/json" },
2989
- body: JSON.stringify({
2990
- model,
2991
- messages: [{ role: "user", content: "role mapping request" }]
2992
- })
2993
- });
2994
- expect(message.ok).toBe(true);
2995
- expect((await message.json() as any).id).toBe("msg-mock");
2996
- }
2997
-
2998
- const messageRequests = sellerRequests.filter((request) => request.url === "/v1/messages");
2999
- expect(messageRequests).toEqual(expect.arrayContaining([
3000
- expect.objectContaining({
3001
- body: expect.objectContaining({
3002
- model: "claude-3-5-sonnet"
3003
- })
3004
- })
3005
- ]));
3006
- });
3007
-
3008
- test("passes through streaming chat responses and records safe ledger metadata", async () => {
3009
- const response = await fetch(`http://127.0.0.1:${daemonProxyPort}/v1/chat/completions`, {
3010
- method: "POST",
3011
- headers: {
3012
- "Content-Type": "application/json",
3013
- "Idempotency-Key": "idem-stream-preserved"
3014
- },
3015
- body: JSON.stringify({
3016
- model: "gpt-4",
3017
- stream: true,
3018
- messages: [{ role: "user", content: "raw stream prompt secret" }],
3019
- requestId: "stream_req_1"
3020
- })
3021
- });
3022
-
3023
- expect(response.ok).toBe(true);
3024
- expect(response.headers.get("content-type")).toContain("text/event-stream");
3025
- const body = await response.text();
3026
- expect(body).toContain("chatcmpl-stream");
3027
- expect(body).toContain("[DONE]");
3028
- expect(body).not.toContain("}data:");
3029
- expect(body).toContain("}\n\ndata: [DONE]");
3030
- expect(body).not.toContain("tokenbuddy.settlement");
3031
- expect(sellerRequests.find((request) => request.url === "/v1/chat/completions" && request.body?.stream)?.idempotencyKey).toBe("idem-stream-preserved");
3032
-
3033
- const inferences = await (await fetch(`http://127.0.0.1:${daemonControlPort}/ledger/inferences`)).json() as any;
3034
- expect(inferences.inferences).toEqual(expect.arrayContaining([
3035
- expect.objectContaining({
3036
- endpoint: "/v1/chat/completions",
3037
- requestId: "stream_req_1",
3038
- status: "settled",
3039
- billedMicros: 110,
3040
- settledMicros: 110,
3041
- settledUsdMicros: 110,
3042
- balanceSource: "seller_authoritative"
3043
- })
3044
- ]));
3045
- const publicOutput = JSON.stringify(inferences);
3046
- expect(publicOutput).not.toContain("raw stream prompt secret");
3047
- expect(publicOutput).not.toContain("chatcmpl-stream");
3048
- });
3049
-
3050
- test("does not abort an active SSE stream after seller response headers arrive", async () => {
3051
- const previousDeadline = process.env.TB_PROXYD_REQUEST_DEADLINE_MS;
3052
- process.env.TB_PROXYD_REQUEST_DEADLINE_MS = "50";
3053
- try {
3054
- const response = await fetch(`http://127.0.0.1:${daemonProxyPort}/v1/chat/completions`, {
3055
- method: "POST",
3056
- headers: { "Content-Type": "application/json" },
3057
- body: JSON.stringify({
3058
- model: "gpt-4",
3059
- stream: true,
3060
- messages: [{ role: "user", content: "slow active stream" }],
3061
- requestId: "stream_req_slow_after_headers"
3062
- })
3063
- });
3064
-
3065
- expect(response.ok).toBe(true);
3066
- const body = await response.text();
3067
- expect(body).toContain("hello");
3068
- expect(body).toContain(" later");
3069
- expect(body).toContain("[DONE]");
3070
- } finally {
3071
- if (previousDeadline === undefined) {
3072
- delete process.env.TB_PROXYD_REQUEST_DEADLINE_MS;
3073
- } else {
3074
- process.env.TB_PROXYD_REQUEST_DEADLINE_MS = previousDeadline;
3075
- }
3076
- }
3077
- });
3078
-
3079
- test("passes through responses SSE bytes unchanged for OpenAI Responses API clients", async () => {
3080
- const response = await fetch(`http://127.0.0.1:${daemonProxyPort}/v1/responses`, {
3081
- method: "POST",
3082
- headers: { "Content-Type": "application/json" },
3083
- body: JSON.stringify({
3084
- model: "gpt-4.1-mini",
3085
- input: "shape normalization",
3086
- requestId: "responses_req_stream_shape",
3087
- stream: true
3088
- })
3089
- });
3090
-
3091
- expect(response.ok).toBe(true);
3092
- expect(response.headers.get("content-type")).toContain("text/event-stream");
3093
- const body = await response.text();
3094
- // 卖方原始 events 直转——不再注入 content_part.added / content_part.done
3095
- expect(body).toContain("event: response.created");
3096
- expect(body).toContain("event: response.output_item.added");
3097
- expect(body).toContain("event: response.output_text.delta");
3098
- expect(body).toContain("event: response.output_text.done");
3099
- expect(body).toContain("event: response.output_item.done");
3100
- expect(body).toContain("event: response.completed");
3101
- expect(body).toContain("\"item_id\":\"item_stream_shape\"");
3102
- expect(body).toContain("\"delta\":\"hello\"");
3103
- // 内部记账事件不泄露给客户端
3104
- expect(body).not.toContain("tokenbuddy.settlement");
3105
- });
3106
-
3107
- test("fails closed when no compatible seller can serve the requested model", async () => {
3108
- const requestId = "missing_model_route_diagnostics";
3109
- const response = await fetch(`http://127.0.0.1:${daemonProxyPort}/v1/chat/completions`, {
3110
- method: "POST",
3111
- headers: { "Content-Type": "application/json" },
3112
- body: JSON.stringify({
3113
- model: "missing-model",
3114
- messages: [{ role: "user", content: "hello" }],
3115
- requestId
3116
- })
3117
- });
3118
- expect(response.status).toBe(502);
3119
- const data = await response.json() as any;
3120
- expect(data.error.message).toContain("no compatible seller");
3121
-
3122
- await new Promise((resolve) => setTimeout(resolve, 100));
3123
- const logFile = resolveModuleLogFile("tb-proxyd");
3124
- const logs = fs.existsSync(logFile) ? fs.readFileSync(logFile, "utf8") : "";
3125
- const requestLogs = logs
3126
- .split("\n")
3127
- .filter((line) => line.includes(`requestId=${requestId}`))
3128
- .join("\n");
3129
- expect(requestLogs).toContain("event=route.candidates.prewarmed");
3130
- expect(requestLogs).toContain("routeReason=no_compatible_seller");
3131
- expect(requestLogs).toContain("sellerCount=0");
3132
- expect(requestLogs).toContain("candidateDiagnostics=");
3133
- });
3134
- });
3135
-
3136
- describe("TokenBuddy seller routing strategies", () => {
3137
- let server: http.Server;
3138
- let sellerPort: number;
3139
- let daemon: TokenbuddyDaemon;
3140
- let daemonProxyPort: number;
3141
- let daemonControlPort: number;
3142
- const events: Array<{ seller: string; url?: string; body?: any; idempotencyKey?: string }> = [];
3143
- let primaryPurchaseSucceeds = false;
3144
- let primaryInferenceFails = false;
3145
- let primaryInferenceFailsOnceWithIdempotencyConflict = false;
3146
- const primaryInferenceSeenRequestIds = new Set<string>();
3147
- let primaryInferenceBusy = false;
3148
- let primaryInferenceDelayMs = 0;
3149
- const dbPath = path.resolve(__dirname, "../../data-test/manual-routing-test.db");
3150
- const routeEvents = (): Array<{ seller: string; url?: string }> => events
3151
- .filter((event) => event.url !== "/primary/health" && event.url !== "/backup/health")
3152
- .filter((event) => event.url !== "/primary/manifest" && event.url !== "/backup/manifest")
3153
- .map((event) => ({ seller: event.seller, url: event.url }));
3154
-
3155
- const readJsonBody = (req: http.IncomingMessage): Promise<any> => new Promise((resolve) => {
3156
- let body = "";
3157
- req.on("data", (chunk) => {
3158
- body += chunk.toString();
3159
- });
3160
- req.on("end", () => {
3161
- resolve(body ? JSON.parse(body) : {});
3162
- });
3163
- });
3164
-
3165
- beforeAll((done) => {
3166
- server = http.createServer(async (req, res) => {
3167
- res.setHeader("Content-Type", "application/json");
3168
- if (req.url === "/registry/sellers") {
3169
- res.end(JSON.stringify({
3170
- version: 1,
3171
- defaultSeller: "primary-seller",
3172
- sellers: [
3173
- {
3174
- id: "primary-seller",
3175
- name: "Primary Seller",
3176
- url: `http://127.0.0.1:${sellerPort}/primary`,
3177
- supportedProtocols: ["chat_completions", "responses"],
3178
- paymentMethods: ["mock"],
3179
- models: ["gpt-manual"]
3180
- },
3181
- {
3182
- id: "backup-seller",
3183
- name: "Backup Seller",
3184
- url: `http://127.0.0.1:${sellerPort}/backup`,
3185
- supportedProtocols: ["chat_completions", "responses"],
3186
- paymentMethods: ["mock"],
3187
- models: ["gpt-manual"]
3188
- }
3189
- ]
3190
- }));
3191
- return;
3192
- }
3193
-
3194
- if (req.url === "/primary/manifest") {
3195
- events.push({ seller: "primary-seller", url: req.url });
3196
- res.end(JSON.stringify({
3197
- sellerId: "primary-seller",
3198
- supportedProtocols: ["chat_completions", "responses"],
3199
- paymentMethods: ["mock"],
3200
- selection: { discountRatio: 1 },
3201
- models: [{ id: "gpt-manual" }]
3202
- }));
3203
- return;
3204
- }
3205
-
3206
- if (req.url === "/backup/manifest") {
3207
- events.push({ seller: "backup-seller", url: req.url });
3208
- res.end(JSON.stringify({
3209
- sellerId: "backup-seller",
3210
- supportedProtocols: ["chat_completions", "responses"],
3211
- paymentMethods: ["mock"],
3212
- selection: { discountRatio: 0.01 },
3213
- models: [{ id: "gpt-manual" }]
3214
- }));
3215
- return;
3216
- }
3217
-
3218
- const body = await readJsonBody(req);
3219
- if (req.url === "/primary/purchase/create") {
3220
- expect(body.paymentMethod).toBe("mock");
3221
- events.push({ seller: "primary-seller", url: req.url });
3222
- if (primaryPurchaseSucceeds) {
3223
- res.end(JSON.stringify({
3224
- purchaseId: "pur_primary_123",
3225
- status: "pending",
3226
- creditMicros: 2000000,
3227
- currency: "USD",
3228
- expiresAt: new Date(Date.now() + 86400 * 1000).toISOString()
3229
- }));
3230
- return;
3231
- }
3232
- res.statusCode = 503;
3233
- res.end(JSON.stringify({ error: { code: "seller_unavailable" } }));
3234
- return;
3235
- }
3236
-
3237
- if (req.url === "/primary/purchase/complete") {
3238
- events.push({ seller: "primary-seller", url: req.url });
3239
- res.end(JSON.stringify({
3240
- purchaseId: "pur_primary_123",
3241
- status: "active",
3242
- accessToken: "tok_primary_token_abc",
3243
- tokenClass: "model:gpt-manual",
3244
- creditMicros: 2000000,
3245
- currency: "USD"
3246
- }));
3247
- return;
3248
- }
3249
-
3250
- if (req.url === "/primary/v1/chat/completions" || req.url === "/primary/v1/responses") {
3251
- events.push({
3252
- seller: "primary-seller",
3253
- url: req.url,
3254
- body,
3255
- idempotencyKey: req.headers["idempotency-key"] as string | undefined
3256
- });
3257
- if (primaryInferenceDelayMs > 0) {
3258
- await new Promise((resolve) => setTimeout(resolve, primaryInferenceDelayMs));
3259
- }
3260
- if (primaryInferenceBusy) {
3261
- res.statusCode = 429;
3262
- res.end(JSON.stringify({ error: { code: "busy_capacity", message: "primary seller capacity is full" } }));
3263
- return;
3264
- }
3265
- if (primaryInferenceFails) {
3266
- res.statusCode = 500;
3267
- res.end(JSON.stringify({ error: { code: "upstream_failed", message: "primary seller failed" } }));
3268
- return;
3269
- }
3270
- if (primaryInferenceFailsOnceWithIdempotencyConflict) {
3271
- if (primaryInferenceSeenRequestIds.has(body.requestId)) {
3272
- res.statusCode = 409;
3273
- res.end(JSON.stringify({
3274
- error: {
3275
- code: "idempotency_conflict",
3276
- message: "Idempotency key already belongs to an existing request."
3277
- }
3278
- }));
3279
- return;
3280
- }
3281
- primaryInferenceSeenRequestIds.add(body.requestId);
3282
- if (primaryInferenceSeenRequestIds.size === 1) {
3283
- res.statusCode = 502;
3284
- res.end(JSON.stringify({ error: { code: "upstream_failed", message: "primary seller failed once" } }));
3285
- return;
3286
- }
3287
- }
3288
- if (req.url === "/primary/v1/responses") {
3289
- res.end(JSON.stringify({
3290
- id: "primary-response",
3291
- usage: { input_tokens: 4, output_tokens: 5 }
3292
- }));
3293
- } else {
3294
- res.end(JSON.stringify({
3295
- id: "primary-chat",
3296
- usage: { prompt_tokens: 4, completion_tokens: 5 }
3297
- }));
3298
- }
3299
- return;
3300
- }
3301
-
3302
- if (req.url === "/backup/purchase/create") {
3303
- expect(body.paymentMethod).toBe("mock");
3304
- events.push({ seller: "backup-seller", url: req.url });
3305
- res.end(JSON.stringify({
3306
- purchaseId: "pur_backup_123",
3307
- status: "pending",
3308
- creditMicros: 2000000,
3309
- currency: "USD",
3310
- expiresAt: new Date(Date.now() + 86400 * 1000).toISOString()
3311
- }));
3312
- return;
3313
- }
3314
-
3315
- if (req.url === "/backup/purchase/complete") {
3316
- events.push({ seller: "backup-seller", url: req.url });
3317
- res.end(JSON.stringify({
3318
- purchaseId: "pur_backup_123",
3319
- status: "active",
3320
- accessToken: "tok_backup_token_abc",
3321
- tokenClass: "model:gpt-manual",
3322
- creditMicros: 2000000,
3323
- currency: "USD"
3324
- }));
3325
- return;
3326
- }
3327
-
3328
- if (req.url === "/backup/v1/chat/completions" || req.url === "/backup/v1/responses") {
3329
- events.push({ seller: "backup-seller", url: req.url });
3330
- if (req.url === "/backup/v1/responses") {
3331
- res.end(JSON.stringify({
3332
- id: "backup-response",
3333
- usage: { input_tokens: 4, output_tokens: 5 }
3334
- }));
3335
- } else {
3336
- res.end(JSON.stringify({
3337
- id: "backup-chat",
3338
- usage: { prompt_tokens: 4, completion_tokens: 5 }
3339
- }));
3340
- }
3341
- return;
3342
- }
3343
-
3344
- if (req.url?.startsWith("/backup/")) {
3345
- events.push({ seller: "backup-seller", url: req.url });
3346
- res.end(JSON.stringify({ id: "backup-should-not-run" }));
3347
- return;
3348
- }
3349
-
3350
- res.statusCode = 404;
3351
- res.end(JSON.stringify({ error: "not_found" }));
3352
- });
3353
-
3354
- server.listen(0, "127.0.0.1", () => {
3355
- sellerPort = (server.address() as AddressInfo).port;
3356
- done();
3357
- });
3358
- });
3359
-
3360
- afterAll((done) => {
3361
- server.close(done);
3362
- });
3363
-
3364
- beforeEach(() => {
3365
- events.length = 0;
3366
- primaryPurchaseSucceeds = false;
3367
- primaryInferenceFails = false;
3368
- primaryInferenceFailsOnceWithIdempotencyConflict = false;
3369
- primaryInferenceSeenRequestIds.clear();
3370
- primaryInferenceBusy = false;
3371
- primaryInferenceDelayMs = 0;
3372
- rmSqliteFiles(dbPath);
3373
- const store = new BuyerStore({ dbPath });
3374
- store.savePayment({
3375
- method: "mock",
3376
- enabled: true,
3377
- isDefault: true,
3378
- config: { channel: "manual-routing-test" }
3379
- });
3380
- store.saveDaemonRuntimeConfig(PROVIDER_MODE_CONFIG_KEY, {
3381
- mode: "auto",
3382
- updatedAt: new Date().toISOString()
3383
- });
3384
- store.close();
3385
-
3386
- daemon = new TokenbuddyDaemon({
3387
- controlPort: 0,
3388
- proxyPort: 0,
3389
- dbPath,
3390
- sellerRegistryUrl: `http://127.0.0.1:${sellerPort}/registry/sellers`,
3391
- sellerRouting: {
3392
- mode: "fixed",
3393
- sellerId: "primary-seller",
3394
- scorer: "balanced"
3395
- }
3396
- });
3397
- daemon.start();
3398
- daemonControlPort = ((daemon as any).controlServer.address() as AddressInfo).port;
3399
- daemonProxyPort = ((daemon as any).proxyServer.address() as AddressInfo).port;
3400
- });
3401
-
3402
- afterEach(() => {
3403
- daemon.stop();
3404
- rmSqliteFiles(dbPath);
3405
- });
3406
-
3407
- test("fixed routing uses only the configured seller and does not fail over to backup", async () => {
3408
- const status = await (await fetch(`http://127.0.0.1:${daemonControlPort}/status`)).json() as any;
3409
- expect(status.selectionMode).toBe("manual");
3410
- expect(status.sellerRoutingMode).toBe("fixed");
3411
- expect(status.selectedSellerId).toBe("primary-seller");
3412
-
3413
- const response = await fetch(`http://127.0.0.1:${daemonProxyPort}/v1/chat/completions`, {
3414
- method: "POST",
3415
- headers: { "Content-Type": "application/json" },
3416
- body: JSON.stringify({
3417
- model: "gpt-manual",
3418
- messages: [{ role: "user", content: "manual mode should not fail over" }]
3419
- })
3420
- });
3421
-
3422
- expect(response.status).toBe(502);
3423
- const output = await response.json() as any;
3424
- expect(output.error.message).toContain("purchase/create failed");
3425
- // v1.2: the buyer no longer fetches the seller manifest per request.
3426
- // The registry's `models` field is the source of truth. Auto-purchase
3427
- // is still attempted once before failing over.
3428
- expect(routeEvents()).toEqual([
3429
- { seller: "primary-seller", url: "/primary/purchase/create" }
3430
- ]);
3431
-
3432
- const purchases = await (await fetch(`http://127.0.0.1:${daemonControlPort}/ledger/purchases`)).json() as any;
3433
- const inferences = await (await fetch(`http://127.0.0.1:${daemonControlPort}/ledger/inferences`)).json() as any;
3434
- expect(purchases.purchases.some((entry: any) => entry.sellerKey === "backup-seller")).toBe(false);
3435
- expect(inferences.inferences.some((entry: any) => entry.sellerKey === "backup-seller")).toBe(false);
3436
- });
3437
-
3438
- test("fixed routing pins to the configured seller id", async () => {
3439
- daemon.stop();
3440
- events.length = 0;
3441
- daemon = new TokenbuddyDaemon({
3442
- controlPort: 0,
3443
- proxyPort: 0,
3444
- dbPath,
3445
- sellerRegistryUrl: `http://127.0.0.1:${sellerPort}/registry/sellers`,
3446
- sellerRouting: {
3447
- mode: "fixed",
3448
- sellerId: "backup-seller",
3449
- scorer: "balanced"
3450
- }
3451
- });
3452
- daemon.start();
3453
- daemonControlPort = ((daemon as any).controlServer.address() as AddressInfo).port;
3454
- daemonProxyPort = ((daemon as any).proxyServer.address() as AddressInfo).port;
3455
-
3456
- const status = await (await fetch(`http://127.0.0.1:${daemonControlPort}/status`)).json() as any;
3457
- expect(status.selectionMode).toBe("manual");
3458
- expect(status.sellerRoutingMode).toBe("fixed");
3459
- expect(status.selectedSellerId).toBe("backup-seller");
3460
-
3461
- const response = await fetch(`http://127.0.0.1:${daemonProxyPort}/v1/chat/completions`, {
3462
- method: "POST",
3463
- headers: { "Content-Type": "application/json" },
3464
- body: JSON.stringify({
3465
- model: "gpt-manual",
3466
- messages: [{ role: "user", content: "manual selected seller should stay pinned" }]
3467
- })
3468
- });
3469
-
3470
- expect(response.ok).toBe(true);
3471
- // v1.2: the buyer no longer fetches the seller manifest per request.
3472
- // The backup-seller is selected via the fixed seller routing config; the manifest
3473
- // is sourced from the registry's `models` field.
3474
- expect(routeEvents()).toEqual([
3475
- { seller: "backup-seller", url: "/backup/purchase/create" },
3476
- { seller: "backup-seller", url: "/backup/purchase/complete" },
3477
- { seller: "backup-seller", url: "/backup/v1/chat/completions" }
3478
- ]);
3479
- });
3480
-
3481
- test("daemon loads fixed routing from buyer runtime config", async () => {
3482
- daemon.stop();
3483
- events.length = 0;
3484
- const store = new BuyerStore({ dbPath });
3485
- store.saveDaemonRuntimeConfig("routing", {
3486
- mode: "fixed",
3487
- sellerId: "backup-seller",
3488
- scorer: "balanced"
3489
- });
3490
- store.close();
3491
-
3492
- daemon = new TokenbuddyDaemon({
3493
- controlPort: 0,
3494
- proxyPort: 0,
3495
- dbPath,
3496
- sellerRegistryUrl: `http://127.0.0.1:${sellerPort}/registry/sellers`
3497
- });
3498
- daemon.start();
3499
- daemonControlPort = ((daemon as any).controlServer.address() as AddressInfo).port;
3500
- daemonProxyPort = ((daemon as any).proxyServer.address() as AddressInfo).port;
3501
-
3502
- const status = await (await fetch(`http://127.0.0.1:${daemonControlPort}/status`)).json() as any;
3503
- expect(status.sellerRoutingMode).toBe("fixed");
3504
- expect(status.selectedSellerId).toBe("backup-seller");
3505
-
3506
- const response = await fetch(`http://127.0.0.1:${daemonProxyPort}/v1/chat/completions`, {
3507
- method: "POST",
3508
- headers: { "Content-Type": "application/json" },
3509
- body: JSON.stringify({
3510
- model: "gpt-manual",
3511
- messages: [{ role: "user", content: "buyer store routing should be active" }]
3512
- })
3513
- });
3514
-
3515
- expect(response.ok).toBe(true);
3516
- expect(routeEvents()).toEqual([
3517
- { seller: "backup-seller", url: "/backup/purchase/create" },
3518
- { seller: "backup-seller", url: "/backup/purchase/complete" },
3519
- { seller: "backup-seller", url: "/backup/v1/chat/completions" }
3520
- ]);
3521
- });
3522
-
3523
- test("daemon applies tb routing set fullAuto without restart", async () => {
3524
- daemon.stop();
3525
- events.length = 0;
3526
- const store = new BuyerStore({ dbPath });
3527
- store.saveDaemonRuntimeConfig("routing", {
3528
- mode: "fixed",
3529
- sellerId: "primary-seller",
3530
- scorer: "discount"
3531
- });
3532
- store.close();
3533
-
3534
- daemon = new TokenbuddyDaemon({
3535
- controlPort: 0,
3536
- proxyPort: 0,
3537
- dbPath,
3538
- sellerRegistryUrl: `http://127.0.0.1:${sellerPort}/registry/sellers`
3539
- });
3540
- daemon.start();
3541
- daemonControlPort = ((daemon as any).controlServer.address() as AddressInfo).port;
3542
- daemonProxyPort = ((daemon as any).proxyServer.address() as AddressInfo).port;
3543
-
3544
- const initialStatus = await (await fetch(`http://127.0.0.1:${daemonControlPort}/status`)).json() as any;
3545
- expect(initialStatus.sellerRoutingMode).toBe("fixed");
3546
- expect(initialStatus.sellerRoutingScorer).toBe("discount");
3547
- expect(initialStatus.selectedSellerId).toBe("primary-seller");
3548
-
3549
- const refreshedStore = new BuyerStore({ dbPath });
3550
- refreshedStore.saveDaemonRuntimeConfig("routing", {
3551
- mode: "fullAuto",
3552
- scorer: "balanced"
3553
- });
3554
- refreshedStore.close();
3555
-
3556
- const reloadedStatus = await (await fetch(`http://127.0.0.1:${daemonControlPort}/status`)).json() as any;
3557
- expect(reloadedStatus.sellerRoutingMode).toBe("fullAuto");
3558
- expect(reloadedStatus.sellerRoutingScorer).toBe("balanced");
3559
- expect(reloadedStatus.selectedSellerId).toBeUndefined();
3560
- const prewarmBeforeRequest = await (await fetch(`http://127.0.0.1:${daemonControlPort}/v1.2/prewarm`)).json() as any;
3561
- const scheduledBeforeRequest = prewarmBeforeRequest.scheduler.totalScheduled;
3562
-
3563
- const response = await fetch(`http://127.0.0.1:${daemonProxyPort}/v1/chat/completions`, {
3564
- method: "POST",
3565
- headers: { "Content-Type": "application/json" },
3566
- body: JSON.stringify({
3567
- model: "gpt-manual",
3568
- messages: [{ role: "user", content: "fullAuto should reload without restart" }]
3569
- })
3570
- });
3571
-
3572
- expect(response.ok).toBe(true);
3573
- expect((await response.json() as any).id).toBe("backup-chat");
3574
- expect(routeEvents()).toEqual([
3575
- { seller: "backup-seller", url: "/backup/purchase/create" },
3576
- { seller: "backup-seller", url: "/backup/purchase/complete" },
3577
- { seller: "backup-seller", url: "/backup/v1/chat/completions" }
3578
- ]);
3579
- const prewarmAfterRequest = await (await fetch(`http://127.0.0.1:${daemonControlPort}/v1.2/prewarm`)).json() as any;
3580
- expect(prewarmAfterRequest.scheduler.totalScheduled).toBeGreaterThan(scheduledBeforeRequest);
3581
- });
3582
-
3583
- test("routing preview uses seller manifest discount metadata", async () => {
3584
- const response = await fetch(
3585
- `http://127.0.0.1:${daemonControlPort}/routing/preview?modelId=gpt-manual&protocol=chat_completions&paymentMethod=mock&mode=fullAuto&scorer=discount`
3586
- );
3587
-
3588
- expect(response.ok).toBe(true);
3589
- const preview = await response.json() as any;
3590
- expect(preview.plan.reason).toBe("fullAuto:discount:routes_2");
3591
- expect(preview.plan.routes.map((route: any) => route.seller.id)).toEqual(["backup-seller", "primary-seller"]);
3592
- expect(preview.plan.routes[0].metrics.discountRatio).toBe(0.01);
3593
- expect(preview.plan.routes[1].metrics.discountRatio).toBe(1);
3594
- expect(events).toEqual(expect.arrayContaining([
3595
- { seller: "primary-seller", url: "/primary/manifest" },
3596
- { seller: "backup-seller", url: "/backup/manifest" }
3597
- ]));
3598
- });
3599
-
3600
- test("fixedSet routing only uses sellers in the configured pool", async () => {
3601
- daemon.stop();
3602
- events.length = 0;
3603
- daemon = new TokenbuddyDaemon({
3604
- controlPort: 0,
3605
- proxyPort: 0,
3606
- dbPath,
3607
- sellerRegistryUrl: `http://127.0.0.1:${sellerPort}/registry/sellers`,
3608
- sellerRouting: {
3609
- mode: "fixedSet",
3610
- sellerIds: ["backup-seller"],
3611
- scorer: "balanced"
3612
- }
3613
- });
3614
- daemon.start();
3615
- daemonControlPort = ((daemon as any).controlServer.address() as AddressInfo).port;
3616
- daemonProxyPort = ((daemon as any).proxyServer.address() as AddressInfo).port;
3617
-
3618
- const status = await (await fetch(`http://127.0.0.1:${daemonControlPort}/status`)).json() as any;
3619
- expect(status.selectionMode).toBe("manual");
3620
- expect(status.sellerRoutingMode).toBe("fixedSet");
3621
- expect(status.selectedSellerId).toBeUndefined();
3622
-
3623
- const response = await fetch(`http://127.0.0.1:${daemonProxyPort}/v1/chat/completions`, {
3624
- method: "POST",
3625
- headers: { "Content-Type": "application/json" },
3626
- body: JSON.stringify({
3627
- model: "gpt-manual",
3628
- messages: [{ role: "user", content: "fixedSet should stay inside the configured pool" }]
3629
- })
3630
- });
3631
-
3632
- expect(response.ok).toBe(true);
3633
- expect(routeEvents()).toEqual([
3634
- { seller: "backup-seller", url: "/backup/purchase/create" },
3635
- { seller: "backup-seller", url: "/backup/purchase/complete" },
3636
- { seller: "backup-seller", url: "/backup/v1/chat/completions" }
3637
- ]);
3638
- });
3639
-
3640
- test("fullAuto routing fails over from a 500 primary seller to the backup seller", async () => {
3641
- daemon.stop();
3642
- events.length = 0;
3643
- primaryPurchaseSucceeds = true;
3644
- primaryInferenceFails = true;
3645
- const requestId = "auto_failover_500_log_detail";
3646
- const rawPrompt = "raw prompt must stay out of tb-proxyd logs: tb-log-secret-500";
3647
- daemon = new TokenbuddyDaemon({
3648
- controlPort: 0,
3649
- proxyPort: 0,
3650
- dbPath,
3651
- sellerRegistryUrl: `http://127.0.0.1:${sellerPort}/registry/sellers`,
3652
- sellerRouting: {
3653
- mode: "fullAuto",
3654
- scorer: "speed"
3655
- }
3656
- });
3657
- daemon.start();
3658
- daemonControlPort = ((daemon as any).controlServer.address() as AddressInfo).port;
3659
- daemonProxyPort = ((daemon as any).proxyServer.address() as AddressInfo).port;
3660
-
3661
- const status = await (await fetch(`http://127.0.0.1:${daemonControlPort}/status`)).json() as any;
3662
- expect(status.selectionMode).toBe("auto");
3663
- expect(status.sellerRoutingMode).toBe("fullAuto");
3664
- expect(status.selectedSellerId).toBeUndefined();
3665
-
3666
- const response = await fetch(`http://127.0.0.1:${daemonProxyPort}/v1/chat/completions`, {
3667
- method: "POST",
3668
- headers: { "Content-Type": "application/json" },
3669
- body: JSON.stringify({
3670
- model: "gpt-manual",
3671
- messages: [{ role: "user", content: rawPrompt }],
3672
- requestId
3673
- })
3674
- });
3675
-
3676
- expect(response.ok).toBe(true);
3677
- expect((await response.json() as any).id).toBe("backup-chat");
3678
- expect(routeEvents()).toEqual([
3679
- { seller: "primary-seller", url: "/primary/purchase/create" },
3680
- { seller: "primary-seller", url: "/primary/purchase/complete" },
3681
- { seller: "primary-seller", url: "/primary/v1/chat/completions" },
3682
- { seller: "primary-seller", url: "/primary/v1/chat/completions" },
3683
- { seller: "primary-seller", url: "/primary/v1/chat/completions" },
3684
- { seller: "backup-seller", url: "/backup/purchase/create" },
3685
- { seller: "backup-seller", url: "/backup/purchase/complete" },
3686
- { seller: "backup-seller", url: "/backup/v1/chat/completions" }
3687
- ]);
3688
-
3689
- await new Promise((resolve) => setTimeout(resolve, 100));
3690
- const logFile = resolveModuleLogFile("tb-proxyd");
3691
- const logs = fs.existsSync(logFile) ? fs.readFileSync(logFile, "utf8") : "";
3692
- const requestLogs = logs
3693
- .split("\n")
3694
- .filter((line) => line.includes(`requestId=${requestId}`))
3695
- .join("\n");
3696
- expect(requestLogs).toContain("event=route.failover.retry_scheduled");
3697
- expect(requestLogs).toContain("event=route.failover.triggered");
3698
- expect(requestLogs).toContain("event=route.candidates.prewarmed");
3699
- expect(requestLogs).toContain("event=route.selected");
3700
- expect(requestLogs).toContain("routePlanSource=registry_fallback");
3701
- expect(requestLogs).toContain("routePlanReason=fullAuto:speed:routes_2");
3702
- expect(requestLogs).toContain("candidateDiagnostics=");
3703
- expect(requestLogs).toContain("hasNextRoute=true");
3704
- expect(requestLogs).toContain("attemptNumber=");
3705
- expect(requestLogs).toContain("event=purchase.create.started");
3706
- expect(requestLogs).toContain("event=purchase.ledger.recorded");
3707
- expect(requestLogs).toContain("event=inference.ledger.recorded");
3708
- expect(requestLogs).toContain("bodySummary=");
3709
- expect(requestLogs).not.toContain("upstreamBody=");
3710
- expect(logs).not.toContain(rawPrompt);
3711
- });
3712
-
3713
- test("soft failure retry uses a fresh seller attempt id after upstream failure", async () => {
3714
- daemon.stop();
3715
- events.length = 0;
3716
- primaryPurchaseSucceeds = true;
3717
- primaryInferenceFailsOnceWithIdempotencyConflict = true;
3718
- const requestId = "auto_retry_fresh_seller_attempt_id";
3719
- daemon = new TokenbuddyDaemon({
3720
- controlPort: 0,
3721
- proxyPort: 0,
3722
- dbPath,
3723
- sellerRegistryUrl: `http://127.0.0.1:${sellerPort}/registry/sellers`,
3724
- sellerRouting: {
3725
- mode: "fixed",
3726
- sellerId: "primary-seller",
3727
- scorer: "balanced"
3728
- }
3729
- });
3730
- daemon.start();
3731
- daemonControlPort = ((daemon as any).controlServer.address() as AddressInfo).port;
3732
- daemonProxyPort = ((daemon as any).proxyServer.address() as AddressInfo).port;
3733
-
3734
- const response = await fetch(`http://127.0.0.1:${daemonProxyPort}/v1/chat/completions`, {
3735
- method: "POST",
3736
- headers: {
3737
- "Content-Type": "application/json",
3738
- "Idempotency-Key": "idem-fresh-seller-attempt"
3739
- },
3740
- body: JSON.stringify({
3741
- model: "gpt-manual",
3742
- messages: [{ role: "user", content: "retry should not reuse seller request id" }],
3743
- requestId
3744
- })
3745
- });
3746
-
3747
- expect(response.ok).toBe(true);
3748
- expect((await response.json() as any).id).toBe("primary-chat");
3749
- const primaryInferenceCalls = events.filter((event) => event.url === "/primary/v1/chat/completions");
3750
- expect(primaryInferenceCalls).toHaveLength(2);
3751
- expect(primaryInferenceCalls.map((event) => event.body?.requestId)).toEqual([
3752
- requestId,
3753
- `${requestId}_r0_a1_n0`
3754
- ]);
3755
- expect(primaryInferenceCalls.map((event) => event.idempotencyKey)).toEqual([
3756
- "idem-fresh-seller-attempt",
3757
- "idem-fresh-seller-attempt_r0_a1_n0"
3758
- ]);
3759
-
3760
- const inferences = await (await fetch(`http://127.0.0.1:${daemonControlPort}/ledger/inferences`)).json() as any;
3761
- expect(inferences.inferences).toEqual(expect.arrayContaining([
3762
- expect.objectContaining({
3763
- requestId,
3764
- sellerKey: "primary-seller",
3765
- endpoint: "/v1/chat/completions",
3766
- status: "estimated"
3767
- })
3768
- ]));
3769
- expect(JSON.stringify(inferences)).not.toContain(`${requestId}_r0_a1_n0`);
3770
- });
3771
-
3772
- test("responses retry uses a fresh seller attempt id after upstream failure", async () => {
3773
- daemon.stop();
3774
- events.length = 0;
3775
- primaryPurchaseSucceeds = true;
3776
- primaryInferenceFailsOnceWithIdempotencyConflict = true;
3777
- const requestId = "responses_retry_fresh_seller_attempt_id";
3778
- daemon = new TokenbuddyDaemon({
3779
- controlPort: 0,
3780
- proxyPort: 0,
3781
- dbPath,
3782
- sellerRegistryUrl: `http://127.0.0.1:${sellerPort}/registry/sellers`,
3783
- sellerRouting: {
3784
- mode: "fixed",
3785
- sellerId: "primary-seller",
3786
- scorer: "balanced"
3787
- }
3788
- });
3789
- daemon.start();
3790
- daemonControlPort = ((daemon as any).controlServer.address() as AddressInfo).port;
3791
- daemonProxyPort = ((daemon as any).proxyServer.address() as AddressInfo).port;
3792
-
3793
- const response = await fetch(`http://127.0.0.1:${daemonProxyPort}/v1/responses`, {
3794
- method: "POST",
3795
- headers: {
3796
- "Content-Type": "application/json",
3797
- "Idempotency-Key": "idem-responses-fresh-seller-attempt"
3798
- },
3799
- body: JSON.stringify({
3800
- model: "gpt-manual",
3801
- input: "retry should not reuse seller request id",
3802
- requestId
3803
- })
3804
- });
3805
-
3806
- expect(response.ok).toBe(true);
3807
- expect((await response.json() as any).id).toBe("primary-response");
3808
- const primaryInferenceCalls = events.filter((event) => event.url === "/primary/v1/responses");
3809
- expect(primaryInferenceCalls).toHaveLength(2);
3810
- expect(primaryInferenceCalls.map((event) => event.body?.requestId)).toEqual([
3811
- requestId,
3812
- `${requestId}_r0_a1_n0`
3813
- ]);
3814
- expect(primaryInferenceCalls.map((event) => event.idempotencyKey)).toEqual([
3815
- "idem-responses-fresh-seller-attempt",
3816
- "idem-responses-fresh-seller-attempt_r0_a1_n0"
3817
- ]);
3818
-
3819
- const inferences = await (await fetch(`http://127.0.0.1:${daemonControlPort}/ledger/inferences`)).json() as any;
3820
- expect(inferences.inferences).toEqual(expect.arrayContaining([
3821
- expect.objectContaining({
3822
- requestId,
3823
- sellerKey: "primary-seller",
3824
- endpoint: "/v1/responses",
3825
- status: "estimated"
3826
- })
3827
- ]));
3828
- expect(JSON.stringify(inferences)).not.toContain(`${requestId}_r0_a1_n0`);
3829
- });
3830
-
3831
- test("fullAuto routing treats busy_capacity as a capacity block and starts the next request on backup", async () => {
3832
- daemon.stop();
3833
- events.length = 0;
3834
- primaryPurchaseSucceeds = true;
3835
- primaryInferenceBusy = true;
3836
- daemon = new TokenbuddyDaemon({
3837
- controlPort: 0,
3838
- proxyPort: 0,
3839
- dbPath,
3840
- sellerRegistryUrl: `http://127.0.0.1:${sellerPort}/registry/sellers`,
3841
- sellerRouting: {
3842
- mode: "fullAuto",
3843
- scorer: "speed"
3844
- }
3845
- });
3846
- daemon.start();
3847
- daemonControlPort = ((daemon as any).controlServer.address() as AddressInfo).port;
3848
- daemonProxyPort = ((daemon as any).proxyServer.address() as AddressInfo).port;
3849
-
3850
- const first = await fetch(`http://127.0.0.1:${daemonProxyPort}/v1/chat/completions`, {
3851
- method: "POST",
3852
- headers: { "Content-Type": "application/json" },
3853
- body: JSON.stringify({
3854
- model: "gpt-manual",
3855
- messages: [{ role: "user", content: "primary is at capacity" }]
3856
- })
3857
- });
3858
-
3859
- expect(first.ok).toBe(true);
3860
- expect((await first.json() as any).id).toBe("backup-chat");
3861
- expect(routeEvents()).toEqual([
3862
- { seller: "primary-seller", url: "/primary/purchase/create" },
3863
- { seller: "primary-seller", url: "/primary/purchase/complete" },
3864
- { seller: "primary-seller", url: "/primary/v1/chat/completions" },
3865
- { seller: "backup-seller", url: "/backup/purchase/create" },
3866
- { seller: "backup-seller", url: "/backup/purchase/complete" },
3867
- { seller: "backup-seller", url: "/backup/v1/chat/completions" }
3868
- ]);
3869
-
3870
- events.length = 0;
3871
- const second = await fetch(`http://127.0.0.1:${daemonProxyPort}/v1/chat/completions`, {
3872
- method: "POST",
3873
- headers: { "Content-Type": "application/json" },
3874
- body: JSON.stringify({
3875
- model: "gpt-manual",
3876
- messages: [{ role: "user", content: "capacity block should still be active" }]
3877
- })
3878
- });
3879
-
3880
- expect(second.ok).toBe(true);
3881
- expect((await second.json() as any).id).toBe("backup-chat");
3882
- expect(routeEvents()).toEqual([
3883
- { seller: "backup-seller", url: "/backup/v1/chat/completions" }
3884
- ]);
3885
- });
3886
-
3887
- test("fullAuto routing skips locally saturated sellers for concurrent requests", async () => {
3888
- daemon.stop();
3889
- events.length = 0;
3890
- primaryPurchaseSucceeds = true;
3891
- primaryInferenceDelayMs = 250;
3892
- daemon = new TokenbuddyDaemon({
3893
- controlPort: 0,
3894
- proxyPort: 0,
3895
- dbPath,
3896
- sellerRegistryUrl: `http://127.0.0.1:${sellerPort}/registry/sellers`,
3897
- sellerRouting: {
3898
- mode: "fullAuto",
3899
- scorer: "speed"
3900
- },
3901
- sellerConcurrency: {
3902
- enabled: true,
3903
- maxInFlightPerSeller: 1,
3904
- leaseTtlMs: 5000
3905
- }
3906
- });
3907
- daemon.start();
3908
- daemonControlPort = ((daemon as any).controlServer.address() as AddressInfo).port;
3909
- daemonProxyPort = ((daemon as any).proxyServer.address() as AddressInfo).port;
3910
- (daemon as any).prewarmCache.commitWarm({
3911
- modelId: "gpt-manual",
3912
- protocol: "chat_completions",
3913
- paymentMethod: "mock",
3914
- ttlMs: 600000,
3915
- candidates: [
3916
- {
3917
- sellerId: "primary-seller",
3918
- url: `http://127.0.0.1:${sellerPort}/primary`,
3919
- healthScore: 100,
3920
- avgLatencyMs: 10
3921
- },
3922
- {
3923
- sellerId: "backup-seller",
3924
- url: `http://127.0.0.1:${sellerPort}/backup`,
3925
- healthScore: 90,
3926
- avgLatencyMs: 20
3927
- }
3928
- ]
3929
- });
3930
- (daemon as any).sellerPool.sync();
3931
-
3932
- const first = fetch(`http://127.0.0.1:${daemonProxyPort}/v1/chat/completions`, {
3933
- method: "POST",
3934
- headers: { "Content-Type": "application/json" },
3935
- body: JSON.stringify({
3936
- model: "gpt-manual",
3937
- messages: [{ role: "user", content: "hold primary local capacity" }],
3938
- requestId: "local_capacity_primary"
3939
- })
3940
- });
3941
-
3942
- for (let i = 0; i < 30; i += 1) {
3943
- if (events.some((event) => event.url === "/primary/v1/chat/completions")) {
3944
- break;
3945
- }
3946
- await new Promise((resolve) => setTimeout(resolve, 10));
3947
- }
3948
- expect(events.some((event) => event.url === "/primary/v1/chat/completions")).toBe(true);
3949
-
3950
- const second = await fetch(`http://127.0.0.1:${daemonProxyPort}/v1/chat/completions`, {
3951
- method: "POST",
3952
- headers: { "Content-Type": "application/json" },
3953
- body: JSON.stringify({
3954
- model: "gpt-manual",
3955
- messages: [{ role: "user", content: "use backup while primary is locally full" }],
3956
- requestId: "local_capacity_backup"
3957
- })
3958
- });
3959
-
3960
- expect(second.ok).toBe(true);
3961
- expect((await second.json() as any).id).toBe("backup-chat");
3962
-
3963
- const firstResponse = await first;
3964
- expect(firstResponse.ok).toBe(true);
3965
- expect((await firstResponse.json() as any).id).toBe("primary-chat");
3966
-
3967
- const primaryInferenceCalls = routeEvents().filter((event) => event.url === "/primary/v1/chat/completions");
3968
- const backupInferenceCalls = routeEvents().filter((event) => event.url === "/backup/v1/chat/completions");
3969
- expect(primaryInferenceCalls).toHaveLength(1);
3970
- expect(backupInferenceCalls).toHaveLength(1);
3971
-
3972
- const status = await (await fetch(`http://127.0.0.1:${daemonControlPort}/status`)).json() as any;
3973
- expect(status.sellerConcurrency.enabled).toBe(true);
3974
- expect(status.sellerConcurrency.active).toEqual([]);
3975
-
3976
- await new Promise((resolve) => setTimeout(resolve, 100));
3977
- const logFile = resolveModuleLogFile("tb-proxyd");
3978
- const logs = fs.existsSync(logFile) ? fs.readFileSync(logFile, "utf8") : "";
3979
- const backupRequestLogs = logs
3980
- .split("\n")
3981
- .filter((line) => line.includes("requestId=local_capacity_backup"))
3982
- .join("\n");
3983
- expect(backupRequestLogs).toContain("event=route.candidates.prewarmed");
3984
- expect(backupRequestLogs).toContain("event=route.selected");
3985
- expect(backupRequestLogs).toContain("primary-seller");
3986
- expect(backupRequestLogs).toContain("sellerKey=backup-seller");
3987
- expect(backupRequestLogs).toContain("\"blockedLocalConcurrencyCount\":1");
3988
- expect(backupRequestLogs).toContain("\"prewarmBlockedSellerIds\":[\"primary-seller\"]");
3989
- expect(backupRequestLogs).toContain("routePlanSellerCount=1");
3990
- expect(backupRequestLogs).toContain("localConcurrencyEnabled=true");
3991
- });
3992
-
3993
- test("fullAuto routing logs purchase failure failover before trying the backup seller", async () => {
3994
- daemon.stop();
3995
- events.length = 0;
3996
- primaryPurchaseSucceeds = false;
3997
- daemon = new TokenbuddyDaemon({
3998
- controlPort: 0,
3999
- proxyPort: 0,
4000
- dbPath,
4001
- sellerRegistryUrl: `http://127.0.0.1:${sellerPort}/registry/sellers`,
4002
- sellerRouting: {
4003
- mode: "fullAuto",
4004
- scorer: "speed"
4005
- }
4006
- });
4007
- daemon.start();
4008
- daemonControlPort = ((daemon as any).controlServer.address() as AddressInfo).port;
4009
- daemonProxyPort = ((daemon as any).proxyServer.address() as AddressInfo).port;
4010
-
4011
- const requestId = "auto_purchase_failover_log_detail";
4012
- const response = await fetch(`http://127.0.0.1:${daemonProxyPort}/v1/chat/completions`, {
4013
- method: "POST",
4014
- headers: { "Content-Type": "application/json" },
4015
- body: JSON.stringify({
4016
- model: "gpt-manual",
4017
- messages: [{ role: "user", content: "purchase failure should fail over" }],
4018
- requestId
4019
- })
4020
- });
4021
-
4022
- expect(response.ok).toBe(true);
4023
- expect((await response.json() as any).id).toBe("backup-chat");
4024
- expect(routeEvents()).toEqual([
4025
- { seller: "primary-seller", url: "/primary/purchase/create" },
4026
- { seller: "backup-seller", url: "/backup/purchase/create" },
4027
- { seller: "backup-seller", url: "/backup/purchase/complete" },
4028
- { seller: "backup-seller", url: "/backup/v1/chat/completions" }
4029
- ]);
4030
-
4031
- await new Promise((resolve) => setTimeout(resolve, 100));
4032
- const logFile = resolveModuleLogFile("tb-proxyd");
4033
- const logs = fs.existsSync(logFile) ? fs.readFileSync(logFile, "utf8") : "";
4034
- const requestLogs = logs
4035
- .split("\n")
4036
- .filter((line) => line.includes(`requestId=${requestId}`))
4037
- .join("\n");
4038
- expect(requestLogs).toContain("event=route.failover.triggered");
4039
- expect(requestLogs).toContain("reason=purchase_failed");
4040
- expect(requestLogs).toContain("controllerAction=failover_next");
4041
- expect(requestLogs).not.toContain("event=route.failover.retry_scheduled");
4042
- });
4043
- });