@tokenbuddy/tokenbuddy 1.0.36 → 1.0.38
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.
- package/dist/src/buyer-store.d.ts +7 -2
- package/dist/src/buyer-store.js +46 -7
- package/dist/src/cli.d.ts +1 -0
- package/dist/src/cli.js +15 -7
- package/dist/src/daemon.d.ts +12 -0
- package/dist/src/daemon.js +791 -61
- package/dist/src/doctor-diagnostics.js +1 -6
- package/dist/src/provider-install.d.ts +2 -2
- package/dist/src/provider-install.js +248 -2
- package/dist/src/seller-catalog.d.ts +21 -0
- package/dist/src/seller-catalog.js +17 -0
- package/dist/src/seller-route-planner.d.ts +4 -1
- package/dist/src/seller-route-planner.js +3 -0
- package/dist/src/seller-routing-strategy.d.ts +3 -0
- package/dist/src/terminal-detect.d.ts +1 -1
- package/dist/src/terminal-detect.js +3 -2
- package/dist/src/workdir.d.ts +10 -0
- package/dist/src/workdir.js +26 -0
- package/package.json +15 -2
- package/static/ui/assets/index-Djfl9tw5.js +271 -0
- package/static/ui/assets/index-DkfztCkn.css +1 -0
- package/static/ui/index.html +2 -2
- package/dist/src/buyer-store.d.ts.map +0 -1
- package/dist/src/buyer-store.js.map +0 -1
- package/dist/src/clawtip-bootstrap.d.ts.map +0 -1
- package/dist/src/clawtip-bootstrap.js.map +0 -1
- package/dist/src/cli.d.ts.map +0 -1
- package/dist/src/cli.js.map +0 -1
- package/dist/src/credit-tracker.d.ts.map +0 -1
- package/dist/src/credit-tracker.js.map +0 -1
- package/dist/src/daemon.d.ts.map +0 -1
- package/dist/src/daemon.js.map +0 -1
- package/dist/src/doctor-clawtip-wallet.d.ts.map +0 -1
- package/dist/src/doctor-clawtip-wallet.js.map +0 -1
- package/dist/src/doctor-diagnostics.d.ts.map +0 -1
- package/dist/src/doctor-diagnostics.js.map +0 -1
- package/dist/src/index.d.ts.map +0 -1
- package/dist/src/index.js.map +0 -1
- package/dist/src/init-clawtip-activation.d.ts.map +0 -1
- package/dist/src/init-clawtip-activation.js.map +0 -1
- package/dist/src/init-payment-options.d.ts.map +0 -1
- package/dist/src/init-payment-options.js.map +0 -1
- package/dist/src/init-setup.d.ts.map +0 -1
- package/dist/src/init-setup.js.map +0 -1
- package/dist/src/model-index.d.ts.map +0 -1
- package/dist/src/model-index.js.map +0 -1
- package/dist/src/package-update.d.ts.map +0 -1
- package/dist/src/package-update.js.map +0 -1
- package/dist/src/prewarm-cache.d.ts.map +0 -1
- package/dist/src/prewarm-cache.js.map +0 -1
- package/dist/src/prewarm-scheduler.d.ts.map +0 -1
- package/dist/src/prewarm-scheduler.js.map +0 -1
- package/dist/src/provider-install.d.ts.map +0 -1
- package/dist/src/provider-install.js.map +0 -1
- package/dist/src/provider-routing-config.d.ts.map +0 -1
- package/dist/src/provider-routing-config.js.map +0 -1
- package/dist/src/registry-trust.d.ts.map +0 -1
- package/dist/src/registry-trust.js.map +0 -1
- package/dist/src/route-failover.d.ts.map +0 -1
- package/dist/src/route-failover.js.map +0 -1
- package/dist/src/seller-catalog.d.ts.map +0 -1
- package/dist/src/seller-catalog.js.map +0 -1
- package/dist/src/seller-concurrency-limiter.d.ts.map +0 -1
- package/dist/src/seller-concurrency-limiter.js.map +0 -1
- package/dist/src/seller-metadata-cache.d.ts.map +0 -1
- package/dist/src/seller-metadata-cache.js.map +0 -1
- package/dist/src/seller-pool.d.ts.map +0 -1
- package/dist/src/seller-pool.js.map +0 -1
- package/dist/src/seller-route-planner.d.ts.map +0 -1
- package/dist/src/seller-route-planner.js.map +0 -1
- package/dist/src/seller-routing-config.d.ts.map +0 -1
- package/dist/src/seller-routing-config.js.map +0 -1
- package/dist/src/seller-routing-strategy.d.ts.map +0 -1
- package/dist/src/seller-routing-strategy.js.map +0 -1
- package/dist/src/stream-failover.d.ts.map +0 -1
- package/dist/src/stream-failover.js.map +0 -1
- package/dist/src/tb-clawtip-proof.d.ts.map +0 -1
- package/dist/src/tb-clawtip-proof.js.map +0 -1
- package/dist/src/tb-proxyd.d.ts.map +0 -1
- package/dist/src/tb-proxyd.js.map +0 -1
- package/dist/src/terminal-detect.d.ts.map +0 -1
- package/dist/src/terminal-detect.js.map +0 -1
- package/dist/src/terminal-image.d.ts.map +0 -1
- package/dist/src/terminal-image.js.map +0 -1
- package/src/buyer-store.ts +0 -1090
- package/src/clawtip-bootstrap.ts +0 -65
- package/src/cli.ts +0 -2243
- package/src/credit-tracker.ts +0 -295
- package/src/daemon.ts +0 -5475
- package/src/doctor-clawtip-wallet.ts +0 -95
- package/src/doctor-diagnostics.ts +0 -1026
- package/src/index.ts +0 -16
- package/src/init-clawtip-activation.ts +0 -695
- package/src/init-payment-options.ts +0 -373
- package/src/init-setup.ts +0 -165
- package/src/model-index.ts +0 -278
- package/src/package-update.ts +0 -311
- package/src/prewarm-cache.ts +0 -485
- package/src/prewarm-scheduler.ts +0 -675
- package/src/provider-install.ts +0 -1006
- package/src/provider-routing-config.ts +0 -410
- package/src/registry-trust.ts +0 -51
- package/src/route-failover.ts +0 -304
- package/src/seller-catalog.ts +0 -505
- package/src/seller-concurrency-limiter.ts +0 -161
- package/src/seller-metadata-cache.ts +0 -91
- package/src/seller-pool.ts +0 -557
- package/src/seller-route-planner.ts +0 -513
- package/src/seller-routing-config.ts +0 -211
- package/src/seller-routing-strategy.ts +0 -362
- package/src/stream-failover.ts +0 -152
- package/src/tb-clawtip-proof.ts +0 -28
- package/src/tb-proxyd.ts +0 -101
- package/src/terminal-detect.ts +0 -333
- package/src/terminal-image.ts +0 -228
- package/static/ui/assets/index-0MVXD7bH.css +0 -1
- package/static/ui/assets/index-BVbeDEwq.js +0 -271
- package/static/ui/assets/index-BVbeDEwq.js.map +0 -1
- package/tests/cli-routing.test.ts +0 -363
- package/tests/control-plane-ui-endpoints.test.ts +0 -1630
- package/tests/credit-tracker.test.ts +0 -165
- package/tests/daemon-413-fallback.test.ts +0 -92
- package/tests/daemon-classify.test.ts +0 -452
- package/tests/daemon-roles.test.ts +0 -92
- package/tests/daemon-trusted-registry-cache.test.ts +0 -132
- package/tests/e2e.test.ts +0 -366
- package/tests/image-generation-e2e.test.ts +0 -230
- package/tests/model-index.test.ts +0 -198
- package/tests/package-update.test.ts +0 -147
- package/tests/prewarm-cache.test.ts +0 -296
- package/tests/prewarm-scheduler.test.ts +0 -367
- package/tests/provider-routing-config.test.ts +0 -150
- package/tests/registry-trust.test.ts +0 -28
- package/tests/route-failover.test.ts +0 -222
- package/tests/seller-catalog-413.test.ts +0 -120
- package/tests/seller-catalog-utilities.test.ts +0 -124
- package/tests/seller-concurrency-limiter.test.ts +0 -83
- package/tests/seller-metadata-cache.test.ts +0 -89
- package/tests/seller-pool.test.ts +0 -365
- package/tests/seller-route-planner.test.ts +0 -312
- package/tests/seller-routing-config.test.ts +0 -124
- package/tests/seller-routing-strategy.test.ts +0 -167
- package/tests/stream-failover.test.ts +0 -52
- package/tests/thousand-seller.test.ts +0 -151
- package/tests/tokenbuddy.test.ts +0 -4043
- package/tsconfig.json +0 -8
package/tests/tokenbuddy.test.ts
DELETED
|
@@ -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
|
-
});
|