@tokenbuddy/tokenbuddy 1.0.13 → 1.0.15
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 +23 -0
- package/dist/src/buyer-store.d.ts.map +1 -1
- package/dist/src/buyer-store.js +31 -6
- package/dist/src/buyer-store.js.map +1 -1
- package/dist/src/clawtip-bootstrap.d.ts +23 -0
- package/dist/src/clawtip-bootstrap.d.ts.map +1 -0
- package/dist/src/clawtip-bootstrap.js +47 -0
- package/dist/src/clawtip-bootstrap.js.map +1 -0
- package/dist/src/cli.d.ts +24 -33
- package/dist/src/cli.d.ts.map +1 -1
- package/dist/src/cli.js +157 -58
- package/dist/src/cli.js.map +1 -1
- package/dist/src/daemon.d.ts +79 -1
- package/dist/src/daemon.d.ts.map +1 -1
- package/dist/src/daemon.js +984 -23
- package/dist/src/daemon.js.map +1 -1
- package/dist/src/model-index.d.ts +1 -1
- package/dist/src/model-index.d.ts.map +1 -1
- package/dist/src/model-index.js +4 -0
- package/dist/src/model-index.js.map +1 -1
- package/dist/src/prewarm-cache.d.ts +4 -0
- package/dist/src/prewarm-cache.d.ts.map +1 -1
- package/dist/src/prewarm-cache.js +2 -1
- package/dist/src/prewarm-cache.js.map +1 -1
- package/dist/src/prewarm-scheduler.d.ts +2 -0
- package/dist/src/prewarm-scheduler.d.ts.map +1 -1
- package/dist/src/prewarm-scheduler.js +4 -2
- package/dist/src/prewarm-scheduler.js.map +1 -1
- package/dist/src/route-failover.d.ts.map +1 -1
- package/dist/src/route-failover.js +10 -0
- package/dist/src/route-failover.js.map +1 -1
- package/dist/src/seller-catalog.d.ts +17 -0
- package/dist/src/seller-catalog.d.ts.map +1 -1
- package/dist/src/seller-catalog.js +15 -1
- package/dist/src/seller-catalog.js.map +1 -1
- package/dist/src/seller-pool.d.ts +12 -1
- package/dist/src/seller-pool.d.ts.map +1 -1
- package/dist/src/seller-pool.js +61 -7
- package/dist/src/seller-pool.js.map +1 -1
- package/dist/src/seller-route-planner.d.ts +11 -1
- package/dist/src/seller-route-planner.d.ts.map +1 -1
- package/dist/src/seller-route-planner.js +21 -9
- package/dist/src/seller-route-planner.js.map +1 -1
- package/dist/src/seller-routing-config.d.ts +2 -0
- package/dist/src/seller-routing-config.d.ts.map +1 -1
- package/dist/src/seller-routing-config.js +11 -1
- package/dist/src/seller-routing-config.js.map +1 -1
- package/package.json +1 -1
- package/src/buyer-store.ts +70 -7
- package/src/clawtip-bootstrap.ts +64 -0
- package/src/cli.ts +201 -76
- package/src/daemon.ts +1132 -25
- package/src/model-index.ts +4 -1
- package/src/prewarm-cache.ts +6 -1
- package/src/prewarm-scheduler.ts +6 -2
- package/src/route-failover.ts +11 -0
- package/src/seller-catalog.ts +24 -1
- package/src/seller-pool.ts +69 -7
- package/src/seller-route-planner.ts +33 -11
- package/src/seller-routing-config.ts +14 -1
- package/static/clawtip/recharge.png +0 -0
- package/tests/control-plane-ui-endpoints.test.ts +559 -0
- package/tests/daemon-classify.test.ts +9 -0
- package/tests/model-index.test.ts +14 -0
- package/tests/route-failover.test.ts +16 -0
- package/tests/seller-catalog-utilities.test.ts +54 -0
- package/tests/seller-pool.test.ts +56 -0
- package/tests/seller-route-planner.test.ts +40 -0
- package/tests/seller-routing-config.test.ts +13 -0
- package/tests/tokenbuddy.test.ts +200 -7
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
import * as http from "http";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import { AddressInfo } from "net";
|
|
5
|
+
import { TokenbuddyDaemon, type DaemonConfig } from "../src/daemon.js";
|
|
6
|
+
import type { SellerRegistryDocument } from "../src/seller-catalog.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* tb-ui v1 (PR-0) 控制平面写端点集成测试。
|
|
10
|
+
*
|
|
11
|
+
* 覆盖:
|
|
12
|
+
* - GET /routing/strategy 默认 + store 写入 + 来源标记
|
|
13
|
+
* - GET /routing/preview 合法 query + 非法 mode + 空 registry
|
|
14
|
+
* - PUT /routing/strategy fixed 缺 sellerId / 非法 mode / 合法 fullAuto + 热更新验证
|
|
15
|
+
* - PUT /prewarm/focus-set 设置 + clear + 热更新触达
|
|
16
|
+
*
|
|
17
|
+
* 跳过:
|
|
18
|
+
* - POST /daemon/restart spawn() monkey-patch 在 ESM binding 下不稳,留到 PR-1
|
|
19
|
+
* 浏览器手动验证(`tb ui` 页面点 Restart 按钮)。
|
|
20
|
+
*/
|
|
21
|
+
describe("TokenbuddyDaemon control-plane UI endpoints (PR-0)", () => {
|
|
22
|
+
const TEMP_DB = path.resolve(__dirname, "../../data-test/ui-endpoints-test.db");
|
|
23
|
+
const TEMP_HOME = path.resolve(__dirname, "../../data-test/ui-endpoints-home");
|
|
24
|
+
const TEST_REGISTRY: SellerRegistryDocument = {
|
|
25
|
+
version: 1,
|
|
26
|
+
defaultSeller: "tbs-86d81e",
|
|
27
|
+
sellers: [
|
|
28
|
+
{
|
|
29
|
+
id: "tbs-86d81e",
|
|
30
|
+
url: "https://tbs-86d81e.example.com",
|
|
31
|
+
supportedProtocols: ["chat_completions", "messages"],
|
|
32
|
+
paymentMethods: ["clawtip", "mock"],
|
|
33
|
+
models: ["claude-sonnet-4-5", "gpt-4o"]
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: "tbs-719577",
|
|
37
|
+
url: "https://tbs-719577.example.com",
|
|
38
|
+
supportedProtocols: ["chat_completions"],
|
|
39
|
+
paymentMethods: ["clawtip"],
|
|
40
|
+
models: ["claude-sonnet-4-5"]
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: "tbs-825edb",
|
|
44
|
+
url: "https://tbs-825edb.example.com",
|
|
45
|
+
supportedProtocols: ["chat_completions"],
|
|
46
|
+
paymentMethods: ["clawtip", "mock"],
|
|
47
|
+
models: ["gpt-4o"]
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
let daemon: TokenbuddyDaemon;
|
|
53
|
+
let controlPort: number;
|
|
54
|
+
let proxyPort: number;
|
|
55
|
+
|
|
56
|
+
function rmDb(): void {
|
|
57
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
58
|
+
const file = TEMP_DB + suffix;
|
|
59
|
+
if (fs.existsSync(file)) fs.unlinkSync(file);
|
|
60
|
+
}
|
|
61
|
+
fs.rmSync(TEMP_HOME, { recursive: true, force: true });
|
|
62
|
+
fs.rmSync(path.join(path.dirname(TEMP_DB), "static"), { recursive: true, force: true });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function startDaemon(overrides: Partial<DaemonConfig> = {}): Promise<void> {
|
|
66
|
+
rmDb();
|
|
67
|
+
daemon = new TokenbuddyDaemon({
|
|
68
|
+
controlPort: 0,
|
|
69
|
+
proxyPort: 0,
|
|
70
|
+
dbPath: TEMP_DB,
|
|
71
|
+
sellerRegistryUrl: "http://127.0.0.1:1/registry/sellers", // 不会真正被触发
|
|
72
|
+
clawtipHomeDir: TEMP_HOME,
|
|
73
|
+
...overrides
|
|
74
|
+
});
|
|
75
|
+
daemon.start();
|
|
76
|
+
// 注入 test-only registry snapshot,避开网络拉取
|
|
77
|
+
daemon.setLastRegistrySnapshotForTest(TEST_REGISTRY);
|
|
78
|
+
const controlServer = (daemon as unknown as { controlServer: { address(): AddressInfo } }).controlServer;
|
|
79
|
+
const proxyServer = (daemon as unknown as { proxyServer: { address(): AddressInfo } }).proxyServer;
|
|
80
|
+
controlPort = controlServer.address().port;
|
|
81
|
+
proxyPort = proxyServer.address().port;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function controlUrl(path: string): string {
|
|
85
|
+
return `http://127.0.0.1:${controlPort}${path}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
beforeEach(async () => {
|
|
89
|
+
await startDaemon();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
afterEach(async () => {
|
|
93
|
+
daemon.stop();
|
|
94
|
+
// Drain any in-flight prewarm scheduler work to avoid jest open-handle
|
|
95
|
+
// warnings. The daemon's stop() is fire-and-forget.
|
|
96
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 50));
|
|
97
|
+
rmDb();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// ─── ClawTip payment QR endpoints ─────────────────────────────
|
|
101
|
+
describe("ClawTip payment QR endpoints", () => {
|
|
102
|
+
it("reflects a renamed wallet config as unbound in /payments", async () => {
|
|
103
|
+
const store = (daemon as unknown as { tokenStore: { savePayment(input: unknown): void } }).tokenStore;
|
|
104
|
+
const configsDir = path.join(TEMP_HOME, ".openclaw", "configs");
|
|
105
|
+
fs.mkdirSync(configsDir, { recursive: true });
|
|
106
|
+
fs.writeFileSync(path.join(configsDir, "config.json.bak"), "{}", "utf8");
|
|
107
|
+
store.savePayment({
|
|
108
|
+
method: "clawtip",
|
|
109
|
+
enabled: true,
|
|
110
|
+
isDefault: true,
|
|
111
|
+
config: {
|
|
112
|
+
resourceUrl: "https://tb-wallet-bootstrap.example.test",
|
|
113
|
+
walletConfigPresent: true
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const res = await fetch(controlUrl("/payments"));
|
|
118
|
+
expect(res.status).toBe(200);
|
|
119
|
+
const body = await res.json() as { payments: Array<{ method: string; enabled: boolean; config: Record<string, unknown> }> };
|
|
120
|
+
const clawtip = body.payments.find((payment) => payment.method === "clawtip");
|
|
121
|
+
expect(clawtip?.enabled).toBe(false);
|
|
122
|
+
expect(clawtip?.config.walletConfigPresent).toBe(false);
|
|
123
|
+
expect(clawtip?.config.nearbyWalletConfigPaths).toEqual([path.join(configsDir, "config.json.bak")]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("reuses the tb init ClawTip flow and exposes the returned QR image as static UI asset", async () => {
|
|
127
|
+
daemon.stop();
|
|
128
|
+
const qrSource = path.join(TEMP_HOME, "source-qrcode.png");
|
|
129
|
+
await startDaemon({
|
|
130
|
+
clawtipBootstrapFetcher: async () => ({
|
|
131
|
+
activationFeeFen: 1,
|
|
132
|
+
payment: {
|
|
133
|
+
orderNo: "order_ui_qr",
|
|
134
|
+
amountFen: 1,
|
|
135
|
+
payTo: "real-pay-to",
|
|
136
|
+
encryptedData: "ciphertext",
|
|
137
|
+
indicator: "indicator_ui_qr",
|
|
138
|
+
slug: "tb-wallet-bootstrap",
|
|
139
|
+
skillId: "si-tb-wallet-bootstrap",
|
|
140
|
+
description: "TokenBuddy ClawTip wallet activation",
|
|
141
|
+
resourceUrl: "https://tb-wallet-bootstrap.example.test"
|
|
142
|
+
}
|
|
143
|
+
}),
|
|
144
|
+
clawtipWalletBootstrapStarter: async (payment) => ({
|
|
145
|
+
orderFile: path.join(TEMP_HOME, ".openclaw", "skills", "orders", payment.indicator, `${payment.orderNo}.json`),
|
|
146
|
+
parsedOutput: {
|
|
147
|
+
mediaPath: qrSource,
|
|
148
|
+
authUrl: "https://idt.jd.com/unifiedAuthM/plat/safeMonitor/?authFlowUid=abc",
|
|
149
|
+
clawtipId: "device-ui",
|
|
150
|
+
requiresWalletAuth: true,
|
|
151
|
+
walletReady: false
|
|
152
|
+
}
|
|
153
|
+
}),
|
|
154
|
+
clawtipActivationWaiter: async () => false
|
|
155
|
+
});
|
|
156
|
+
fs.mkdirSync(path.dirname(qrSource), { recursive: true });
|
|
157
|
+
fs.writeFileSync(qrSource, "png-bytes", "utf8");
|
|
158
|
+
|
|
159
|
+
const res = await fetch(controlUrl("/payments/clawtip/activate"), { method: "POST" });
|
|
160
|
+
expect(res.status).toBe(200);
|
|
161
|
+
const body = await res.json() as { qrImageUrl: string; sourceImagePath: string; orderNo: string };
|
|
162
|
+
expect(body.orderNo).toBe("order_ui_qr");
|
|
163
|
+
expect(body.sourceImagePath).toBe(qrSource);
|
|
164
|
+
expect(body.qrImageUrl).toMatch(/^\/static\/clawtip\/order_ui_qr-/);
|
|
165
|
+
|
|
166
|
+
const imageRes = await fetch(controlUrl(body.qrImageUrl));
|
|
167
|
+
expect(imageRes.status).toBe(200);
|
|
168
|
+
expect(await imageRes.text()).toBe("png-bytes");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("updates ClawTip payment readiness after the activation wait detects wallet config", async () => {
|
|
172
|
+
daemon.stop();
|
|
173
|
+
const qrSource = path.join(TEMP_HOME, "source-qrcode.png");
|
|
174
|
+
const configsDir = path.join(TEMP_HOME, ".openclaw", "configs");
|
|
175
|
+
await startDaemon({
|
|
176
|
+
clawtipBootstrapFetcher: async () => ({
|
|
177
|
+
activationFeeFen: 1,
|
|
178
|
+
payment: {
|
|
179
|
+
orderNo: "order_wait_qr",
|
|
180
|
+
amountFen: 1,
|
|
181
|
+
payTo: "real-pay-to",
|
|
182
|
+
encryptedData: "ciphertext",
|
|
183
|
+
indicator: "indicator_wait_qr",
|
|
184
|
+
resourceUrl: "https://tb-wallet-bootstrap.example.test"
|
|
185
|
+
}
|
|
186
|
+
}),
|
|
187
|
+
clawtipWalletBootstrapStarter: async (payment) => ({
|
|
188
|
+
orderFile: path.join(TEMP_HOME, ".openclaw", "skills", "orders", payment.indicator, `${payment.orderNo}.json`),
|
|
189
|
+
parsedOutput: {
|
|
190
|
+
mediaPath: qrSource,
|
|
191
|
+
clawtipId: "device-wait",
|
|
192
|
+
requiresWalletAuth: true,
|
|
193
|
+
walletReady: false
|
|
194
|
+
}
|
|
195
|
+
}),
|
|
196
|
+
clawtipActivationWaiter: async () => {
|
|
197
|
+
fs.mkdirSync(configsDir, { recursive: true });
|
|
198
|
+
fs.writeFileSync(path.join(configsDir, "config.json"), "{}", "utf8");
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
fs.mkdirSync(path.dirname(qrSource), { recursive: true });
|
|
203
|
+
fs.writeFileSync(qrSource, "png-bytes", "utf8");
|
|
204
|
+
|
|
205
|
+
const activateRes = await fetch(controlUrl("/payments/clawtip/activate"), { method: "POST" });
|
|
206
|
+
expect(activateRes.status).toBe(200);
|
|
207
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 10));
|
|
208
|
+
|
|
209
|
+
const paymentsRes = await fetch(controlUrl("/payments"));
|
|
210
|
+
expect(paymentsRes.status).toBe(200);
|
|
211
|
+
const body = await paymentsRes.json() as { payments: Array<{ method: string; enabled: boolean; config: Record<string, unknown> }> };
|
|
212
|
+
const clawtip = body.payments.find((payment) => payment.method === "clawtip");
|
|
213
|
+
expect(clawtip?.enabled).toBe(true);
|
|
214
|
+
expect(clawtip?.config.walletConfigPresent).toBe(true);
|
|
215
|
+
expect(clawtip?.config.activationCompletedBy).toBe("wallet-config");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("fails activation when the reused ClawTip flow does not return a QR media file", async () => {
|
|
219
|
+
daemon.stop();
|
|
220
|
+
await startDaemon({
|
|
221
|
+
clawtipBootstrapFetcher: async () => ({
|
|
222
|
+
activationFeeFen: 1,
|
|
223
|
+
payment: {
|
|
224
|
+
orderNo: "order_no_media",
|
|
225
|
+
amountFen: 1,
|
|
226
|
+
payTo: "real-pay-to",
|
|
227
|
+
encryptedData: "ciphertext",
|
|
228
|
+
indicator: "indicator_no_media",
|
|
229
|
+
resourceUrl: "https://tb-wallet-bootstrap.example.test"
|
|
230
|
+
}
|
|
231
|
+
}),
|
|
232
|
+
clawtipWalletBootstrapStarter: async (payment) => ({
|
|
233
|
+
orderFile: path.join(TEMP_HOME, ".openclaw", "skills", "orders", payment.indicator, `${payment.orderNo}.json`),
|
|
234
|
+
parsedOutput: {
|
|
235
|
+
authUrl: "https://clawtip.jd.com/qrcode?clawtipId=device-no-media",
|
|
236
|
+
clawtipId: "device-no-media",
|
|
237
|
+
requiresWalletAuth: true,
|
|
238
|
+
walletReady: false
|
|
239
|
+
}
|
|
240
|
+
})
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const res = await fetch(controlUrl("/payments/clawtip/activate"), { method: "POST" });
|
|
244
|
+
expect(res.status).toBe(500);
|
|
245
|
+
const body = await res.json() as { error: { code: string; message: string } };
|
|
246
|
+
expect(body.error.code).toBe("clawtip_activate_qr_failed");
|
|
247
|
+
expect(body.error.message).toContain("did not return a QR image");
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("does not reuse the activation QR when the fixed recharge asset is missing", async () => {
|
|
251
|
+
daemon.stop();
|
|
252
|
+
await startDaemon({ clawtipBundledStaticDir: false });
|
|
253
|
+
const store = (daemon as unknown as { tokenStore: { savePayment(input: unknown): void } }).tokenStore;
|
|
254
|
+
const activationQr = path.join(TEMP_HOME, "activation-qrcode.png");
|
|
255
|
+
fs.mkdirSync(path.dirname(activationQr), { recursive: true });
|
|
256
|
+
fs.writeFileSync(activationQr, "activation-png-bytes", "utf8");
|
|
257
|
+
store.savePayment({
|
|
258
|
+
method: "clawtip",
|
|
259
|
+
enabled: true,
|
|
260
|
+
isDefault: true,
|
|
261
|
+
config: {
|
|
262
|
+
orderNo: "order_activation_only",
|
|
263
|
+
activationQrImagePath: activationQr,
|
|
264
|
+
walletConfigPresent: true
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
const res = await fetch(controlUrl("/payments/clawtip/recharge"), { method: "POST" });
|
|
269
|
+
expect(res.status).toBe(500);
|
|
270
|
+
const body = await res.json() as { error: { code: string; message: string } };
|
|
271
|
+
expect(body.error.code).toBe("clawtip_recharge_qr_failed");
|
|
272
|
+
expect(body.error.message).toContain("fixed recharge QR image is missing");
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("returns the fixed ClawTip recharge static asset", async () => {
|
|
276
|
+
const store = (daemon as unknown as { tokenStore: { savePayment(input: unknown): void } }).tokenStore;
|
|
277
|
+
store.savePayment({
|
|
278
|
+
method: "clawtip",
|
|
279
|
+
enabled: true,
|
|
280
|
+
isDefault: true,
|
|
281
|
+
config: {
|
|
282
|
+
orderNo: "order_recharge_static",
|
|
283
|
+
resourceUrl: "https://tb-wallet-bootstrap.example.test",
|
|
284
|
+
walletConfigPresent: true
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
const staticDir = path.join(path.dirname(TEMP_DB), "static", "clawtip");
|
|
288
|
+
fs.mkdirSync(staticDir, { recursive: true });
|
|
289
|
+
fs.writeFileSync(path.join(staticDir, "recharge.png"), "fixed-recharge-png", "utf8");
|
|
290
|
+
|
|
291
|
+
const res = await fetch(controlUrl("/payments/clawtip/recharge"), { method: "POST" });
|
|
292
|
+
expect(res.status).toBe(200);
|
|
293
|
+
const body = await res.json() as { qrImageUrl: string; sourceImagePath: string; staticImagePath: string; orderNo: string };
|
|
294
|
+
expect(body.orderNo).toBe("order_recharge_static");
|
|
295
|
+
expect(body.qrImageUrl).toBe("/static/clawtip/recharge.png");
|
|
296
|
+
expect(body.sourceImagePath).toBe(path.join(staticDir, "recharge.png"));
|
|
297
|
+
expect(body.staticImagePath).toBe(path.join(staticDir, "recharge.png"));
|
|
298
|
+
|
|
299
|
+
const imageRes = await fetch(controlUrl(body.qrImageUrl));
|
|
300
|
+
expect(imageRes.status).toBe(200);
|
|
301
|
+
expect(await imageRes.text()).toBe("fixed-recharge-png");
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// ─── GET /providers/status ───────────────────────────────────
|
|
306
|
+
describe("GET /providers/status", () => {
|
|
307
|
+
it("returns read-only client tool status with a CLI install command", async () => {
|
|
308
|
+
daemon.stop();
|
|
309
|
+
await startDaemon({ providerHomeDir: TEMP_HOME });
|
|
310
|
+
const codexConfig = path.join(TEMP_HOME, ".codex", "config.toml");
|
|
311
|
+
fs.mkdirSync(path.dirname(codexConfig), { recursive: true });
|
|
312
|
+
fs.writeFileSync(codexConfig, "[tokenbuddy]\nproxy_url = \"http://127.0.0.1:17821\"\n", "utf8");
|
|
313
|
+
|
|
314
|
+
const res = await fetch(controlUrl("/providers/status"));
|
|
315
|
+
expect(res.status).toBe(200);
|
|
316
|
+
const body = await res.json() as {
|
|
317
|
+
clients: Array<{ id: string; status: string; configured: boolean; reason: string; manualConfig?: { openaiBaseUrl: string; anthropicBaseUrl: string; apiKey: string } }>;
|
|
318
|
+
summary: { configuredCount: number; installCommand: string };
|
|
319
|
+
};
|
|
320
|
+
const codex = body.clients.find((client) => client.id === "codex");
|
|
321
|
+
const custom = body.clients.find((client) => client.id === "custom");
|
|
322
|
+
expect(codex).toMatchObject({ status: "configured", configured: true });
|
|
323
|
+
expect(custom).toMatchObject({ status: "manual", configured: false });
|
|
324
|
+
expect(custom?.reason).toContain("http://127.0.0.1:");
|
|
325
|
+
expect(custom?.manualConfig).toEqual({
|
|
326
|
+
openaiBaseUrl: `http://127.0.0.1:${proxyPort}/v1`,
|
|
327
|
+
anthropicBaseUrl: `http://127.0.0.1:${proxyPort}`,
|
|
328
|
+
apiKey: "TOKENBUDDY_PROXY"
|
|
329
|
+
});
|
|
330
|
+
expect(body.summary.configuredCount).toBeGreaterThanOrEqual(1);
|
|
331
|
+
expect(body.summary.installCommand).toBe("tb init");
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// ─── GET /routing/strategy ────────────────────────────────────
|
|
336
|
+
describe("GET /routing/strategy", () => {
|
|
337
|
+
it("returns default strategy with source=default when no store value", async () => {
|
|
338
|
+
const res = await fetch(controlUrl("/routing/strategy"));
|
|
339
|
+
expect(res.status).toBe(200);
|
|
340
|
+
const body = await res.json() as {
|
|
341
|
+
strategy: { mode: string; scorer: string };
|
|
342
|
+
source: string;
|
|
343
|
+
};
|
|
344
|
+
expect(body.strategy.mode).toBe("fullAuto");
|
|
345
|
+
expect(body.strategy.scorer).toBe("balanced");
|
|
346
|
+
expect(body.source).toBe("default");
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("returns source=store after PUT /routing/strategy writes a value", async () => {
|
|
350
|
+
// 写入策略
|
|
351
|
+
const putRes = await fetch(controlUrl("/routing/strategy"), {
|
|
352
|
+
method: "PUT",
|
|
353
|
+
headers: { "content-type": "application/json" },
|
|
354
|
+
body: JSON.stringify({ mode: "fixedSet", scorer: "speed", sellerIds: ["tbs-86d81e", "tbs-719577"] })
|
|
355
|
+
});
|
|
356
|
+
expect(putRes.status).toBe(200);
|
|
357
|
+
// 再读
|
|
358
|
+
const getRes = await fetch(controlUrl("/routing/strategy"));
|
|
359
|
+
const body = await getRes.json() as { strategy: { mode: string; scorer: string; sellerIds?: string[] }; source: string };
|
|
360
|
+
expect(body.strategy.mode).toBe("fixedSet");
|
|
361
|
+
expect(body.strategy.scorer).toBe("speed");
|
|
362
|
+
expect(body.strategy.sellerIds).toEqual(["tbs-86d81e", "tbs-719577"]);
|
|
363
|
+
expect(body.source).toBe("store");
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// ─── GET /routing/preview ─────────────────────────────────────
|
|
368
|
+
describe("GET /routing/preview", () => {
|
|
369
|
+
it("returns a plan with the default (fullAuto, balanced) strategy", async () => {
|
|
370
|
+
const res = await fetch(controlUrl("/routing/preview?modelId=claude-sonnet-4-5"));
|
|
371
|
+
expect(res.status).toBe(200);
|
|
372
|
+
const body = await res.json() as {
|
|
373
|
+
modelId: string;
|
|
374
|
+
plan: { mode: string; scorer: string; routes: Array<{ sellerId: string }>; reason: string };
|
|
375
|
+
};
|
|
376
|
+
expect(body.modelId).toBe("claude-sonnet-4-5");
|
|
377
|
+
expect(body.plan.mode).toBe("fullAuto");
|
|
378
|
+
expect(body.plan.scorer).toBe("balanced");
|
|
379
|
+
expect(body.plan.routes.length).toBeGreaterThan(0);
|
|
380
|
+
expect(body.plan.reason).toMatch(/^fullAuto:balanced:routes_\d+$/);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it("resolves fixedByModel for the requested model", async () => {
|
|
384
|
+
const res = await fetch(controlUrl("/routing/preview?mode=fixed&scorer=speed&fixedByModel=claude-sonnet-4-5:tbs-719577,gpt-4o:tbs-825edb&modelId=gpt-4o"));
|
|
385
|
+
expect(res.status).toBe(200);
|
|
386
|
+
const body = await res.json() as {
|
|
387
|
+
plan: { mode: string; scorer: string; routes: Array<{ seller: { id: string } }>; reason: string };
|
|
388
|
+
};
|
|
389
|
+
expect(body.plan.mode).toBe("fixed");
|
|
390
|
+
expect(body.plan.scorer).toBe("speed");
|
|
391
|
+
expect(body.plan.routes.map((route) => route.seller.id)).toEqual(["tbs-825edb"]);
|
|
392
|
+
expect(body.plan.reason).toBe("fixed:speed:routes_1");
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it("rejects an invalid mode in the query string", async () => {
|
|
396
|
+
const res = await fetch(controlUrl("/routing/preview?mode=invalid"));
|
|
397
|
+
expect(res.status).toBe(400);
|
|
398
|
+
const body = await res.json() as { error: { code: string } };
|
|
399
|
+
expect(body.error.code).toBe("routing_preview_failed");
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it("returns 409 with registry_not_loaded when snapshot is cleared", async () => {
|
|
403
|
+
daemon.setLastRegistrySnapshotForTest(null);
|
|
404
|
+
const res = await fetch(controlUrl("/routing/preview?modelId=claude-sonnet-4-5"));
|
|
405
|
+
expect(res.status).toBe(409);
|
|
406
|
+
const body = await res.json() as { error: { code: string } };
|
|
407
|
+
expect(body.error.code).toBe("registry_not_loaded");
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
// ─── PUT /routing/strategy ────────────────────────────────────
|
|
412
|
+
describe("PUT /routing/strategy", () => {
|
|
413
|
+
it("rejects fixed mode without sellerId", async () => {
|
|
414
|
+
const res = await fetch(controlUrl("/routing/strategy"), {
|
|
415
|
+
method: "PUT",
|
|
416
|
+
headers: { "content-type": "application/json" },
|
|
417
|
+
body: JSON.stringify({ mode: "fixed", scorer: "balanced" })
|
|
418
|
+
});
|
|
419
|
+
expect(res.status).toBe(400);
|
|
420
|
+
const body = await res.json() as { error: { code: string; message: string } };
|
|
421
|
+
expect(body.error.code).toBe("routing_strategy_apply_failed");
|
|
422
|
+
expect(body.error.message).toMatch(/fixed/);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it("rejects an invalid mode value", async () => {
|
|
426
|
+
const res = await fetch(controlUrl("/routing/strategy"), {
|
|
427
|
+
method: "PUT",
|
|
428
|
+
headers: { "content-type": "application/json" },
|
|
429
|
+
body: JSON.stringify({ mode: "auto", scorer: "balanced" })
|
|
430
|
+
});
|
|
431
|
+
expect(res.status).toBe(400);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it("applies a valid fullAuto strategy and returns the preview", async () => {
|
|
435
|
+
const res = await fetch(controlUrl("/routing/strategy"), {
|
|
436
|
+
method: "PUT",
|
|
437
|
+
headers: { "content-type": "application/json" },
|
|
438
|
+
body: JSON.stringify({ mode: "fullAuto", scorer: "discount" })
|
|
439
|
+
});
|
|
440
|
+
expect(res.status).toBe(200);
|
|
441
|
+
const body = await res.json() as Record<string, unknown>;
|
|
442
|
+
const body2 = body as {
|
|
443
|
+
applied: boolean;
|
|
444
|
+
strategy: { mode: string; scorer: string };
|
|
445
|
+
preview: { modelId?: string; mode?: string; scorer?: string; routes?: unknown[]; error?: string };
|
|
446
|
+
};
|
|
447
|
+
expect(body2.applied).toBe(true);
|
|
448
|
+
expect(body2.strategy.mode).toBe("fullAuto");
|
|
449
|
+
expect(body2.strategy.scorer).toBe("discount");
|
|
450
|
+
expect(body2.preview.mode).toBe("fullAuto");
|
|
451
|
+
expect(body2.preview.scorer).toBe("discount");
|
|
452
|
+
expect((body2.preview.routes ?? []).length).toBeGreaterThan(0);
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it("persists applied strategy across GET requests (hot reload)", async () => {
|
|
456
|
+
await fetch(controlUrl("/routing/strategy"), {
|
|
457
|
+
method: "PUT",
|
|
458
|
+
headers: { "content-type": "application/json" },
|
|
459
|
+
body: JSON.stringify({ mode: "fixed", scorer: "balanced", sellerId: "tbs-86d81e" })
|
|
460
|
+
});
|
|
461
|
+
// 再读,daemon 内部 routing 应当是 fixed
|
|
462
|
+
const res = await fetch(controlUrl("/routing/strategy"));
|
|
463
|
+
const body = await res.json() as { strategy: { mode: string; sellerId?: string } };
|
|
464
|
+
expect(body.strategy.mode).toBe("fixed");
|
|
465
|
+
expect(body.strategy.sellerId).toBe("tbs-86d81e");
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it("persists fixed sellers per model and previews the matching seller", async () => {
|
|
469
|
+
const putRes = await fetch(controlUrl("/routing/strategy"), {
|
|
470
|
+
method: "PUT",
|
|
471
|
+
headers: { "content-type": "application/json" },
|
|
472
|
+
body: JSON.stringify({
|
|
473
|
+
mode: "fixed",
|
|
474
|
+
scorer: "speed",
|
|
475
|
+
fixedByModel: {
|
|
476
|
+
"claude-sonnet-4-5": "tbs-719577",
|
|
477
|
+
"gpt-4o": "tbs-825edb"
|
|
478
|
+
}
|
|
479
|
+
})
|
|
480
|
+
});
|
|
481
|
+
expect(putRes.status).toBe(200);
|
|
482
|
+
|
|
483
|
+
const getRes = await fetch(controlUrl("/routing/strategy"));
|
|
484
|
+
const strategyBody = await getRes.json() as {
|
|
485
|
+
strategy: { mode: string; fixedByModel?: Record<string, string> };
|
|
486
|
+
};
|
|
487
|
+
expect(strategyBody.strategy.mode).toBe("fixed");
|
|
488
|
+
expect(strategyBody.strategy.fixedByModel).toEqual({
|
|
489
|
+
"claude-sonnet-4-5": "tbs-719577",
|
|
490
|
+
"gpt-4o": "tbs-825edb"
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
const previewRes = await fetch(controlUrl("/routing/preview?modelId=claude-sonnet-4-5"));
|
|
494
|
+
expect(previewRes.status).toBe(200);
|
|
495
|
+
const previewBody = await previewRes.json() as {
|
|
496
|
+
plan: { routes: Array<{ seller: { id: string } }>; reason: string };
|
|
497
|
+
};
|
|
498
|
+
expect(previewBody.plan.routes.map((route) => route.seller.id)).toEqual(["tbs-719577"]);
|
|
499
|
+
expect(previewBody.plan.reason).toBe("fixed:speed:routes_1");
|
|
500
|
+
});
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
// ─── PUT /prewarm/focus-set ───────────────────────────────────
|
|
504
|
+
describe("PUT /prewarm/focus-set", () => {
|
|
505
|
+
it("sets an explicit focus set and reports source=explicit", async () => {
|
|
506
|
+
const res = await fetch(controlUrl("/prewarm/focus-set"), {
|
|
507
|
+
method: "PUT",
|
|
508
|
+
headers: { "content-type": "application/json" },
|
|
509
|
+
body: JSON.stringify({ models: ["claude-sonnet-4-5", "gpt-4o"] })
|
|
510
|
+
});
|
|
511
|
+
expect(res.status).toBe(200);
|
|
512
|
+
const body = await res.json() as { ok: boolean; focusSet: string[]; source: string };
|
|
513
|
+
expect(body.ok).toBe(true);
|
|
514
|
+
expect(body.focusSet).toEqual(["claude-sonnet-4-5", "gpt-4o"]);
|
|
515
|
+
expect(body.source).toBe("explicit");
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it("dedupes and trims the models array", async () => {
|
|
519
|
+
const res = await fetch(controlUrl("/prewarm/focus-set"), {
|
|
520
|
+
method: "PUT",
|
|
521
|
+
headers: { "content-type": "application/json" },
|
|
522
|
+
body: JSON.stringify({ models: [" gpt-4o ", "gpt-4o", "claude-sonnet-4-5", ""] })
|
|
523
|
+
});
|
|
524
|
+
expect(res.status).toBe(200);
|
|
525
|
+
const body = await res.json() as { focusSet: string[] };
|
|
526
|
+
expect(body.focusSet).toEqual(["gpt-4o", "claude-sonnet-4-5"]);
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
it("rejects a body without models and without clear flag", async () => {
|
|
530
|
+
const res = await fetch(controlUrl("/prewarm/focus-set"), {
|
|
531
|
+
method: "PUT",
|
|
532
|
+
headers: { "content-type": "application/json" },
|
|
533
|
+
body: JSON.stringify({})
|
|
534
|
+
});
|
|
535
|
+
expect(res.status).toBe(400);
|
|
536
|
+
const body = await res.json() as { error: { code: string } };
|
|
537
|
+
expect(body.error.code).toBe("invalid_focus_set");
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it("clear=true falls back to env/historical (not explicit)", async () => {
|
|
541
|
+
// 先设置一个 explicit
|
|
542
|
+
await fetch(controlUrl("/prewarm/focus-set"), {
|
|
543
|
+
method: "PUT",
|
|
544
|
+
headers: { "content-type": "application/json" },
|
|
545
|
+
body: JSON.stringify({ models: ["gpt-4o"] })
|
|
546
|
+
});
|
|
547
|
+
// clear
|
|
548
|
+
const res = await fetch(controlUrl("/prewarm/focus-set"), {
|
|
549
|
+
method: "PUT",
|
|
550
|
+
headers: { "content-type": "application/json" },
|
|
551
|
+
body: JSON.stringify({ clear: true })
|
|
552
|
+
});
|
|
553
|
+
expect(res.status).toBe(200);
|
|
554
|
+
const body = await res.json() as { ok: boolean; source: string };
|
|
555
|
+
expect(body.ok).toBe(true);
|
|
556
|
+
expect(body.source).not.toBe("explicit");
|
|
557
|
+
});
|
|
558
|
+
});
|
|
559
|
+
});
|
|
@@ -41,6 +41,15 @@ describe("TokenbuddyDaemon classifyFailureStatus", () => {
|
|
|
41
41
|
expect((daemon as any).classifyFailureStatus(503)).toBe("soft_5xx");
|
|
42
42
|
});
|
|
43
43
|
|
|
44
|
+
test("classifies seller capacity exhaustion separately from generic 429", () => {
|
|
45
|
+
expect((daemon as any).classifyFailureStatus(429, JSON.stringify({
|
|
46
|
+
error: { code: "busy_capacity" }
|
|
47
|
+
}))).toBe("busy_capacity");
|
|
48
|
+
expect((daemon as any).classifyFailureStatus(429, JSON.stringify({
|
|
49
|
+
error: { code: "rate_limited" }
|
|
50
|
+
}))).toBe("soft_5xx");
|
|
51
|
+
});
|
|
52
|
+
|
|
44
53
|
test("recognizes supported proxy endpoint protocols", () => {
|
|
45
54
|
expect((daemon as any).endpointProtocol("/v1/chat/completions")).toBe("chat_completions");
|
|
46
55
|
expect((daemon as any).endpointProtocol("/v1/responses")).toBe("responses");
|
|
@@ -5,6 +5,7 @@ function makeSeller(overrides: Partial<RegistrySeller> & { id: string; models?:
|
|
|
5
5
|
return {
|
|
6
6
|
id: overrides.id,
|
|
7
7
|
name: overrides.name ?? overrides.id,
|
|
8
|
+
status: overrides.status,
|
|
8
9
|
url: overrides.url ?? `https://${overrides.id}.example.com`,
|
|
9
10
|
supportedProtocols: overrides.supportedProtocols ?? ["chat_completions"],
|
|
10
11
|
paymentMethods: overrides.paymentMethods ?? ["clawtip"],
|
|
@@ -84,6 +85,19 @@ describe("ModelIndex", () => {
|
|
|
84
85
|
expect(noFilter.map((s) => s.id).sort()).toEqual(["s-chat", "s-msg"]);
|
|
85
86
|
});
|
|
86
87
|
|
|
88
|
+
test("indexes only active buyer-visible registry sellers", () => {
|
|
89
|
+
const index = new ModelIndex();
|
|
90
|
+
index.rebuild([
|
|
91
|
+
makeSeller({ id: "legacy-no-status", models: ["gpt-4o"] }),
|
|
92
|
+
makeSeller({ id: "active", status: "active", models: ["gpt-4o"] }),
|
|
93
|
+
makeSeller({ id: "pending", status: "pending", models: ["gpt-4o"] }),
|
|
94
|
+
makeSeller({ id: "offline", status: "offline", models: ["gpt-4o"] })
|
|
95
|
+
]);
|
|
96
|
+
|
|
97
|
+
expect(index.sellersFor("gpt-4o").map((seller) => seller.id)).toEqual(["legacy-no-status", "active"]);
|
|
98
|
+
expect(index.getSeller("pending")).toBeUndefined();
|
|
99
|
+
});
|
|
100
|
+
|
|
87
101
|
test("sellers missing models field are excluded from lookups but still addressable by id", () => {
|
|
88
102
|
const index = new ModelIndex();
|
|
89
103
|
const sellers: RegistrySeller[] = [
|
|
@@ -102,6 +102,22 @@ describe("RouteFailover", () => {
|
|
|
102
102
|
expect(decision.reason).toBe("soft_failure_fresh_purchase_window");
|
|
103
103
|
});
|
|
104
104
|
|
|
105
|
+
test("decide on busy_capacity fails over without retrying or opening the circuit", () => {
|
|
106
|
+
const { failover, credit, pool } = buildHarness([{ id: "s1" }, { id: "s2" }]);
|
|
107
|
+
credit.recordPurchase("s1", 1_000_000, 1_000_000);
|
|
108
|
+
const decision = failover.decide(
|
|
109
|
+
{ sellerId: "s1", status: 429, errorKind: "busy_capacity", errorMessage: "capacity full", attempt: 0 },
|
|
110
|
+
2
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
expect(decision.action).toBe("failover_next");
|
|
114
|
+
expect(decision.reason).toBe("busy_capacity");
|
|
115
|
+
const entry = pool.snapshot().find((candidate) => candidate.sellerId === "s1");
|
|
116
|
+
expect(entry?.circuit).toBe("closed");
|
|
117
|
+
expect(entry?.consecutiveFailures).toBe(0);
|
|
118
|
+
expect(entry?.capacityBlockedUntil).toBeGreaterThan(Date.now());
|
|
119
|
+
});
|
|
120
|
+
|
|
105
121
|
test("decide on soft_5xx after exhausting retries returns failover_next", () => {
|
|
106
122
|
const { failover, credit } = buildHarness([{ id: "s1" }]);
|
|
107
123
|
credit.recordPurchase("s1", 1_000_000, 1_000_000);
|