@tokenbuddy/tokenbuddy 1.0.4
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/bin/tb-proxyd.js +2 -0
- package/bin/tb.js +3 -0
- package/bin/tokenbuddy-proxyd.js +2 -0
- package/bin/tokenbuddy.js +3 -0
- package/dist/src/buyer-store.d.ts +118 -0
- package/dist/src/buyer-store.d.ts.map +1 -0
- package/dist/src/buyer-store.js +296 -0
- package/dist/src/buyer-store.js.map +1 -0
- package/dist/src/cli.d.ts +3 -0
- package/dist/src/cli.d.ts.map +1 -0
- package/dist/src/cli.js +648 -0
- package/dist/src/cli.js.map +1 -0
- package/dist/src/daemon.d.ts +48 -0
- package/dist/src/daemon.d.ts.map +1 -0
- package/dist/src/daemon.js +998 -0
- package/dist/src/daemon.js.map +1 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +12 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/provider-install.d.ts +44 -0
- package/dist/src/provider-install.d.ts.map +1 -0
- package/dist/src/provider-install.js +286 -0
- package/dist/src/provider-install.js.map +1 -0
- package/dist/src/tb-proxyd.d.ts +2 -0
- package/dist/src/tb-proxyd.d.ts.map +1 -0
- package/dist/src/tb-proxyd.js +54 -0
- package/dist/src/tb-proxyd.js.map +1 -0
- package/dist/src/terminal-detect.d.ts +29 -0
- package/dist/src/terminal-detect.d.ts.map +1 -0
- package/dist/src/terminal-detect.js +209 -0
- package/dist/src/terminal-detect.js.map +1 -0
- package/package.json +29 -0
- package/src/buyer-store.ts +536 -0
- package/src/cli.ts +732 -0
- package/src/daemon.ts +1158 -0
- package/src/index.ts +12 -0
- package/src/provider-install.ts +363 -0
- package/src/tb-proxyd.ts +60 -0
- package/src/terminal-detect.ts +225 -0
- package/tests/e2e.test.ts +264 -0
- package/tests/tokenbuddy.test.ts +1186 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,1186 @@
|
|
|
1
|
+
import { TokenbuddyDaemon } from "../src/daemon.js";
|
|
2
|
+
import { BuyerStore, resolveBuyerStorePath } from "../src/buyer-store.js";
|
|
3
|
+
import { buildCli } from "../src/cli.js";
|
|
4
|
+
import {
|
|
5
|
+
applyProviderInstall,
|
|
6
|
+
detectProviders,
|
|
7
|
+
previewProviderInstall,
|
|
8
|
+
rollbackProviderInstall
|
|
9
|
+
} from "../src/provider-install.js";
|
|
10
|
+
import { detectTerminals } from "../src/terminal-detect.js";
|
|
11
|
+
import * as path from "path";
|
|
12
|
+
import * as fs from "fs";
|
|
13
|
+
import http from "http";
|
|
14
|
+
import { AddressInfo } from "net";
|
|
15
|
+
|
|
16
|
+
const TEMP_BUYER_DB = path.resolve(__dirname, "../../data-test/buyer-cache-test.db");
|
|
17
|
+
const TEMP_STORE_ROOT = path.resolve(__dirname, "../../data-test/buyer-store-test");
|
|
18
|
+
const PACKAGE_JSON = path.resolve(__dirname, "../package.json");
|
|
19
|
+
|
|
20
|
+
function rmSqliteFiles(dbPath: string): void {
|
|
21
|
+
for (const fileName of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) {
|
|
22
|
+
fs.rmSync(fileName, { force: true });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function rmDir(dirPath: string): void {
|
|
27
|
+
fs.rmSync(dirPath, { recursive: true, force: true });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("TokenBuddy CLI command surface", () => {
|
|
31
|
+
test("tb root help only exposes the approved user commands", () => {
|
|
32
|
+
const program = buildCli();
|
|
33
|
+
const commandNames = program.commands
|
|
34
|
+
.map(command => command.name())
|
|
35
|
+
.filter(command => command !== "help")
|
|
36
|
+
.sort();
|
|
37
|
+
|
|
38
|
+
expect(commandNames).toEqual(["doctor", "init", "models", "payment"]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("tb payment help only exposes list, add, and remove", () => {
|
|
42
|
+
const program = buildCli();
|
|
43
|
+
const payment = program.commands.find(command => command.name() === "payment");
|
|
44
|
+
|
|
45
|
+
expect(payment).toBeDefined();
|
|
46
|
+
const help = payment!.helpInformation();
|
|
47
|
+
|
|
48
|
+
for (const command of ["list", "add", "remove"]) {
|
|
49
|
+
expect(help).toContain(command);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
for (const removedCommand of ["enable", "disable", "doctor", "default"]) {
|
|
53
|
+
expect(help).not.toContain(` ${removedCommand}`);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("removed top-level commands are unreachable", () => {
|
|
58
|
+
for (const command of ["proxy", "seller", "admin", "config", "ledger"]) {
|
|
59
|
+
const program = buildCli();
|
|
60
|
+
program.exitOverride();
|
|
61
|
+
program.configureOutput({ writeErr: () => undefined });
|
|
62
|
+
|
|
63
|
+
expect(() => program.parse(["node", "tb", command])).toThrow(/unknown command/i);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("package exposes tb and tb-proxyd bins", () => {
|
|
68
|
+
const packageJson = JSON.parse(fs.readFileSync(PACKAGE_JSON, "utf8"));
|
|
69
|
+
|
|
70
|
+
expect(packageJson.bin).toEqual({
|
|
71
|
+
tb: "./bin/tb.js",
|
|
72
|
+
"tb-proxyd": "./bin/tb-proxyd.js"
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("BuyerStore safe SQLite persistence", () => {
|
|
78
|
+
let store: BuyerStore;
|
|
79
|
+
|
|
80
|
+
beforeEach(() => {
|
|
81
|
+
rmDir(TEMP_STORE_ROOT);
|
|
82
|
+
store = new BuyerStore({ root: TEMP_STORE_ROOT });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
afterEach(() => {
|
|
86
|
+
store.close();
|
|
87
|
+
rmDir(TEMP_STORE_ROOT);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("resolves TOKENBUDDY_BUYER_STORE and enables WAL", () => {
|
|
91
|
+
const previousStoreRoot = process.env.TOKENBUDDY_BUYER_STORE;
|
|
92
|
+
process.env.TOKENBUDDY_BUYER_STORE = TEMP_STORE_ROOT;
|
|
93
|
+
try {
|
|
94
|
+
expect(resolveBuyerStorePath()).toBe(path.join(TEMP_STORE_ROOT, "buyer-store.db"));
|
|
95
|
+
} finally {
|
|
96
|
+
if (previousStoreRoot === undefined) {
|
|
97
|
+
delete process.env.TOKENBUDDY_BUYER_STORE;
|
|
98
|
+
} else {
|
|
99
|
+
process.env.TOKENBUDDY_BUYER_STORE = previousStoreRoot;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
expect(store.journalMode()).toBe("wal");
|
|
104
|
+
expect(fs.existsSync(path.join(TEMP_STORE_ROOT, "buyer-store.db"))).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("keeps token cache behavior behind the buyer store boundary", () => {
|
|
108
|
+
expect(store.getToken("seller-a")).toBeUndefined();
|
|
109
|
+
|
|
110
|
+
store.saveToken("seller-a", "raw-token-secret", "model:gpt-4", 500000, "2030-01-01T00:00:00.000Z");
|
|
111
|
+
expect(store.getToken("seller-a")).toEqual({
|
|
112
|
+
token: "raw-token-secret",
|
|
113
|
+
balanceMicros: 500000
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
store.deductBalance("seller-a", 120000);
|
|
117
|
+
expect(store.getToken("seller-a")).toEqual({
|
|
118
|
+
token: "raw-token-secret",
|
|
119
|
+
balanceMicros: 380000
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("returns stable empty state for payments, pending purchases, and ledgers", () => {
|
|
124
|
+
expect(store.listPayments()).toEqual([]);
|
|
125
|
+
expect(store.listPendingPurchases()).toEqual([]);
|
|
126
|
+
expect(store.listPurchaseLedger()).toEqual([]);
|
|
127
|
+
expect(store.listInferenceLedger()).toEqual([]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("stores payment config and pending purchases with safe references", () => {
|
|
131
|
+
store.savePayment({
|
|
132
|
+
method: "mock",
|
|
133
|
+
enabled: true,
|
|
134
|
+
isDefault: true,
|
|
135
|
+
config: { channel: "developer" }
|
|
136
|
+
});
|
|
137
|
+
store.upsertPendingPurchase({
|
|
138
|
+
purchaseId: "pur_pending_1",
|
|
139
|
+
sellerKey: "seller-a",
|
|
140
|
+
modelId: "gpt-4",
|
|
141
|
+
paymentMethod: "mock",
|
|
142
|
+
amountUsdMicros: 1000000,
|
|
143
|
+
status: "pending",
|
|
144
|
+
paymentReference: "payCredential-secret",
|
|
145
|
+
expiresAt: "2030-01-01T00:00:00.000Z"
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
expect(store.listPayments()).toMatchObject([
|
|
149
|
+
{ method: "mock", enabled: true, isDefault: true, config: { channel: "developer" } }
|
|
150
|
+
]);
|
|
151
|
+
const pendingPurchases = store.listPendingPurchases();
|
|
152
|
+
expect(pendingPurchases).toMatchObject([
|
|
153
|
+
{
|
|
154
|
+
purchaseId: "pur_pending_1",
|
|
155
|
+
sellerKey: "seller-a"
|
|
156
|
+
}
|
|
157
|
+
]);
|
|
158
|
+
expect(pendingPurchases[0]).not.toHaveProperty("paymentReference");
|
|
159
|
+
const serialized = JSON.stringify(pendingPurchases);
|
|
160
|
+
expect(serialized).not.toContain("payCredential-secret");
|
|
161
|
+
expect(serialized).toContain("paymentReferenceHash");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("fetches and removes payment config by method", () => {
|
|
165
|
+
store.savePayment({
|
|
166
|
+
method: "mock",
|
|
167
|
+
enabled: true,
|
|
168
|
+
isDefault: true,
|
|
169
|
+
config: { channel: "developer" }
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
expect(store.getPayment("mock")).toMatchObject({
|
|
173
|
+
method: "mock",
|
|
174
|
+
enabled: true,
|
|
175
|
+
isDefault: true,
|
|
176
|
+
config: { channel: "developer" }
|
|
177
|
+
});
|
|
178
|
+
expect(store.removePayment("mock")).toBe(true);
|
|
179
|
+
expect(store.getPayment("mock")).toBeUndefined();
|
|
180
|
+
expect(store.removePayment("mock")).toBe(false);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("redacts raw proof, prompt, and response from safe ledger output", () => {
|
|
184
|
+
store.recordPurchaseLedger({
|
|
185
|
+
purchaseId: "pur_done_1",
|
|
186
|
+
sellerKey: "seller-a",
|
|
187
|
+
modelId: "gpt-4",
|
|
188
|
+
paymentMethod: "mock",
|
|
189
|
+
status: "funded",
|
|
190
|
+
creditMicros: 1000000,
|
|
191
|
+
currency: "USD",
|
|
192
|
+
paymentReference: "raw-payment-proof-secret"
|
|
193
|
+
});
|
|
194
|
+
store.recordInferenceLedger({
|
|
195
|
+
requestId: "req_1",
|
|
196
|
+
sellerKey: "seller-a",
|
|
197
|
+
modelId: "gpt-4",
|
|
198
|
+
endpoint: "/v1/chat/completions",
|
|
199
|
+
status: "settled",
|
|
200
|
+
promptTokens: 10,
|
|
201
|
+
completionTokens: 20,
|
|
202
|
+
billedMicros: 70,
|
|
203
|
+
prompt: "raw prompt secret",
|
|
204
|
+
response: "raw model response secret"
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const publicOutput = JSON.stringify({
|
|
208
|
+
purchases: store.listPurchaseLedger(),
|
|
209
|
+
inferences: store.listInferenceLedger()
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
for (const secret of [
|
|
213
|
+
"raw-payment-proof-secret",
|
|
214
|
+
"raw prompt secret",
|
|
215
|
+
"raw model response secret",
|
|
216
|
+
"payCredential"
|
|
217
|
+
]) {
|
|
218
|
+
expect(publicOutput).not.toContain(secret);
|
|
219
|
+
}
|
|
220
|
+
expect(publicOutput).toContain("paymentReferenceHash");
|
|
221
|
+
expect(publicOutput).toContain("promptHash");
|
|
222
|
+
expect(publicOutput).toContain("responseHash");
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe("TokenBuddy payment CLI", () => {
|
|
227
|
+
const CLI_STORE_ROOT = path.resolve(__dirname, "../../data-test/payment-cli-store");
|
|
228
|
+
let previousBuyerStore: string | undefined;
|
|
229
|
+
let previousExitCode: string | number | undefined;
|
|
230
|
+
let previousControlPort: string | undefined;
|
|
231
|
+
let previousProxyPort: string | undefined;
|
|
232
|
+
let controlServer: http.Server;
|
|
233
|
+
let controlServerOpen = false;
|
|
234
|
+
let controlPort: number;
|
|
235
|
+
|
|
236
|
+
beforeEach((done) => {
|
|
237
|
+
rmDir(CLI_STORE_ROOT);
|
|
238
|
+
previousBuyerStore = process.env.TOKENBUDDY_BUYER_STORE;
|
|
239
|
+
previousExitCode = process.exitCode;
|
|
240
|
+
previousControlPort = process.env.TB_PROXYD_CONTROL_PORT;
|
|
241
|
+
previousProxyPort = process.env.TB_PROXYD_PROXY_PORT;
|
|
242
|
+
process.env.TOKENBUDDY_BUYER_STORE = CLI_STORE_ROOT;
|
|
243
|
+
process.exitCode = undefined;
|
|
244
|
+
controlServer = http.createServer((req, res) => {
|
|
245
|
+
res.setHeader("Content-Type", "application/json");
|
|
246
|
+
if (req.url === "/status") {
|
|
247
|
+
res.end(JSON.stringify({
|
|
248
|
+
status: "running",
|
|
249
|
+
pid: 12345,
|
|
250
|
+
controlPort,
|
|
251
|
+
proxyPort: 45678
|
|
252
|
+
}));
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
res.statusCode = 404;
|
|
256
|
+
res.end(JSON.stringify({ error: "not_found" }));
|
|
257
|
+
});
|
|
258
|
+
controlServer.listen(0, "127.0.0.1", () => {
|
|
259
|
+
controlServerOpen = true;
|
|
260
|
+
controlPort = (controlServer.address() as AddressInfo).port;
|
|
261
|
+
process.env.TB_PROXYD_CONTROL_PORT = String(controlPort);
|
|
262
|
+
process.env.TB_PROXYD_PROXY_PORT = "45678";
|
|
263
|
+
done();
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
afterEach((done) => {
|
|
268
|
+
if (previousBuyerStore === undefined) {
|
|
269
|
+
delete process.env.TOKENBUDDY_BUYER_STORE;
|
|
270
|
+
} else {
|
|
271
|
+
process.env.TOKENBUDDY_BUYER_STORE = previousBuyerStore;
|
|
272
|
+
}
|
|
273
|
+
if (previousControlPort === undefined) {
|
|
274
|
+
delete process.env.TB_PROXYD_CONTROL_PORT;
|
|
275
|
+
} else {
|
|
276
|
+
process.env.TB_PROXYD_CONTROL_PORT = previousControlPort;
|
|
277
|
+
}
|
|
278
|
+
if (previousProxyPort === undefined) {
|
|
279
|
+
delete process.env.TB_PROXYD_PROXY_PORT;
|
|
280
|
+
} else {
|
|
281
|
+
process.env.TB_PROXYD_PROXY_PORT = previousProxyPort;
|
|
282
|
+
}
|
|
283
|
+
process.exitCode = previousExitCode;
|
|
284
|
+
rmDir(CLI_STORE_ROOT);
|
|
285
|
+
jest.restoreAllMocks();
|
|
286
|
+
if (controlServerOpen) {
|
|
287
|
+
controlServer.close(() => {
|
|
288
|
+
controlServerOpen = false;
|
|
289
|
+
done();
|
|
290
|
+
});
|
|
291
|
+
} else {
|
|
292
|
+
done();
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("ordinary commands fail closed when tb-proxyd is not running", async () => {
|
|
297
|
+
await new Promise<void>((resolve) => controlServer.close(() => {
|
|
298
|
+
controlServerOpen = false;
|
|
299
|
+
resolve();
|
|
300
|
+
}));
|
|
301
|
+
const errors: string[] = [];
|
|
302
|
+
const program = buildCli();
|
|
303
|
+
program.exitOverride();
|
|
304
|
+
program.configureOutput({ writeErr: () => undefined });
|
|
305
|
+
jest.spyOn(console, "error").mockImplementation((message?: unknown) => {
|
|
306
|
+
errors.push(String(message));
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
await expect(program.parseAsync(["node", "tb", "payment", "list", "--json"])).rejects.toThrow("tb-proxyd is not running");
|
|
310
|
+
expect(errors.join("\n")).toContain("tb doctor --fix");
|
|
311
|
+
expect(errors.join("\n")).toContain("tb init");
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("payment list --json emits pure JSON with supported methods", async () => {
|
|
315
|
+
const store = new BuyerStore({ root: CLI_STORE_ROOT });
|
|
316
|
+
store.savePayment({
|
|
317
|
+
method: "mock",
|
|
318
|
+
enabled: true,
|
|
319
|
+
isDefault: true,
|
|
320
|
+
config: { channel: "developer", explicitOptIn: true }
|
|
321
|
+
});
|
|
322
|
+
store.close();
|
|
323
|
+
|
|
324
|
+
const output: string[] = [];
|
|
325
|
+
jest.spyOn(console, "log").mockImplementation((message?: unknown) => {
|
|
326
|
+
output.push(String(message));
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
const program = buildCli();
|
|
330
|
+
await program.parseAsync(["node", "tb", "payment", "list", "--json"]);
|
|
331
|
+
|
|
332
|
+
expect(output).toHaveLength(1);
|
|
333
|
+
const parsed = JSON.parse(output[0]) as any;
|
|
334
|
+
expect(parsed.payments).toEqual(expect.arrayContaining([
|
|
335
|
+
expect.objectContaining({
|
|
336
|
+
method: "mock",
|
|
337
|
+
supported: true,
|
|
338
|
+
configured: true,
|
|
339
|
+
enabled: true,
|
|
340
|
+
isDefault: true
|
|
341
|
+
}),
|
|
342
|
+
expect.objectContaining({
|
|
343
|
+
method: "clawtip",
|
|
344
|
+
supported: true,
|
|
345
|
+
configured: false
|
|
346
|
+
})
|
|
347
|
+
]));
|
|
348
|
+
expect(output[0]).not.toContain("payCredential");
|
|
349
|
+
expect(output[0]).not.toContain("encryptedData");
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("payment add/remove mock updates the buyer store", async () => {
|
|
353
|
+
const logs: string[] = [];
|
|
354
|
+
jest.spyOn(console, "log").mockImplementation((message?: unknown) => {
|
|
355
|
+
logs.push(String(message));
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
await buildCli().parseAsync(["node", "tb", "payment", "add", "mock"]);
|
|
359
|
+
let store = new BuyerStore({ root: CLI_STORE_ROOT });
|
|
360
|
+
expect(store.getPayment("mock")).toMatchObject({
|
|
361
|
+
method: "mock",
|
|
362
|
+
enabled: true,
|
|
363
|
+
isDefault: true,
|
|
364
|
+
config: { channel: "developer", explicitOptIn: true }
|
|
365
|
+
});
|
|
366
|
+
store.close();
|
|
367
|
+
|
|
368
|
+
await buildCli().parseAsync(["node", "tb", "payment", "remove", "mock"]);
|
|
369
|
+
store = new BuyerStore({ root: CLI_STORE_ROOT });
|
|
370
|
+
expect(store.getPayment("mock")).toBeUndefined();
|
|
371
|
+
store.close();
|
|
372
|
+
expect(logs.join("\n")).toContain("Mock payment method registered");
|
|
373
|
+
expect(logs.join("\n")).toContain("removed");
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
describe("TokenBuddy JSON inspection commands", () => {
|
|
378
|
+
let controlServer: http.Server;
|
|
379
|
+
let controlPort: number;
|
|
380
|
+
let previousControlPort: string | undefined;
|
|
381
|
+
let previousProxyPort: string | undefined;
|
|
382
|
+
|
|
383
|
+
beforeEach((done) => {
|
|
384
|
+
previousControlPort = process.env.TB_PROXYD_CONTROL_PORT;
|
|
385
|
+
previousProxyPort = process.env.TB_PROXYD_PROXY_PORT;
|
|
386
|
+
controlServer = http.createServer((req, res) => {
|
|
387
|
+
res.setHeader("Content-Type", "application/json");
|
|
388
|
+
if (req.url === "/status") {
|
|
389
|
+
res.end(JSON.stringify({
|
|
390
|
+
status: "running",
|
|
391
|
+
pid: 12345,
|
|
392
|
+
controlPort,
|
|
393
|
+
proxyPort: 45678
|
|
394
|
+
}));
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
if (req.url === "/models") {
|
|
398
|
+
res.end(JSON.stringify({
|
|
399
|
+
object: "list",
|
|
400
|
+
data: [
|
|
401
|
+
{ id: "gpt-4", sellerId: "json-test-seller" }
|
|
402
|
+
]
|
|
403
|
+
}));
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
res.statusCode = 404;
|
|
407
|
+
res.end(JSON.stringify({ error: "not_found" }));
|
|
408
|
+
});
|
|
409
|
+
controlServer.listen(0, "127.0.0.1", () => {
|
|
410
|
+
controlPort = (controlServer.address() as AddressInfo).port;
|
|
411
|
+
process.env.TB_PROXYD_CONTROL_PORT = String(controlPort);
|
|
412
|
+
process.env.TB_PROXYD_PROXY_PORT = "45678";
|
|
413
|
+
done();
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
afterEach((done) => {
|
|
418
|
+
if (previousControlPort === undefined) {
|
|
419
|
+
delete process.env.TB_PROXYD_CONTROL_PORT;
|
|
420
|
+
} else {
|
|
421
|
+
process.env.TB_PROXYD_CONTROL_PORT = previousControlPort;
|
|
422
|
+
}
|
|
423
|
+
if (previousProxyPort === undefined) {
|
|
424
|
+
delete process.env.TB_PROXYD_PROXY_PORT;
|
|
425
|
+
} else {
|
|
426
|
+
process.env.TB_PROXYD_PROXY_PORT = previousProxyPort;
|
|
427
|
+
}
|
|
428
|
+
jest.restoreAllMocks();
|
|
429
|
+
controlServer.close(done);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test("doctor --json reports daemon and provider state", async () => {
|
|
433
|
+
const output: string[] = [];
|
|
434
|
+
jest.spyOn(console, "log").mockImplementation((message?: unknown) => {
|
|
435
|
+
output.push(String(message));
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
await buildCli().parseAsync(["node", "tb", "doctor", "--json"]);
|
|
439
|
+
|
|
440
|
+
expect(output).toHaveLength(1);
|
|
441
|
+
const parsed = JSON.parse(output[0]) as any;
|
|
442
|
+
expect(parsed.daemon).toMatchObject({
|
|
443
|
+
running: true,
|
|
444
|
+
controlPort,
|
|
445
|
+
proxyPort: 45678,
|
|
446
|
+
fixAvailable: true
|
|
447
|
+
});
|
|
448
|
+
expect(parsed.repair).toMatchObject({
|
|
449
|
+
requested: false,
|
|
450
|
+
attempted: false,
|
|
451
|
+
fixed: false
|
|
452
|
+
});
|
|
453
|
+
expect(parsed.providers).toEqual(expect.arrayContaining([
|
|
454
|
+
expect.objectContaining({ id: "codex" }),
|
|
455
|
+
expect.objectContaining({ id: "claude-code" })
|
|
456
|
+
]));
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
test("models --json returns daemon model data", async () => {
|
|
460
|
+
const output: string[] = [];
|
|
461
|
+
jest.spyOn(console, "log").mockImplementation((message?: unknown) => {
|
|
462
|
+
output.push(String(message));
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
await buildCli().parseAsync(["node", "tb", "models", "--json"]);
|
|
466
|
+
|
|
467
|
+
expect(output).toHaveLength(1);
|
|
468
|
+
const parsed = JSON.parse(output[0]) as any;
|
|
469
|
+
expect(parsed.data).toEqual([
|
|
470
|
+
{ id: "gpt-4", sellerId: "json-test-seller" }
|
|
471
|
+
]);
|
|
472
|
+
});
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
describe("Provider install planning", () => {
|
|
476
|
+
const PROVIDER_HOME = path.resolve(__dirname, "../../data-test/provider-home");
|
|
477
|
+
const PROVIDER_STORE_ROOT = path.resolve(__dirname, "../../data-test/provider-store");
|
|
478
|
+
const proxyUrl = "http://127.0.0.1:17821";
|
|
479
|
+
|
|
480
|
+
beforeEach(() => {
|
|
481
|
+
rmDir(PROVIDER_HOME);
|
|
482
|
+
rmDir(PROVIDER_STORE_ROOT);
|
|
483
|
+
fs.mkdirSync(path.join(PROVIDER_HOME, ".codex"), { recursive: true });
|
|
484
|
+
fs.mkdirSync(path.join(PROVIDER_HOME, ".claude"), { recursive: true });
|
|
485
|
+
fs.mkdirSync(path.join(PROVIDER_HOME, ".openclaw"), { recursive: true });
|
|
486
|
+
fs.writeFileSync(path.join(PROVIDER_HOME, ".codex", "config.toml"), "approval_policy = \"never\"\n", "utf8");
|
|
487
|
+
fs.writeFileSync(path.join(PROVIDER_HOME, ".claude", "settings.json"), JSON.stringify({ theme: "dark" }, null, 2), "utf8");
|
|
488
|
+
fs.writeFileSync(path.join(PROVIDER_HOME, ".openclaw", "config.json"), JSON.stringify({ keep: "field" }, null, 2), "utf8");
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
afterEach(() => {
|
|
492
|
+
rmDir(PROVIDER_HOME);
|
|
493
|
+
rmDir(PROVIDER_STORE_ROOT);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
test("detects providers and previews without mutating files", () => {
|
|
497
|
+
const codexBefore = fs.readFileSync(path.join(PROVIDER_HOME, ".codex", "config.toml"), "utf8");
|
|
498
|
+
const providers = detectProviders({ home: PROVIDER_HOME });
|
|
499
|
+
expect(providers).toEqual(expect.arrayContaining([
|
|
500
|
+
expect.objectContaining({ id: "codex", detected: true }),
|
|
501
|
+
expect.objectContaining({ id: "claude-code", detected: true }),
|
|
502
|
+
expect.objectContaining({ id: "openclaw", detected: true }),
|
|
503
|
+
expect.objectContaining({ id: "hermes", detected: false })
|
|
504
|
+
]));
|
|
505
|
+
|
|
506
|
+
const changes = previewProviderInstall({
|
|
507
|
+
providers: ["codex", "claude-code", "openclaw", "hermes"],
|
|
508
|
+
proxyUrl,
|
|
509
|
+
model: "gpt-4",
|
|
510
|
+
home: PROVIDER_HOME
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
expect(changes).toEqual(expect.arrayContaining([
|
|
514
|
+
expect.objectContaining({ providerId: "codex", action: "update" }),
|
|
515
|
+
expect.objectContaining({ providerId: "hermes", action: "create" })
|
|
516
|
+
]));
|
|
517
|
+
expect(fs.readFileSync(path.join(PROVIDER_HOME, ".codex", "config.toml"), "utf8")).toBe(codexBefore);
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
test("applies provider config and rolls back existing and created files", () => {
|
|
521
|
+
const store = new BuyerStore({ root: PROVIDER_STORE_ROOT });
|
|
522
|
+
try {
|
|
523
|
+
const applied = applyProviderInstall({
|
|
524
|
+
providers: ["codex", "claude-code", "openclaw", "hermes"],
|
|
525
|
+
proxyUrl,
|
|
526
|
+
model: "gpt-4",
|
|
527
|
+
home: PROVIDER_HOME
|
|
528
|
+
}, store);
|
|
529
|
+
expect(applied).toEqual(expect.arrayContaining([
|
|
530
|
+
expect.objectContaining({ providerId: "codex", action: "updated" }),
|
|
531
|
+
expect.objectContaining({ providerId: "hermes", action: "created" })
|
|
532
|
+
]));
|
|
533
|
+
|
|
534
|
+
const codex = fs.readFileSync(path.join(PROVIDER_HOME, ".codex", "config.toml"), "utf8");
|
|
535
|
+
expect(codex).toContain("approval_policy = \"never\"");
|
|
536
|
+
expect(codex).toContain("[tokenbuddy]");
|
|
537
|
+
expect(codex).toContain(`proxy_url = "${proxyUrl}"`);
|
|
538
|
+
|
|
539
|
+
const claude = JSON.parse(fs.readFileSync(path.join(PROVIDER_HOME, ".claude", "settings.json"), "utf8"));
|
|
540
|
+
expect(claude.theme).toBe("dark");
|
|
541
|
+
expect(claude.env.ANTHROPIC_BASE_URL).toBe(proxyUrl);
|
|
542
|
+
|
|
543
|
+
const openclaw = JSON.parse(fs.readFileSync(path.join(PROVIDER_HOME, ".openclaw", "config.json"), "utf8"));
|
|
544
|
+
expect(openclaw.keep).toBe("field");
|
|
545
|
+
expect(openclaw.api_url).toBe(proxyUrl);
|
|
546
|
+
expect(fs.existsSync(path.join(PROVIDER_HOME, ".hermes", "settings.json"))).toBe(true);
|
|
547
|
+
expect(store.getProviderInstallSnapshot("codex")).toBeDefined();
|
|
548
|
+
|
|
549
|
+
const rolledBack = rollbackProviderInstall({
|
|
550
|
+
providers: ["codex", "claude-code", "openclaw", "hermes"],
|
|
551
|
+
home: PROVIDER_HOME
|
|
552
|
+
}, store);
|
|
553
|
+
|
|
554
|
+
expect(rolledBack).toEqual(expect.arrayContaining([
|
|
555
|
+
expect.objectContaining({ providerId: "codex", action: "restored" }),
|
|
556
|
+
expect.objectContaining({ providerId: "hermes", action: "removed" })
|
|
557
|
+
]));
|
|
558
|
+
expect(fs.readFileSync(path.join(PROVIDER_HOME, ".codex", "config.toml"), "utf8")).toBe("approval_policy = \"never\"\n");
|
|
559
|
+
expect(JSON.parse(fs.readFileSync(path.join(PROVIDER_HOME, ".openclaw", "config.json"), "utf8"))).toEqual({ keep: "field" });
|
|
560
|
+
expect(fs.existsSync(path.join(PROVIDER_HOME, ".hermes", "settings.json"))).toBe(false);
|
|
561
|
+
expect(store.getProviderInstallSnapshot("codex")).toBeUndefined();
|
|
562
|
+
} finally {
|
|
563
|
+
store.close();
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
describe("TokenBuddy CLI and Daemon Integration Tests", () => {
|
|
569
|
+
let daemon: TokenbuddyDaemon;
|
|
570
|
+
let mockSellerServer: http.Server;
|
|
571
|
+
let sellerReqCount = 0;
|
|
572
|
+
let completeReqCount = 0;
|
|
573
|
+
let mockSellerPort: number;
|
|
574
|
+
let daemonControlPort: number;
|
|
575
|
+
let daemonProxyPort: number;
|
|
576
|
+
const sellerRequests: Array<{
|
|
577
|
+
url?: string;
|
|
578
|
+
authorization?: string;
|
|
579
|
+
idempotencyKey?: string;
|
|
580
|
+
paymentMethod?: string;
|
|
581
|
+
body?: any;
|
|
582
|
+
}> = [];
|
|
583
|
+
|
|
584
|
+
const readJsonBody = (req: http.IncomingMessage): Promise<any> => new Promise((resolve) => {
|
|
585
|
+
let body = "";
|
|
586
|
+
req.on("data", (chunk) => {
|
|
587
|
+
body += chunk.toString();
|
|
588
|
+
});
|
|
589
|
+
req.on("end", () => {
|
|
590
|
+
resolve(body ? JSON.parse(body) : {});
|
|
591
|
+
});
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
beforeAll((done) => {
|
|
595
|
+
mockSellerServer = http.createServer(async (req, res) => {
|
|
596
|
+
res.setHeader("Content-Type", "application/json");
|
|
597
|
+
|
|
598
|
+
if (req.url === "/registry/sellers") {
|
|
599
|
+
res.end(JSON.stringify({
|
|
600
|
+
version: 1,
|
|
601
|
+
defaultSeller: "mock-seller",
|
|
602
|
+
sellers: [
|
|
603
|
+
{
|
|
604
|
+
id: "incompatible-seller",
|
|
605
|
+
name: "Incompatible Seller",
|
|
606
|
+
url: `http://127.0.0.1:${mockSellerPort}/incompatible`,
|
|
607
|
+
supportedProtocols: ["chat_completions"],
|
|
608
|
+
paymentMethods: ["mock"]
|
|
609
|
+
},
|
|
610
|
+
{
|
|
611
|
+
id: "mock-seller",
|
|
612
|
+
name: "Mock Seller",
|
|
613
|
+
url: `http://127.0.0.1:${mockSellerPort}`,
|
|
614
|
+
supportedProtocols: ["chat_completions", "responses", "messages"],
|
|
615
|
+
paymentMethods: ["mock"]
|
|
616
|
+
}
|
|
617
|
+
]
|
|
618
|
+
}));
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
if (req.url === "/incompatible/manifest") {
|
|
623
|
+
res.end(JSON.stringify({
|
|
624
|
+
sellerId: "incompatible-seller",
|
|
625
|
+
supportedProtocols: ["chat_completions"],
|
|
626
|
+
paymentMethods: ["mock"],
|
|
627
|
+
models: [
|
|
628
|
+
{ id: "other-model" }
|
|
629
|
+
]
|
|
630
|
+
}));
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
if (req.url === "/manifest") {
|
|
635
|
+
res.end(JSON.stringify({
|
|
636
|
+
sellerId: "mock-seller",
|
|
637
|
+
supportedProtocols: ["chat_completions", "responses", "messages"],
|
|
638
|
+
paymentMethods: ["mock"],
|
|
639
|
+
models: [
|
|
640
|
+
{ id: "gpt-4" },
|
|
641
|
+
{ id: "gpt-4.1-mini" },
|
|
642
|
+
{ id: "claude-3-5-sonnet" }
|
|
643
|
+
]
|
|
644
|
+
}));
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
if (req.url === "/purchase/create") {
|
|
649
|
+
const body = await readJsonBody(req);
|
|
650
|
+
sellerRequests.push({ url: req.url, paymentMethod: body.paymentMethod, body });
|
|
651
|
+
sellerReqCount++;
|
|
652
|
+
res.end(JSON.stringify({
|
|
653
|
+
purchaseId: "pur_mock_123",
|
|
654
|
+
status: "pending",
|
|
655
|
+
creditMicros: 2000000,
|
|
656
|
+
currency: "USD",
|
|
657
|
+
expiresAt: new Date(Date.now() + 86400 * 1000).toISOString()
|
|
658
|
+
}));
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
if (req.url === "/purchase/complete") {
|
|
663
|
+
const body = await readJsonBody(req);
|
|
664
|
+
sellerRequests.push({ url: req.url, paymentMethod: body.paymentMethod, body });
|
|
665
|
+
completeReqCount++;
|
|
666
|
+
res.end(JSON.stringify({
|
|
667
|
+
purchaseId: "pur_mock_123",
|
|
668
|
+
status: "active",
|
|
669
|
+
accessToken: "tok_mock_token_abc",
|
|
670
|
+
tokenClass: "model:gpt-4",
|
|
671
|
+
creditMicros: 2000000,
|
|
672
|
+
currency: "USD"
|
|
673
|
+
}));
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
if (req.url === "/v1/chat/completions") {
|
|
678
|
+
const body = await readJsonBody(req);
|
|
679
|
+
sellerRequests.push({
|
|
680
|
+
url: req.url,
|
|
681
|
+
authorization: req.headers.authorization,
|
|
682
|
+
idempotencyKey: req.headers["idempotency-key"] as string | undefined,
|
|
683
|
+
body
|
|
684
|
+
});
|
|
685
|
+
if (body.stream) {
|
|
686
|
+
res.writeHead(200, { "Content-Type": "text/event-stream" });
|
|
687
|
+
res.write("data: {\"id\":\"chatcmpl-stream\",\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\n\n");
|
|
688
|
+
res.end("data: [DONE]\n\n");
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
res.end(JSON.stringify({
|
|
692
|
+
id: "chatcmpl-mock",
|
|
693
|
+
usage: { prompt_tokens: 10, completion_tokens: 10 }
|
|
694
|
+
}));
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
if (req.url === "/v1/responses") {
|
|
699
|
+
const body = await readJsonBody(req);
|
|
700
|
+
sellerRequests.push({
|
|
701
|
+
url: req.url,
|
|
702
|
+
authorization: req.headers.authorization,
|
|
703
|
+
idempotencyKey: req.headers["idempotency-key"] as string | undefined,
|
|
704
|
+
body
|
|
705
|
+
});
|
|
706
|
+
res.end(JSON.stringify({
|
|
707
|
+
id: "resp-mock",
|
|
708
|
+
usage: { input_tokens: 7, output_tokens: 9 }
|
|
709
|
+
}));
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
if (req.url === "/v1/messages" || req.url === "/messages") {
|
|
714
|
+
const body = await readJsonBody(req);
|
|
715
|
+
sellerRequests.push({
|
|
716
|
+
url: req.url,
|
|
717
|
+
authorization: req.headers.authorization,
|
|
718
|
+
idempotencyKey: req.headers["idempotency-key"] as string | undefined,
|
|
719
|
+
body
|
|
720
|
+
});
|
|
721
|
+
res.end(JSON.stringify({
|
|
722
|
+
id: "msg-mock",
|
|
723
|
+
usage: { input_tokens: 5, output_tokens: 6 }
|
|
724
|
+
}));
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
res.end("{}");
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
mockSellerServer.listen(0, "127.0.0.1", () => {
|
|
732
|
+
mockSellerPort = (mockSellerServer.address() as AddressInfo).port;
|
|
733
|
+
done();
|
|
734
|
+
});
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
afterAll((done) => {
|
|
738
|
+
mockSellerServer.close(done);
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
beforeEach(() => {
|
|
742
|
+
rmSqliteFiles(TEMP_BUYER_DB);
|
|
743
|
+
sellerReqCount = 0;
|
|
744
|
+
completeReqCount = 0;
|
|
745
|
+
sellerRequests.length = 0;
|
|
746
|
+
const seedStore = new BuyerStore({ dbPath: TEMP_BUYER_DB });
|
|
747
|
+
seedStore.savePayment({
|
|
748
|
+
method: "mock",
|
|
749
|
+
enabled: true,
|
|
750
|
+
isDefault: true,
|
|
751
|
+
config: { channel: "control-plane-test" }
|
|
752
|
+
});
|
|
753
|
+
seedStore.recordPurchaseLedger({
|
|
754
|
+
purchaseId: "pur_control_1",
|
|
755
|
+
sellerKey: "mock-seller",
|
|
756
|
+
modelId: "gpt-4",
|
|
757
|
+
paymentMethod: "mock",
|
|
758
|
+
status: "funded",
|
|
759
|
+
creditMicros: 1000000,
|
|
760
|
+
currency: "USD",
|
|
761
|
+
paymentReference: "raw-control-payment-proof"
|
|
762
|
+
});
|
|
763
|
+
seedStore.recordInferenceLedger({
|
|
764
|
+
requestId: "req_control_1",
|
|
765
|
+
sellerKey: "mock-seller",
|
|
766
|
+
modelId: "gpt-4",
|
|
767
|
+
endpoint: "/v1/chat/completions",
|
|
768
|
+
status: "settled",
|
|
769
|
+
promptTokens: 10,
|
|
770
|
+
completionTokens: 20,
|
|
771
|
+
billedMicros: 70,
|
|
772
|
+
prompt: "raw control prompt",
|
|
773
|
+
response: "raw control response"
|
|
774
|
+
});
|
|
775
|
+
seedStore.close();
|
|
776
|
+
|
|
777
|
+
daemon = new TokenbuddyDaemon({
|
|
778
|
+
controlPort: 0,
|
|
779
|
+
proxyPort: 0,
|
|
780
|
+
dbPath: TEMP_BUYER_DB,
|
|
781
|
+
sellerRegistryUrl: `http://127.0.0.1:${mockSellerPort}/registry/sellers`
|
|
782
|
+
});
|
|
783
|
+
daemon.start();
|
|
784
|
+
daemonControlPort = ((daemon as any).controlServer.address() as AddressInfo).port;
|
|
785
|
+
daemonProxyPort = ((daemon as any).proxyServer.address() as AddressInfo).port;
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
afterEach(() => {
|
|
789
|
+
daemon.stop();
|
|
790
|
+
rmSqliteFiles(TEMP_BUYER_DB);
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
test("Terminal detection Candidates works without errors", () => {
|
|
794
|
+
const list = detectTerminals();
|
|
795
|
+
expect(list.length).toBeGreaterThan(0);
|
|
796
|
+
expect(list.some(c => c.id === "claude-code")).toBe(true);
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
test("control plane exposes health, registry-backed models, payments, and safe ledgers", async () => {
|
|
800
|
+
const controlUrl = `http://127.0.0.1:${daemonControlPort}`;
|
|
801
|
+
|
|
802
|
+
const health = await (await fetch(`${controlUrl}/health`)).json() as any;
|
|
803
|
+
expect(health).toMatchObject({
|
|
804
|
+
status: "ok",
|
|
805
|
+
controlPort: daemonControlPort,
|
|
806
|
+
proxyPort: daemonProxyPort,
|
|
807
|
+
store: { journalMode: "wal" }
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
const status = await (await fetch(`${controlUrl}/status`)).json() as any;
|
|
811
|
+
expect(status).toMatchObject({
|
|
812
|
+
status: "running",
|
|
813
|
+
controlPort: daemonControlPort,
|
|
814
|
+
proxyPort: daemonProxyPort,
|
|
815
|
+
sellerRegistryUrl: `http://127.0.0.1:${mockSellerPort}/registry/sellers`,
|
|
816
|
+
store: {
|
|
817
|
+
paymentsCount: 1,
|
|
818
|
+
purchaseLedgerCount: 1,
|
|
819
|
+
inferenceLedgerCount: 1
|
|
820
|
+
}
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
const sellers = await (await fetch(`${controlUrl}/sellers`)).json() as any;
|
|
824
|
+
expect(sellers.sellers).toEqual(expect.arrayContaining([
|
|
825
|
+
expect.objectContaining({
|
|
826
|
+
id: "mock-seller",
|
|
827
|
+
status: "configured",
|
|
828
|
+
paymentMethods: ["mock"]
|
|
829
|
+
})
|
|
830
|
+
]));
|
|
831
|
+
|
|
832
|
+
const models = await (await fetch(`${controlUrl}/models`)).json() as any;
|
|
833
|
+
expect(models.data).toEqual(expect.arrayContaining([
|
|
834
|
+
expect.objectContaining({
|
|
835
|
+
id: "gpt-4",
|
|
836
|
+
sellerId: "mock-seller",
|
|
837
|
+
paymentMethods: ["mock"]
|
|
838
|
+
})
|
|
839
|
+
]));
|
|
840
|
+
expect(models.sellers).toEqual(expect.arrayContaining([
|
|
841
|
+
expect.objectContaining({ id: "mock-seller", status: "ok" })
|
|
842
|
+
]));
|
|
843
|
+
|
|
844
|
+
const payments = await (await fetch(`${controlUrl}/payments`)).json() as any;
|
|
845
|
+
expect(payments.payments).toMatchObject([
|
|
846
|
+
{ method: "mock", enabled: true, isDefault: true }
|
|
847
|
+
]);
|
|
848
|
+
|
|
849
|
+
const purchases = await (await fetch(`${controlUrl}/ledger/purchases`)).json() as any;
|
|
850
|
+
const inferences = await (await fetch(`${controlUrl}/ledger/inferences`)).json() as any;
|
|
851
|
+
const publicOutput = JSON.stringify({ purchases, inferences, payments, models, sellers, status, health });
|
|
852
|
+
|
|
853
|
+
expect(purchases.purchases).toHaveLength(1);
|
|
854
|
+
expect(inferences.inferences).toHaveLength(1);
|
|
855
|
+
expect(publicOutput).toContain("paymentReferenceHash");
|
|
856
|
+
expect(publicOutput).toContain("promptHash");
|
|
857
|
+
expect(publicOutput).toContain("responseHash");
|
|
858
|
+
for (const secret of [
|
|
859
|
+
"raw-control-payment-proof",
|
|
860
|
+
"raw control prompt",
|
|
861
|
+
"raw control response",
|
|
862
|
+
"payCredential"
|
|
863
|
+
]) {
|
|
864
|
+
expect(publicOutput).not.toContain(secret);
|
|
865
|
+
}
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
test("coalesces concurrent chat requests and preserves purchase/proxy headers", async () => {
|
|
869
|
+
const chatReq = {
|
|
870
|
+
model: "gpt-4",
|
|
871
|
+
messages: [{ role: "user", content: "raw concurrent prompt secret" }],
|
|
872
|
+
requestId: "chat_req_parallel"
|
|
873
|
+
};
|
|
874
|
+
|
|
875
|
+
const sendRequest = () => fetch(`http://127.0.0.1:${daemonProxyPort}/v1/chat/completions`, {
|
|
876
|
+
method: "POST",
|
|
877
|
+
headers: {
|
|
878
|
+
"Content-Type": "application/json",
|
|
879
|
+
"Idempotency-Key": "idem-chat-preserved"
|
|
880
|
+
},
|
|
881
|
+
body: JSON.stringify(chatReq)
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
const responses = await Promise.all([
|
|
885
|
+
sendRequest(), sendRequest(), sendRequest(), sendRequest(), sendRequest(),
|
|
886
|
+
sendRequest(), sendRequest(), sendRequest(), sendRequest(), sendRequest()
|
|
887
|
+
]);
|
|
888
|
+
|
|
889
|
+
for (const r of responses) {
|
|
890
|
+
expect(r.ok).toBe(true);
|
|
891
|
+
const data: any = await r.json();
|
|
892
|
+
expect(data.id).toBe("chatcmpl-mock");
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
expect(sellerReqCount).toBe(1);
|
|
896
|
+
expect(completeReqCount).toBe(1);
|
|
897
|
+
expect(sellerRequests.find((request) => request.url === "/purchase/create")).toMatchObject({
|
|
898
|
+
paymentMethod: "mock"
|
|
899
|
+
});
|
|
900
|
+
const chatForwards = sellerRequests.filter((request) => request.url === "/v1/chat/completions");
|
|
901
|
+
expect(chatForwards).toHaveLength(10);
|
|
902
|
+
expect(chatForwards.every((request) => request.authorization === "Bearer tok_mock_token_abc")).toBe(true);
|
|
903
|
+
expect(chatForwards.every((request) => request.idempotencyKey === "idem-chat-preserved")).toBe(true);
|
|
904
|
+
|
|
905
|
+
const controlUrl = `http://127.0.0.1:${daemonControlPort}`;
|
|
906
|
+
const purchases = await (await fetch(`${controlUrl}/ledger/purchases`)).json() as any;
|
|
907
|
+
const inferences = await (await fetch(`${controlUrl}/ledger/inferences`)).json() as any;
|
|
908
|
+
expect(purchases.purchases.some((entry: any) => entry.purchaseId === "pur_mock_123" && entry.paymentMethod === "mock")).toBe(true);
|
|
909
|
+
expect(inferences.inferences.filter((entry: any) => entry.endpoint === "/v1/chat/completions")).toHaveLength(11);
|
|
910
|
+
const publicOutput = JSON.stringify({ purchases, inferences });
|
|
911
|
+
expect(publicOutput).not.toContain("raw concurrent prompt secret");
|
|
912
|
+
expect(publicOutput).not.toContain("tok_mock_token_abc");
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
test("proxies models, responses, and anthropic message endpoints through compatible seller manifests", async () => {
|
|
916
|
+
const proxyUrl = `http://127.0.0.1:${daemonProxyPort}`;
|
|
917
|
+
|
|
918
|
+
const models = await (await fetch(`${proxyUrl}/v1/models`)).json() as any;
|
|
919
|
+
expect(models.data).toEqual(expect.arrayContaining([
|
|
920
|
+
expect.objectContaining({
|
|
921
|
+
id: "gpt-4",
|
|
922
|
+
sellerId: "mock-seller"
|
|
923
|
+
})
|
|
924
|
+
]));
|
|
925
|
+
|
|
926
|
+
const responses = await fetch(`${proxyUrl}/v1/responses`, {
|
|
927
|
+
method: "POST",
|
|
928
|
+
headers: {
|
|
929
|
+
"Content-Type": "application/json",
|
|
930
|
+
"Idempotency-Key": "idem-responses-preserved"
|
|
931
|
+
},
|
|
932
|
+
body: JSON.stringify({
|
|
933
|
+
model: "gpt-4.1-mini",
|
|
934
|
+
input: "raw responses prompt secret",
|
|
935
|
+
requestId: "responses_req_1"
|
|
936
|
+
})
|
|
937
|
+
});
|
|
938
|
+
expect(responses.ok).toBe(true);
|
|
939
|
+
expect((await responses.json() as any).id).toBe("resp-mock");
|
|
940
|
+
|
|
941
|
+
const largeInput = `raw large responses prompt secret ${"x".repeat(160 * 1024)}`;
|
|
942
|
+
const largeResponses = await fetch(`${proxyUrl}/v1/responses`, {
|
|
943
|
+
method: "POST",
|
|
944
|
+
headers: {
|
|
945
|
+
"Content-Type": "application/json",
|
|
946
|
+
"Idempotency-Key": "idem-large-responses-preserved"
|
|
947
|
+
},
|
|
948
|
+
body: JSON.stringify({
|
|
949
|
+
model: "gpt-4.1-mini",
|
|
950
|
+
input: largeInput,
|
|
951
|
+
requestId: "responses_req_large"
|
|
952
|
+
})
|
|
953
|
+
});
|
|
954
|
+
expect(largeResponses.ok).toBe(true);
|
|
955
|
+
expect((await largeResponses.json() as any).id).toBe("resp-mock");
|
|
956
|
+
|
|
957
|
+
for (const endpoint of ["/v1/messages", "/messages"]) {
|
|
958
|
+
const message = await fetch(`${proxyUrl}${endpoint}`, {
|
|
959
|
+
method: "POST",
|
|
960
|
+
headers: { "Content-Type": "application/json" },
|
|
961
|
+
body: JSON.stringify({
|
|
962
|
+
model: "claude-3-5-sonnet",
|
|
963
|
+
messages: [{ role: "user", content: "raw anthropic prompt secret" }]
|
|
964
|
+
})
|
|
965
|
+
});
|
|
966
|
+
expect(message.ok).toBe(true);
|
|
967
|
+
expect((await message.json() as any).id).toBe("msg-mock");
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
expect(sellerRequests.find((request) => request.url === "/v1/responses")?.idempotencyKey).toBe("idem-responses-preserved");
|
|
971
|
+
expect(sellerRequests.find((request) => request.idempotencyKey === "idem-large-responses-preserved")?.body.input).toHaveLength(largeInput.length);
|
|
972
|
+
expect(sellerRequests.some((request) => request.url === "/v1/messages")).toBe(true);
|
|
973
|
+
expect(sellerRequests.some((request) => request.url === "/messages")).toBe(true);
|
|
974
|
+
|
|
975
|
+
const inferences = await (await fetch(`http://127.0.0.1:${daemonControlPort}/ledger/inferences`)).json() as any;
|
|
976
|
+
expect(inferences.inferences).toEqual(expect.arrayContaining([
|
|
977
|
+
expect.objectContaining({ endpoint: "/v1/responses", promptTokens: 7, completionTokens: 9 }),
|
|
978
|
+
expect.objectContaining({ endpoint: "/v1/responses", requestId: "responses_req_large" }),
|
|
979
|
+
expect.objectContaining({ endpoint: "/v1/messages", promptTokens: 5, completionTokens: 6 }),
|
|
980
|
+
expect.objectContaining({ endpoint: "/messages", promptTokens: 5, completionTokens: 6 })
|
|
981
|
+
]));
|
|
982
|
+
const publicOutput = JSON.stringify(inferences);
|
|
983
|
+
expect(publicOutput).not.toContain("raw responses prompt secret");
|
|
984
|
+
expect(publicOutput).not.toContain("raw large responses prompt secret");
|
|
985
|
+
expect(publicOutput).not.toContain("raw anthropic prompt secret");
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
test("passes through streaming chat responses and records safe ledger metadata", async () => {
|
|
989
|
+
const response = await fetch(`http://127.0.0.1:${daemonProxyPort}/v1/chat/completions`, {
|
|
990
|
+
method: "POST",
|
|
991
|
+
headers: {
|
|
992
|
+
"Content-Type": "application/json",
|
|
993
|
+
"Idempotency-Key": "idem-stream-preserved"
|
|
994
|
+
},
|
|
995
|
+
body: JSON.stringify({
|
|
996
|
+
model: "gpt-4",
|
|
997
|
+
stream: true,
|
|
998
|
+
messages: [{ role: "user", content: "raw stream prompt secret" }],
|
|
999
|
+
requestId: "stream_req_1"
|
|
1000
|
+
})
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
expect(response.ok).toBe(true);
|
|
1004
|
+
expect(response.headers.get("content-type")).toContain("text/event-stream");
|
|
1005
|
+
const body = await response.text();
|
|
1006
|
+
expect(body).toContain("chatcmpl-stream");
|
|
1007
|
+
expect(body).toContain("[DONE]");
|
|
1008
|
+
expect(sellerRequests.find((request) => request.url === "/v1/chat/completions" && request.body?.stream)?.idempotencyKey).toBe("idem-stream-preserved");
|
|
1009
|
+
|
|
1010
|
+
const inferences = await (await fetch(`http://127.0.0.1:${daemonControlPort}/ledger/inferences`)).json() as any;
|
|
1011
|
+
expect(inferences.inferences).toEqual(expect.arrayContaining([
|
|
1012
|
+
expect.objectContaining({ endpoint: "/v1/chat/completions", requestId: "stream_req_1" })
|
|
1013
|
+
]));
|
|
1014
|
+
const publicOutput = JSON.stringify(inferences);
|
|
1015
|
+
expect(publicOutput).not.toContain("raw stream prompt secret");
|
|
1016
|
+
expect(publicOutput).not.toContain("chatcmpl-stream");
|
|
1017
|
+
});
|
|
1018
|
+
|
|
1019
|
+
test("fails closed when no compatible seller can serve the requested model", async () => {
|
|
1020
|
+
const response = await fetch(`http://127.0.0.1:${daemonProxyPort}/v1/chat/completions`, {
|
|
1021
|
+
method: "POST",
|
|
1022
|
+
headers: { "Content-Type": "application/json" },
|
|
1023
|
+
body: JSON.stringify({
|
|
1024
|
+
model: "missing-model",
|
|
1025
|
+
messages: [{ role: "user", content: "hello" }]
|
|
1026
|
+
})
|
|
1027
|
+
});
|
|
1028
|
+
expect(response.status).toBe(502);
|
|
1029
|
+
const data = await response.json() as any;
|
|
1030
|
+
expect(data.error.message).toContain("no compatible seller");
|
|
1031
|
+
});
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
describe("TokenBuddy manual routing mode", () => {
|
|
1035
|
+
let server: http.Server;
|
|
1036
|
+
let sellerPort: number;
|
|
1037
|
+
let daemon: TokenbuddyDaemon;
|
|
1038
|
+
let daemonProxyPort: number;
|
|
1039
|
+
let daemonControlPort: number;
|
|
1040
|
+
const events: Array<{ seller: string; url?: string }> = [];
|
|
1041
|
+
const dbPath = path.resolve(__dirname, "../../data-test/manual-routing-test.db");
|
|
1042
|
+
|
|
1043
|
+
const readJsonBody = (req: http.IncomingMessage): Promise<any> => new Promise((resolve) => {
|
|
1044
|
+
let body = "";
|
|
1045
|
+
req.on("data", (chunk) => {
|
|
1046
|
+
body += chunk.toString();
|
|
1047
|
+
});
|
|
1048
|
+
req.on("end", () => {
|
|
1049
|
+
resolve(body ? JSON.parse(body) : {});
|
|
1050
|
+
});
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
beforeAll((done) => {
|
|
1054
|
+
server = http.createServer(async (req, res) => {
|
|
1055
|
+
res.setHeader("Content-Type", "application/json");
|
|
1056
|
+
if (req.url === "/registry/sellers") {
|
|
1057
|
+
res.end(JSON.stringify({
|
|
1058
|
+
version: 1,
|
|
1059
|
+
defaultSeller: "primary-seller",
|
|
1060
|
+
sellers: [
|
|
1061
|
+
{
|
|
1062
|
+
id: "primary-seller",
|
|
1063
|
+
name: "Primary Seller",
|
|
1064
|
+
url: `http://127.0.0.1:${sellerPort}/primary`,
|
|
1065
|
+
supportedProtocols: ["chat_completions"],
|
|
1066
|
+
paymentMethods: ["mock"]
|
|
1067
|
+
},
|
|
1068
|
+
{
|
|
1069
|
+
id: "backup-seller",
|
|
1070
|
+
name: "Backup Seller",
|
|
1071
|
+
url: `http://127.0.0.1:${sellerPort}/backup`,
|
|
1072
|
+
supportedProtocols: ["chat_completions"],
|
|
1073
|
+
paymentMethods: ["mock"]
|
|
1074
|
+
}
|
|
1075
|
+
]
|
|
1076
|
+
}));
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
if (req.url === "/primary/manifest") {
|
|
1081
|
+
events.push({ seller: "primary-seller", url: req.url });
|
|
1082
|
+
res.end(JSON.stringify({
|
|
1083
|
+
sellerId: "primary-seller",
|
|
1084
|
+
supportedProtocols: ["chat_completions"],
|
|
1085
|
+
paymentMethods: ["mock"],
|
|
1086
|
+
models: [{ id: "gpt-manual" }]
|
|
1087
|
+
}));
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
if (req.url === "/backup/manifest") {
|
|
1092
|
+
events.push({ seller: "backup-seller", url: req.url });
|
|
1093
|
+
res.end(JSON.stringify({
|
|
1094
|
+
sellerId: "backup-seller",
|
|
1095
|
+
supportedProtocols: ["chat_completions"],
|
|
1096
|
+
paymentMethods: ["mock"],
|
|
1097
|
+
models: [{ id: "gpt-manual" }]
|
|
1098
|
+
}));
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
const body = await readJsonBody(req);
|
|
1103
|
+
if (req.url === "/primary/purchase/create") {
|
|
1104
|
+
expect(body.paymentMethod).toBe("mock");
|
|
1105
|
+
events.push({ seller: "primary-seller", url: req.url });
|
|
1106
|
+
res.statusCode = 503;
|
|
1107
|
+
res.end(JSON.stringify({ error: { code: "seller_unavailable" } }));
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
if (req.url?.startsWith("/backup/")) {
|
|
1112
|
+
events.push({ seller: "backup-seller", url: req.url });
|
|
1113
|
+
res.end(JSON.stringify({ id: "backup-should-not-run" }));
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
res.statusCode = 404;
|
|
1118
|
+
res.end(JSON.stringify({ error: "not_found" }));
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
server.listen(0, "127.0.0.1", () => {
|
|
1122
|
+
sellerPort = (server.address() as AddressInfo).port;
|
|
1123
|
+
done();
|
|
1124
|
+
});
|
|
1125
|
+
});
|
|
1126
|
+
|
|
1127
|
+
afterAll((done) => {
|
|
1128
|
+
server.close(done);
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
beforeEach(() => {
|
|
1132
|
+
events.length = 0;
|
|
1133
|
+
rmSqliteFiles(dbPath);
|
|
1134
|
+
const store = new BuyerStore({ dbPath });
|
|
1135
|
+
store.savePayment({
|
|
1136
|
+
method: "mock",
|
|
1137
|
+
enabled: true,
|
|
1138
|
+
isDefault: true,
|
|
1139
|
+
config: { channel: "manual-routing-test" }
|
|
1140
|
+
});
|
|
1141
|
+
store.close();
|
|
1142
|
+
|
|
1143
|
+
daemon = new TokenbuddyDaemon({
|
|
1144
|
+
controlPort: 0,
|
|
1145
|
+
proxyPort: 0,
|
|
1146
|
+
dbPath,
|
|
1147
|
+
sellerRegistryUrl: `http://127.0.0.1:${sellerPort}/registry/sellers`,
|
|
1148
|
+
selectionMode: "manual"
|
|
1149
|
+
});
|
|
1150
|
+
daemon.start();
|
|
1151
|
+
daemonControlPort = ((daemon as any).controlServer.address() as AddressInfo).port;
|
|
1152
|
+
daemonProxyPort = ((daemon as any).proxyServer.address() as AddressInfo).port;
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
afterEach(() => {
|
|
1156
|
+
daemon.stop();
|
|
1157
|
+
rmSqliteFiles(dbPath);
|
|
1158
|
+
});
|
|
1159
|
+
|
|
1160
|
+
test("uses only the default seller and does not prewarm or fail over to backup", async () => {
|
|
1161
|
+
const status = await (await fetch(`http://127.0.0.1:${daemonControlPort}/status`)).json() as any;
|
|
1162
|
+
expect(status.selectionMode).toBe("manual");
|
|
1163
|
+
|
|
1164
|
+
const response = await fetch(`http://127.0.0.1:${daemonProxyPort}/v1/chat/completions`, {
|
|
1165
|
+
method: "POST",
|
|
1166
|
+
headers: { "Content-Type": "application/json" },
|
|
1167
|
+
body: JSON.stringify({
|
|
1168
|
+
model: "gpt-manual",
|
|
1169
|
+
messages: [{ role: "user", content: "manual mode should not fail over" }]
|
|
1170
|
+
})
|
|
1171
|
+
});
|
|
1172
|
+
|
|
1173
|
+
expect(response.status).toBe(502);
|
|
1174
|
+
const output = await response.json() as any;
|
|
1175
|
+
expect(output.error.message).toContain("purchase/create failed");
|
|
1176
|
+
expect(events).toEqual([
|
|
1177
|
+
{ seller: "primary-seller", url: "/primary/manifest" },
|
|
1178
|
+
{ seller: "primary-seller", url: "/primary/purchase/create" }
|
|
1179
|
+
]);
|
|
1180
|
+
|
|
1181
|
+
const purchases = await (await fetch(`http://127.0.0.1:${daemonControlPort}/ledger/purchases`)).json() as any;
|
|
1182
|
+
const inferences = await (await fetch(`http://127.0.0.1:${daemonControlPort}/ledger/inferences`)).json() as any;
|
|
1183
|
+
expect(purchases.purchases.some((entry: any) => entry.sellerKey === "backup-seller")).toBe(false);
|
|
1184
|
+
expect(inferences.inferences.some((entry: any) => entry.sellerKey === "backup-seller")).toBe(false);
|
|
1185
|
+
});
|
|
1186
|
+
});
|