@tokenbuddy/tokenbuddy 1.0.36 → 1.0.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/buyer-store.d.ts +6 -1
- package/dist/src/buyer-store.js +43 -4
- package/dist/src/cli.js +2 -2
- 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/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
|
@@ -1,1630 +0,0 @@
|
|
|
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 { DEFAULT_INIT_RECOMMENDED_MODELS } from "../src/init-setup.js";
|
|
7
|
-
import type { SellerRegistryDocument } from "../src/seller-catalog.js";
|
|
8
|
-
|
|
9
|
-
jest.setTimeout(30000);
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* tb-ui v1 (PR-0) 控制平面写端点集成测试。
|
|
13
|
-
*
|
|
14
|
-
* 覆盖:
|
|
15
|
-
* - GET /routing/strategy 默认 + store 写入 + 来源标记
|
|
16
|
-
* - GET /routing/preview 合法 query + 非法 mode + 空 registry
|
|
17
|
-
* - PUT /routing/strategy fixed 缺 sellerId / 非法 mode / 合法 fullAuto + 热更新验证
|
|
18
|
-
* - PUT /prewarm/focus-set 设置 + clear + 热更新触达
|
|
19
|
-
*
|
|
20
|
-
* 跳过:
|
|
21
|
-
* - POST /daemon/restart spawn() monkey-patch 在 ESM binding 下不稳,留到 PR-1
|
|
22
|
-
* 浏览器手动验证(`tb ui` 页面点 Restart 按钮)。
|
|
23
|
-
*/
|
|
24
|
-
describe("TokenbuddyDaemon control-plane UI endpoints (PR-0)", () => {
|
|
25
|
-
const TEMP_DB = path.resolve(__dirname, "../../data-test/ui-endpoints-test.db");
|
|
26
|
-
const TEMP_HOME = path.resolve(__dirname, "../../data-test/ui-endpoints-home");
|
|
27
|
-
const TEST_REGISTRY: SellerRegistryDocument = {
|
|
28
|
-
version: 1,
|
|
29
|
-
defaultSeller: "tbs-86d81e",
|
|
30
|
-
sellers: [
|
|
31
|
-
{
|
|
32
|
-
id: "tbs-86d81e",
|
|
33
|
-
url: "https://tbs-86d81e.example.com",
|
|
34
|
-
supportedProtocols: ["chat_completions", "messages"],
|
|
35
|
-
paymentMethods: ["clawtip", "mock"],
|
|
36
|
-
models: ["claude-sonnet-4-5", "gpt-4o"]
|
|
37
|
-
},
|
|
38
|
-
{
|
|
39
|
-
id: "tbs-719577",
|
|
40
|
-
url: "https://tbs-719577.example.com",
|
|
41
|
-
supportedProtocols: ["chat_completions"],
|
|
42
|
-
paymentMethods: ["clawtip"],
|
|
43
|
-
models: ["claude-sonnet-4-5"]
|
|
44
|
-
},
|
|
45
|
-
{
|
|
46
|
-
id: "tbs-825edb",
|
|
47
|
-
url: "https://tbs-825edb.example.com",
|
|
48
|
-
supportedProtocols: ["chat_completions"],
|
|
49
|
-
paymentMethods: ["clawtip", "mock"],
|
|
50
|
-
models: ["gpt-4o"]
|
|
51
|
-
}
|
|
52
|
-
]
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
let daemon: TokenbuddyDaemon;
|
|
56
|
-
let controlPort: number;
|
|
57
|
-
let proxyPort: number;
|
|
58
|
-
|
|
59
|
-
function rmDb(): void {
|
|
60
|
-
for (const suffix of ["", "-wal", "-shm"]) {
|
|
61
|
-
const file = TEMP_DB + suffix;
|
|
62
|
-
if (fs.existsSync(file)) fs.unlinkSync(file);
|
|
63
|
-
}
|
|
64
|
-
fs.rmSync(TEMP_HOME, { recursive: true, force: true });
|
|
65
|
-
fs.rmSync(path.join(path.dirname(TEMP_DB), "static"), { recursive: true, force: true });
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
async function startDaemon(overrides: Partial<DaemonConfig> = {}): Promise<void> {
|
|
69
|
-
rmDb();
|
|
70
|
-
daemon = new TokenbuddyDaemon({
|
|
71
|
-
controlPort: 0,
|
|
72
|
-
proxyPort: 0,
|
|
73
|
-
dbPath: TEMP_DB,
|
|
74
|
-
sellerRegistryUrl: "http://127.0.0.1:1/registry/sellers", // 不会真正被触发
|
|
75
|
-
clawtipHomeDir: TEMP_HOME,
|
|
76
|
-
providerHomeDir: TEMP_HOME,
|
|
77
|
-
...overrides
|
|
78
|
-
});
|
|
79
|
-
daemon.start();
|
|
80
|
-
// 注入 test-only registry snapshot,避开网络拉取
|
|
81
|
-
daemon.setLastRegistrySnapshotForTest(TEST_REGISTRY);
|
|
82
|
-
const controlServer = (daemon as unknown as { controlServer: { address(): AddressInfo } }).controlServer;
|
|
83
|
-
const proxyServer = (daemon as unknown as { proxyServer: { address(): AddressInfo } }).proxyServer;
|
|
84
|
-
controlPort = controlServer.address().port;
|
|
85
|
-
proxyPort = proxyServer.address().port;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function controlUrl(path: string): string {
|
|
89
|
-
return `http://127.0.0.1:${controlPort}${path}`;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function proxyUrl(path: string): string {
|
|
93
|
-
return `http://127.0.0.1:${proxyPort}${path}`;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
async function startJsonServer(handler: http.RequestListener): Promise<{ server: http.Server; url: string }> {
|
|
97
|
-
const server = http.createServer(handler);
|
|
98
|
-
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
99
|
-
const address = server.address() as AddressInfo;
|
|
100
|
-
return {
|
|
101
|
-
server,
|
|
102
|
-
url: `http://127.0.0.1:${address.port}`
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
async function closeServer(server: http.Server): Promise<void> {
|
|
107
|
-
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
beforeEach(async () => {
|
|
111
|
-
await startDaemon();
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
afterEach(async () => {
|
|
115
|
-
daemon.stop();
|
|
116
|
-
// Drain any in-flight prewarm scheduler work to avoid jest open-handle
|
|
117
|
-
// warnings. The daemon's stop() is fire-and-forget.
|
|
118
|
-
await new Promise<void>((resolve) => setTimeout(resolve, 50));
|
|
119
|
-
rmDb();
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
function markClawtipReady(): void {
|
|
123
|
-
const store = (daemon as unknown as { tokenStore: { savePayment(input: unknown): void } }).tokenStore;
|
|
124
|
-
const configsDir = path.join(TEMP_HOME, ".openclaw", "configs");
|
|
125
|
-
fs.mkdirSync(configsDir, { recursive: true });
|
|
126
|
-
fs.writeFileSync(path.join(configsDir, "config.json"), "{}", "utf8");
|
|
127
|
-
store.savePayment({
|
|
128
|
-
method: "clawtip",
|
|
129
|
-
enabled: true,
|
|
130
|
-
isDefault: true,
|
|
131
|
-
config: {
|
|
132
|
-
resourceUrl: "https://tb-registry.example.test",
|
|
133
|
-
walletConfigPresent: true
|
|
134
|
-
}
|
|
135
|
-
});
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// ─── ClawTip payment QR endpoints ─────────────────────────────
|
|
139
|
-
describe("ClawTip payment QR endpoints", () => {
|
|
140
|
-
it("selects a configured payment method as default", async () => {
|
|
141
|
-
const store = (daemon as unknown as { tokenStore: { savePayment(input: unknown): void; listPayments(): Array<{ method: string; isDefault: boolean }> } }).tokenStore;
|
|
142
|
-
store.savePayment({
|
|
143
|
-
method: "clawtip",
|
|
144
|
-
enabled: false,
|
|
145
|
-
isDefault: true,
|
|
146
|
-
config: {
|
|
147
|
-
walletConfigPresent: false
|
|
148
|
-
}
|
|
149
|
-
});
|
|
150
|
-
store.savePayment({
|
|
151
|
-
method: "invoice",
|
|
152
|
-
enabled: true,
|
|
153
|
-
isDefault: false,
|
|
154
|
-
config: {
|
|
155
|
-
walletConfigPresent: true
|
|
156
|
-
}
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
const res = await fetch(controlUrl("/payments/default"), {
|
|
160
|
-
method: "PUT",
|
|
161
|
-
headers: { "content-type": "application/json" },
|
|
162
|
-
body: JSON.stringify({ method: "invoice" })
|
|
163
|
-
});
|
|
164
|
-
expect(res.status).toBe(200);
|
|
165
|
-
const body = await res.json() as { payments: Array<{ method: string; isDefault: boolean }> };
|
|
166
|
-
expect(body.payments.find((payment) => payment.method === "invoice")?.isDefault).toBe(true);
|
|
167
|
-
expect(body.payments.find((payment) => payment.method === "clawtip")?.isDefault).toBe(false);
|
|
168
|
-
expect(store.listPayments().find((payment) => payment.method === "invoice")?.isDefault).toBe(true);
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
it("rejects selecting an unbound payment method as default", async () => {
|
|
172
|
-
const store = (daemon as unknown as { tokenStore: { savePayment(input: unknown): void } }).tokenStore;
|
|
173
|
-
store.savePayment({
|
|
174
|
-
method: "invoice",
|
|
175
|
-
enabled: false,
|
|
176
|
-
isDefault: false,
|
|
177
|
-
config: {
|
|
178
|
-
walletConfigPresent: false
|
|
179
|
-
}
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
const res = await fetch(controlUrl("/payments/default"), {
|
|
183
|
-
method: "PUT",
|
|
184
|
-
headers: { "content-type": "application/json" },
|
|
185
|
-
body: JSON.stringify({ method: "invoice" })
|
|
186
|
-
});
|
|
187
|
-
expect(res.status).toBe(400);
|
|
188
|
-
const body = await res.json() as { error: { code: string } };
|
|
189
|
-
expect(body.error.code).toBe("payment_default_not_ready");
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
it("reflects a renamed wallet config as unbound in /payments", async () => {
|
|
193
|
-
const store = (daemon as unknown as { tokenStore: { savePayment(input: unknown): void } }).tokenStore;
|
|
194
|
-
const configsDir = path.join(TEMP_HOME, ".openclaw", "configs");
|
|
195
|
-
fs.mkdirSync(configsDir, { recursive: true });
|
|
196
|
-
fs.writeFileSync(path.join(configsDir, "config.json.bak"), "{}", "utf8");
|
|
197
|
-
store.savePayment({
|
|
198
|
-
method: "clawtip",
|
|
199
|
-
enabled: true,
|
|
200
|
-
isDefault: true,
|
|
201
|
-
config: {
|
|
202
|
-
resourceUrl: "https://tb-registry.example.test",
|
|
203
|
-
walletConfigPresent: true
|
|
204
|
-
}
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
const res = await fetch(controlUrl("/payments"));
|
|
208
|
-
expect(res.status).toBe(200);
|
|
209
|
-
const body = await res.json() as { payments: Array<{ method: string; enabled: boolean; config: Record<string, unknown> }> };
|
|
210
|
-
const clawtip = body.payments.find((payment) => payment.method === "clawtip");
|
|
211
|
-
expect(clawtip?.enabled).toBe(false);
|
|
212
|
-
expect(clawtip?.config.walletConfigPresent).toBe(false);
|
|
213
|
-
expect(clawtip?.config.nearbyWalletConfigPaths).toEqual([path.join(configsDir, "config.json.bak")]);
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
it("reuses the tb init ClawTip flow and exposes the returned QR image as static UI asset", async () => {
|
|
217
|
-
daemon.stop();
|
|
218
|
-
const qrSource = path.join(TEMP_HOME, "source-qrcode.png");
|
|
219
|
-
await startDaemon({
|
|
220
|
-
clawtipBootstrapFetcher: async () => ({
|
|
221
|
-
activationFeeFen: 1,
|
|
222
|
-
payment: {
|
|
223
|
-
orderNo: "order_ui_qr",
|
|
224
|
-
amountFen: 1,
|
|
225
|
-
payTo: "real-pay-to",
|
|
226
|
-
encryptedData: "ciphertext",
|
|
227
|
-
indicator: "indicator_ui_qr",
|
|
228
|
-
slug: "tb-registry",
|
|
229
|
-
skillId: "si-tb-registry",
|
|
230
|
-
description: "TokenBuddy ClawTip wallet activation",
|
|
231
|
-
resourceUrl: "https://tb-registry.example.test"
|
|
232
|
-
}
|
|
233
|
-
}),
|
|
234
|
-
clawtipWalletBootstrapStarter: async (payment) => ({
|
|
235
|
-
orderFile: path.join(TEMP_HOME, ".openclaw", "skills", "orders", payment.indicator, `${payment.orderNo}.json`),
|
|
236
|
-
parsedOutput: {
|
|
237
|
-
mediaPath: qrSource,
|
|
238
|
-
authUrl: "https://idt.jd.com/unifiedAuthM/plat/safeMonitor/?authFlowUid=abc",
|
|
239
|
-
clawtipId: "device-ui",
|
|
240
|
-
requiresWalletAuth: true,
|
|
241
|
-
walletReady: false
|
|
242
|
-
}
|
|
243
|
-
}),
|
|
244
|
-
clawtipActivationWaiter: async () => false
|
|
245
|
-
});
|
|
246
|
-
fs.mkdirSync(path.dirname(qrSource), { recursive: true });
|
|
247
|
-
fs.writeFileSync(qrSource, "png-bytes", "utf8");
|
|
248
|
-
|
|
249
|
-
const res = await fetch(controlUrl("/payments/clawtip/activate"), { method: "POST" });
|
|
250
|
-
expect(res.status).toBe(200);
|
|
251
|
-
const body = await res.json() as { qrImageUrl: string; sourceImagePath: string; orderNo: string };
|
|
252
|
-
expect(body.orderNo).toBe("order_ui_qr");
|
|
253
|
-
expect(body.sourceImagePath).toBe(qrSource);
|
|
254
|
-
expect(body.qrImageUrl).toMatch(/^\/static\/clawtip\/order_ui_qr-/);
|
|
255
|
-
|
|
256
|
-
const imageRes = await fetch(controlUrl(body.qrImageUrl));
|
|
257
|
-
expect(imageRes.status).toBe(200);
|
|
258
|
-
expect(await imageRes.text()).toBe("png-bytes");
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
it("updates ClawTip payment readiness after the activation wait detects wallet config", async () => {
|
|
262
|
-
daemon.stop();
|
|
263
|
-
const qrSource = path.join(TEMP_HOME, "source-qrcode.png");
|
|
264
|
-
const configsDir = path.join(TEMP_HOME, ".openclaw", "configs");
|
|
265
|
-
await startDaemon({
|
|
266
|
-
clawtipBootstrapFetcher: async () => ({
|
|
267
|
-
activationFeeFen: 1,
|
|
268
|
-
payment: {
|
|
269
|
-
orderNo: "order_wait_qr",
|
|
270
|
-
amountFen: 1,
|
|
271
|
-
payTo: "real-pay-to",
|
|
272
|
-
encryptedData: "ciphertext",
|
|
273
|
-
indicator: "indicator_wait_qr",
|
|
274
|
-
resourceUrl: "https://tb-registry.example.test"
|
|
275
|
-
}
|
|
276
|
-
}),
|
|
277
|
-
clawtipWalletBootstrapStarter: async (payment) => ({
|
|
278
|
-
orderFile: path.join(TEMP_HOME, ".openclaw", "skills", "orders", payment.indicator, `${payment.orderNo}.json`),
|
|
279
|
-
parsedOutput: {
|
|
280
|
-
mediaPath: qrSource,
|
|
281
|
-
clawtipId: "device-wait",
|
|
282
|
-
requiresWalletAuth: true,
|
|
283
|
-
walletReady: false
|
|
284
|
-
}
|
|
285
|
-
}),
|
|
286
|
-
clawtipActivationWaiter: async () => {
|
|
287
|
-
fs.mkdirSync(configsDir, { recursive: true });
|
|
288
|
-
fs.writeFileSync(path.join(configsDir, "config.json"), "{}", "utf8");
|
|
289
|
-
return true;
|
|
290
|
-
}
|
|
291
|
-
});
|
|
292
|
-
fs.mkdirSync(path.dirname(qrSource), { recursive: true });
|
|
293
|
-
fs.writeFileSync(qrSource, "png-bytes", "utf8");
|
|
294
|
-
|
|
295
|
-
const activateRes = await fetch(controlUrl("/payments/clawtip/activate"), { method: "POST" });
|
|
296
|
-
expect(activateRes.status).toBe(200);
|
|
297
|
-
await new Promise<void>((resolve) => setTimeout(resolve, 10));
|
|
298
|
-
|
|
299
|
-
const paymentsRes = await fetch(controlUrl("/payments"));
|
|
300
|
-
expect(paymentsRes.status).toBe(200);
|
|
301
|
-
const body = await paymentsRes.json() as { payments: Array<{ method: string; enabled: boolean; config: Record<string, unknown> }> };
|
|
302
|
-
const clawtip = body.payments.find((payment) => payment.method === "clawtip");
|
|
303
|
-
expect(clawtip?.enabled).toBe(true);
|
|
304
|
-
expect(clawtip?.config.walletConfigPresent).toBe(true);
|
|
305
|
-
expect(clawtip?.config.activationCompletedBy).toBe("wallet-config");
|
|
306
|
-
});
|
|
307
|
-
|
|
308
|
-
it("fails activation when the reused ClawTip flow does not return a QR media file", async () => {
|
|
309
|
-
daemon.stop();
|
|
310
|
-
await startDaemon({
|
|
311
|
-
clawtipBootstrapFetcher: async () => ({
|
|
312
|
-
activationFeeFen: 1,
|
|
313
|
-
payment: {
|
|
314
|
-
orderNo: "order_no_media",
|
|
315
|
-
amountFen: 1,
|
|
316
|
-
payTo: "real-pay-to",
|
|
317
|
-
encryptedData: "ciphertext",
|
|
318
|
-
indicator: "indicator_no_media",
|
|
319
|
-
resourceUrl: "https://tb-registry.example.test"
|
|
320
|
-
}
|
|
321
|
-
}),
|
|
322
|
-
clawtipWalletBootstrapStarter: async (payment) => ({
|
|
323
|
-
orderFile: path.join(TEMP_HOME, ".openclaw", "skills", "orders", payment.indicator, `${payment.orderNo}.json`),
|
|
324
|
-
parsedOutput: {
|
|
325
|
-
authUrl: "https://clawtip.jd.com/qrcode?clawtipId=device-no-media",
|
|
326
|
-
clawtipId: "device-no-media",
|
|
327
|
-
requiresWalletAuth: true,
|
|
328
|
-
walletReady: false
|
|
329
|
-
}
|
|
330
|
-
})
|
|
331
|
-
});
|
|
332
|
-
|
|
333
|
-
const res = await fetch(controlUrl("/payments/clawtip/activate"), { method: "POST" });
|
|
334
|
-
expect(res.status).toBe(500);
|
|
335
|
-
const body = await res.json() as { error: { code: string; message: string } };
|
|
336
|
-
expect(body.error.code).toBe("clawtip_activate_qr_failed");
|
|
337
|
-
expect(body.error.message).toContain("did not return a QR image");
|
|
338
|
-
});
|
|
339
|
-
|
|
340
|
-
it("does not reuse the activation QR when the fixed recharge asset is missing", async () => {
|
|
341
|
-
daemon.stop();
|
|
342
|
-
await startDaemon({ clawtipBundledStaticDir: false });
|
|
343
|
-
const store = (daemon as unknown as { tokenStore: { savePayment(input: unknown): void } }).tokenStore;
|
|
344
|
-
const activationQr = path.join(TEMP_HOME, "activation-qrcode.png");
|
|
345
|
-
fs.mkdirSync(path.dirname(activationQr), { recursive: true });
|
|
346
|
-
fs.writeFileSync(activationQr, "activation-png-bytes", "utf8");
|
|
347
|
-
store.savePayment({
|
|
348
|
-
method: "clawtip",
|
|
349
|
-
enabled: true,
|
|
350
|
-
isDefault: true,
|
|
351
|
-
config: {
|
|
352
|
-
orderNo: "order_activation_only",
|
|
353
|
-
activationQrImagePath: activationQr,
|
|
354
|
-
walletConfigPresent: true
|
|
355
|
-
}
|
|
356
|
-
});
|
|
357
|
-
|
|
358
|
-
const res = await fetch(controlUrl("/payments/clawtip/recharge"), { method: "POST" });
|
|
359
|
-
expect(res.status).toBe(500);
|
|
360
|
-
const body = await res.json() as { error: { code: string; message: string } };
|
|
361
|
-
expect(body.error.code).toBe("clawtip_recharge_qr_failed");
|
|
362
|
-
expect(body.error.message).toContain("fixed recharge QR image is missing");
|
|
363
|
-
});
|
|
364
|
-
|
|
365
|
-
it("returns the fixed ClawTip recharge static asset", async () => {
|
|
366
|
-
const store = (daemon as unknown as { tokenStore: { savePayment(input: unknown): void } }).tokenStore;
|
|
367
|
-
store.savePayment({
|
|
368
|
-
method: "clawtip",
|
|
369
|
-
enabled: true,
|
|
370
|
-
isDefault: true,
|
|
371
|
-
config: {
|
|
372
|
-
orderNo: "order_recharge_static",
|
|
373
|
-
resourceUrl: "https://tb-registry.example.test",
|
|
374
|
-
walletConfigPresent: true
|
|
375
|
-
}
|
|
376
|
-
});
|
|
377
|
-
const staticDir = path.join(path.dirname(TEMP_DB), "static", "clawtip");
|
|
378
|
-
fs.mkdirSync(staticDir, { recursive: true });
|
|
379
|
-
fs.writeFileSync(path.join(staticDir, "recharge.png"), "fixed-recharge-png", "utf8");
|
|
380
|
-
|
|
381
|
-
const res = await fetch(controlUrl("/payments/clawtip/recharge"), { method: "POST" });
|
|
382
|
-
expect(res.status).toBe(200);
|
|
383
|
-
const body = await res.json() as { qrImageUrl: string; sourceImagePath: string; staticImagePath: string; orderNo: string };
|
|
384
|
-
expect(body.orderNo).toBe("order_recharge_static");
|
|
385
|
-
expect(body.qrImageUrl).toBe("/static/clawtip/recharge.png");
|
|
386
|
-
expect(body.sourceImagePath).toBe(path.join(staticDir, "recharge.png"));
|
|
387
|
-
expect(body.staticImagePath).toBe(path.join(staticDir, "recharge.png"));
|
|
388
|
-
|
|
389
|
-
const imageRes = await fetch(controlUrl(body.qrImageUrl));
|
|
390
|
-
expect(imageRes.status).toBe(200);
|
|
391
|
-
expect(await imageRes.text()).toBe("fixed-recharge-png");
|
|
392
|
-
});
|
|
393
|
-
});
|
|
394
|
-
|
|
395
|
-
// ─── Bundled PWA Static Assets ────────────────────────────────
|
|
396
|
-
describe("bundled tb-ui static assets", () => {
|
|
397
|
-
it("serves the installable PWA shell from the control plane", async () => {
|
|
398
|
-
const overviewRes = await fetch(controlUrl("/overview"));
|
|
399
|
-
expect(overviewRes.status).toBe(200);
|
|
400
|
-
expect(overviewRes.headers.get("content-type")).toContain("text/html");
|
|
401
|
-
expect(await overviewRes.text()).toContain("/manifest.webmanifest");
|
|
402
|
-
|
|
403
|
-
const manifestRes = await fetch(controlUrl("/manifest.webmanifest"));
|
|
404
|
-
expect(manifestRes.status).toBe(200);
|
|
405
|
-
expect(manifestRes.headers.get("content-type")).toContain("application/manifest+json");
|
|
406
|
-
const manifest = await manifestRes.json() as { start_url: string; icons: Array<{ src: string; sizes: string }> };
|
|
407
|
-
expect(manifest).toMatchObject({ name: "TokenBuddy", short_name: "TokenBuddy" });
|
|
408
|
-
expect(manifest.start_url).toBe("/overview");
|
|
409
|
-
expect(manifest.icons).toEqual(expect.arrayContaining([
|
|
410
|
-
expect.objectContaining({ src: "/icons/tokenbuddy-192.png", sizes: "192x192" }),
|
|
411
|
-
expect.objectContaining({ src: "/icons/tokenbuddy-512.png", sizes: "512x512" })
|
|
412
|
-
]));
|
|
413
|
-
|
|
414
|
-
const serviceWorkerRes = await fetch(controlUrl("/sw.js"));
|
|
415
|
-
expect(serviceWorkerRes.status).toBe(200);
|
|
416
|
-
expect(serviceWorkerRes.headers.get("content-type")).toContain("application/javascript");
|
|
417
|
-
expect(await serviceWorkerRes.text()).toContain("tokenbuddy-ui-v2");
|
|
418
|
-
|
|
419
|
-
const iconRes = await fetch(controlUrl("/icons/tokenbuddy-192.png"));
|
|
420
|
-
expect(iconRes.status).toBe(200);
|
|
421
|
-
expect(iconRes.headers.get("content-type")).toContain("image/png");
|
|
422
|
-
expect(Number(iconRes.headers.get("content-length"))).toBeGreaterThan(1_000);
|
|
423
|
-
});
|
|
424
|
-
});
|
|
425
|
-
|
|
426
|
-
// ─── Init Wizard State ────────────────────────────────────────
|
|
427
|
-
describe("web init wizard state", () => {
|
|
428
|
-
it("returns a fresh-machine setup snapshot for the web wizard", async () => {
|
|
429
|
-
daemon.stop();
|
|
430
|
-
await startDaemon({ providerHomeDir: TEMP_HOME });
|
|
431
|
-
|
|
432
|
-
const res = await fetch(controlUrl("/init/state"));
|
|
433
|
-
expect(res.status).toBe(200);
|
|
434
|
-
const body = await res.json() as {
|
|
435
|
-
setup: { status: string; completedSteps: string[] };
|
|
436
|
-
freshMachine: boolean;
|
|
437
|
-
runtime: { status: string; controlPort: number; proxyPort: number };
|
|
438
|
-
payments: unknown[];
|
|
439
|
-
clients: Array<{ id: string; name: string }>;
|
|
440
|
-
clientsSummary: { installCommand: string; totalCount: number };
|
|
441
|
-
routing: { strategy: { mode: string; scorer: string }; source: string };
|
|
442
|
-
focusSet: string[];
|
|
443
|
-
recommendedModels: string[];
|
|
444
|
-
};
|
|
445
|
-
|
|
446
|
-
expect(body.setup).toMatchObject({
|
|
447
|
-
status: "not_started",
|
|
448
|
-
completedSteps: []
|
|
449
|
-
});
|
|
450
|
-
expect(body.freshMachine).toBe(true);
|
|
451
|
-
expect(body.runtime).toMatchObject({
|
|
452
|
-
status: "running",
|
|
453
|
-
controlPort,
|
|
454
|
-
proxyPort
|
|
455
|
-
});
|
|
456
|
-
expect(body.payments).toEqual([]);
|
|
457
|
-
expect(body.clients).toEqual(expect.arrayContaining([
|
|
458
|
-
expect.objectContaining({ id: "custom", name: "Custom client" })
|
|
459
|
-
]));
|
|
460
|
-
expect(body.clientsSummary.installCommand).toBe("tb init");
|
|
461
|
-
expect(body.clientsSummary.totalCount).toBe(body.clients.length);
|
|
462
|
-
expect(body.routing.strategy).toMatchObject({ mode: "fullAuto", scorer: "balanced" });
|
|
463
|
-
expect(body.routing.source).toBe("default");
|
|
464
|
-
expect(body.focusSet).toEqual([]);
|
|
465
|
-
expect(body.recommendedModels).toEqual([...DEFAULT_INIT_RECOMMENDED_MODELS]);
|
|
466
|
-
});
|
|
467
|
-
|
|
468
|
-
it("returns configured init recommended models in stable order", async () => {
|
|
469
|
-
daemon.stop();
|
|
470
|
-
await startDaemon({
|
|
471
|
-
providerHomeDir: TEMP_HOME,
|
|
472
|
-
initRecommendedModels: [" custom-a ", "", "custom-b", "custom-a"]
|
|
473
|
-
});
|
|
474
|
-
|
|
475
|
-
const res = await fetch(controlUrl("/init/state"));
|
|
476
|
-
expect(res.status).toBe(200);
|
|
477
|
-
const body = await res.json() as { recommendedModels: string[] };
|
|
478
|
-
|
|
479
|
-
expect(body.recommendedModels).toEqual(["custom-a", "custom-b"]);
|
|
480
|
-
});
|
|
481
|
-
|
|
482
|
-
it("marks the web init wizard as completed and rejects unknown steps", async () => {
|
|
483
|
-
const badRes = await fetch(controlUrl("/init/complete"), {
|
|
484
|
-
method: "POST",
|
|
485
|
-
headers: { "content-type": "application/json" },
|
|
486
|
-
body: JSON.stringify({ completedSteps: ["gateway_intro", "unknown_step"] })
|
|
487
|
-
});
|
|
488
|
-
expect(badRes.status).toBe(400);
|
|
489
|
-
const badBody = await badRes.json() as { error: { code: string; message: string } };
|
|
490
|
-
expect(badBody.error.code).toBe("init_setup_complete_failed");
|
|
491
|
-
expect(badBody.error.message).toContain("unknown init setup step");
|
|
492
|
-
|
|
493
|
-
const completeRes = await fetch(controlUrl("/init/complete"), {
|
|
494
|
-
method: "POST",
|
|
495
|
-
headers: { "content-type": "application/json" },
|
|
496
|
-
body: JSON.stringify({ completedSteps: ["gateway_intro", "model_access"] })
|
|
497
|
-
});
|
|
498
|
-
expect(completeRes.status).toBe(200);
|
|
499
|
-
const completed = await completeRes.json() as {
|
|
500
|
-
setup: { status: string; completedSteps: string[]; completedAt?: string };
|
|
501
|
-
freshMachine: boolean;
|
|
502
|
-
repairMode: boolean;
|
|
503
|
-
repairReasons: string[];
|
|
504
|
-
};
|
|
505
|
-
expect(completed.setup.status).toBe("completed");
|
|
506
|
-
expect(completed.setup.completedSteps).toEqual(["gateway_intro", "model_access"]);
|
|
507
|
-
expect(completed.setup.completedAt).toEqual(expect.any(String));
|
|
508
|
-
expect(completed.freshMachine).toBe(false);
|
|
509
|
-
expect(completed.repairMode).toBe(true);
|
|
510
|
-
expect(completed.repairReasons).toEqual(expect.arrayContaining([
|
|
511
|
-
expect.stringContaining("missing_steps:"),
|
|
512
|
-
"missing_focus_models",
|
|
513
|
-
"missing_payment_method",
|
|
514
|
-
"missing_connected_tools"
|
|
515
|
-
]));
|
|
516
|
-
|
|
517
|
-
const stateRes = await fetch(controlUrl("/init/state"));
|
|
518
|
-
expect(stateRes.status).toBe(200);
|
|
519
|
-
const state = await stateRes.json() as {
|
|
520
|
-
setup: { status: string; completedSteps: string[] };
|
|
521
|
-
freshMachine: boolean;
|
|
522
|
-
repairMode: boolean;
|
|
523
|
-
repairReasons: string[];
|
|
524
|
-
};
|
|
525
|
-
expect(state.setup.status).toBe("completed");
|
|
526
|
-
expect(state.setup.completedSteps).toEqual(["gateway_intro", "model_access"]);
|
|
527
|
-
expect(state.freshMachine).toBe(false);
|
|
528
|
-
expect(state.repairMode).toBe(true);
|
|
529
|
-
expect(state.repairReasons).toEqual(completed.repairReasons);
|
|
530
|
-
});
|
|
531
|
-
|
|
532
|
-
it("keeps completed setup out of repair mode when key wizard outputs exist", async () => {
|
|
533
|
-
daemon.stop();
|
|
534
|
-
await startDaemon({ providerHomeDir: TEMP_HOME });
|
|
535
|
-
const codexConfig = path.join(TEMP_HOME, ".codex", "config.toml");
|
|
536
|
-
fs.mkdirSync(path.dirname(codexConfig), { recursive: true });
|
|
537
|
-
fs.writeFileSync(codexConfig, "[tokenbuddy]\nproxy_url = \"http://127.0.0.1:17821\"\n", "utf8");
|
|
538
|
-
|
|
539
|
-
const store = (daemon as unknown as { tokenStore: { savePayment(input: unknown): void } }).tokenStore;
|
|
540
|
-
store.savePayment({
|
|
541
|
-
method: "mock",
|
|
542
|
-
enabled: true,
|
|
543
|
-
isDefault: true,
|
|
544
|
-
config: { source: "test" }
|
|
545
|
-
});
|
|
546
|
-
|
|
547
|
-
const focusRes = await fetch(controlUrl("/prewarm/focus-set"), {
|
|
548
|
-
method: "PUT",
|
|
549
|
-
headers: { "content-type": "application/json" },
|
|
550
|
-
body: JSON.stringify({ models: ["gpt-4o"] })
|
|
551
|
-
});
|
|
552
|
-
expect(focusRes.status).toBe(200);
|
|
553
|
-
|
|
554
|
-
const completeRes = await fetch(controlUrl("/init/complete"), {
|
|
555
|
-
method: "POST",
|
|
556
|
-
headers: { "content-type": "application/json" },
|
|
557
|
-
body: JSON.stringify({})
|
|
558
|
-
});
|
|
559
|
-
expect(completeRes.status).toBe(200);
|
|
560
|
-
|
|
561
|
-
const stateRes = await fetch(controlUrl("/init/state"));
|
|
562
|
-
expect(stateRes.status).toBe(200);
|
|
563
|
-
const state = await stateRes.json() as {
|
|
564
|
-
setup: { status: string; completedSteps: string[] };
|
|
565
|
-
freshMachine: boolean;
|
|
566
|
-
repairMode: boolean;
|
|
567
|
-
repairReasons: string[];
|
|
568
|
-
focusSet: string[];
|
|
569
|
-
payments: Array<{ method: string; enabled: boolean }>;
|
|
570
|
-
clientsSummary: { configuredCount: number };
|
|
571
|
-
};
|
|
572
|
-
expect(state.setup.status).toBe("completed");
|
|
573
|
-
expect(state.setup.completedSteps).toEqual([
|
|
574
|
-
"gateway_intro",
|
|
575
|
-
"model_access",
|
|
576
|
-
"supplier_routing",
|
|
577
|
-
"auto_purchase",
|
|
578
|
-
"connect_tools",
|
|
579
|
-
"verify_gateway",
|
|
580
|
-
"install_app"
|
|
581
|
-
]);
|
|
582
|
-
expect(state.freshMachine).toBe(false);
|
|
583
|
-
expect(state.repairMode).toBe(false);
|
|
584
|
-
expect(state.repairReasons).toEqual([]);
|
|
585
|
-
expect(state.focusSet).toEqual(["gpt-4o"]);
|
|
586
|
-
expect(state.payments).toEqual(expect.arrayContaining([
|
|
587
|
-
expect.objectContaining({ method: "mock", enabled: true })
|
|
588
|
-
]));
|
|
589
|
-
expect(state.clientsSummary.configuredCount).toBeGreaterThanOrEqual(1);
|
|
590
|
-
});
|
|
591
|
-
|
|
592
|
-
it("runs structured init doctor checks for the web wizard", async () => {
|
|
593
|
-
const res = await fetch(controlUrl("/init/doctor/run"), { method: "POST" });
|
|
594
|
-
expect(res.status).toBe(200);
|
|
595
|
-
const body = await res.json() as {
|
|
596
|
-
status: "passed" | "warning" | "failed";
|
|
597
|
-
generatedAt: string;
|
|
598
|
-
checks: Array<{ id: string; label: string; status: string; message: string; details: string[] }>;
|
|
599
|
-
};
|
|
600
|
-
|
|
601
|
-
expect(body.generatedAt).toEqual(expect.any(String));
|
|
602
|
-
expect(body.status).toBe("warning");
|
|
603
|
-
expect(body.checks).toEqual(expect.arrayContaining([
|
|
604
|
-
expect.objectContaining({ id: "local_service", label: "本地服务", status: "passed" }),
|
|
605
|
-
expect.objectContaining({ id: "model_catalog", label: "模型目录", status: "passed" }),
|
|
606
|
-
expect.objectContaining({ id: "routing_strategy", label: "路由策略", status: "passed" }),
|
|
607
|
-
expect.objectContaining({ id: "payment_method", label: "支付方式", status: "warning" }),
|
|
608
|
-
expect.objectContaining({ id: "connected_tools", label: "已连接工具", status: "warning" })
|
|
609
|
-
]));
|
|
610
|
-
const routing = body.checks.find((check) => check.id === "routing_strategy");
|
|
611
|
-
expect(routing?.details.join(" ")).toContain("fullAuto");
|
|
612
|
-
});
|
|
613
|
-
});
|
|
614
|
-
|
|
615
|
-
// ─── GET /providers/status ───────────────────────────────────
|
|
616
|
-
describe("GET /providers/status", () => {
|
|
617
|
-
it("returns read-only client tool status with a CLI install command", async () => {
|
|
618
|
-
daemon.stop();
|
|
619
|
-
await startDaemon({ providerHomeDir: TEMP_HOME });
|
|
620
|
-
const codexConfig = path.join(TEMP_HOME, ".codex", "config.toml");
|
|
621
|
-
fs.mkdirSync(path.dirname(codexConfig), { recursive: true });
|
|
622
|
-
fs.writeFileSync(codexConfig, "[tokenbuddy]\nproxy_url = \"http://127.0.0.1:17821\"\n", "utf8");
|
|
623
|
-
|
|
624
|
-
const res = await fetch(controlUrl("/providers/status"));
|
|
625
|
-
expect(res.status).toBe(200);
|
|
626
|
-
const body = await res.json() as {
|
|
627
|
-
clients: Array<{ id: string; status: string; configured: boolean; reason: string; manualConfig?: { openaiBaseUrl: string; anthropicBaseUrl: string; apiKey: string } }>;
|
|
628
|
-
summary: { configuredCount: number; installCommand: string };
|
|
629
|
-
};
|
|
630
|
-
const codex = body.clients.find((client) => client.id === "codex");
|
|
631
|
-
const custom = body.clients.find((client) => client.id === "custom");
|
|
632
|
-
expect(codex).toMatchObject({ status: "configured", configured: true });
|
|
633
|
-
expect(custom).toMatchObject({ status: "manual", configured: false });
|
|
634
|
-
expect(custom?.reason).toContain("http://127.0.0.1:");
|
|
635
|
-
expect(custom?.manualConfig).toEqual({
|
|
636
|
-
openaiBaseUrl: `http://127.0.0.1:${proxyPort}/v1`,
|
|
637
|
-
anthropicBaseUrl: `http://127.0.0.1:${proxyPort}`,
|
|
638
|
-
apiKey: "TOKENBUDDY_PROXY"
|
|
639
|
-
});
|
|
640
|
-
expect(body.summary.configuredCount).toBeGreaterThanOrEqual(1);
|
|
641
|
-
expect(body.summary.installCommand).toBe("tb init");
|
|
642
|
-
});
|
|
643
|
-
|
|
644
|
-
it("applies tb-ui provider install requests to active OpenClaw and Hermes config files", async () => {
|
|
645
|
-
daemon.stop();
|
|
646
|
-
await startDaemon({ providerHomeDir: TEMP_HOME });
|
|
647
|
-
|
|
648
|
-
const res = await fetch(controlUrl("/providers/install/apply"), {
|
|
649
|
-
method: "POST",
|
|
650
|
-
headers: { "content-type": "application/json" },
|
|
651
|
-
body: JSON.stringify({
|
|
652
|
-
home: TEMP_HOME,
|
|
653
|
-
providers: ["openclaw", "hermes"],
|
|
654
|
-
proxyUrl: `http://127.0.0.1:${proxyPort}`,
|
|
655
|
-
providerSelections: {
|
|
656
|
-
openclaw: {
|
|
657
|
-
selectionKind: "single-model",
|
|
658
|
-
protocolPreference: "chat_completions",
|
|
659
|
-
defaultModel: "gpt-5.4"
|
|
660
|
-
},
|
|
661
|
-
hermes: {
|
|
662
|
-
selectionKind: "single-model",
|
|
663
|
-
protocolPreference: "chat_completions",
|
|
664
|
-
defaultModel: "gpt-5.4"
|
|
665
|
-
}
|
|
666
|
-
}
|
|
667
|
-
})
|
|
668
|
-
});
|
|
669
|
-
|
|
670
|
-
expect(res.status).toBe(200);
|
|
671
|
-
const body = await res.json() as { applied: Array<{ providerId: string; path: string; action: string }> };
|
|
672
|
-
expect(body.applied).toEqual(expect.arrayContaining([
|
|
673
|
-
expect.objectContaining({
|
|
674
|
-
providerId: "openclaw",
|
|
675
|
-
path: path.join(TEMP_HOME, ".openclaw", "openclaw.json")
|
|
676
|
-
}),
|
|
677
|
-
expect.objectContaining({
|
|
678
|
-
providerId: "hermes",
|
|
679
|
-
path: path.join(TEMP_HOME, ".hermes", "config.yaml")
|
|
680
|
-
})
|
|
681
|
-
]));
|
|
682
|
-
|
|
683
|
-
const openclaw = JSON.parse(fs.readFileSync(path.join(TEMP_HOME, ".openclaw", "openclaw.json"), "utf8"));
|
|
684
|
-
expect(openclaw.models.providers.tokenbuddy.baseUrl).toBe(`http://127.0.0.1:${proxyPort}/v1`);
|
|
685
|
-
expect(openclaw.models.providers.tokenbuddy.apiKey).toBe("TOKENBUDDY_PROXY");
|
|
686
|
-
expect(openclaw.models.providers.tokenbuddy.auth).toBe("api-key");
|
|
687
|
-
expect(openclaw.models.providers.tokenbuddy.api).toBe("openai-completions");
|
|
688
|
-
expect(openclaw.models.providers.tokenbuddy.models).toEqual(expect.arrayContaining([
|
|
689
|
-
expect.objectContaining({ id: "gpt-5.4", api: "openai-completions" })
|
|
690
|
-
]));
|
|
691
|
-
expect(openclaw.agents.defaults.model).toBe("tokenbuddy/gpt-5.4");
|
|
692
|
-
|
|
693
|
-
const hermes = fs.readFileSync(path.join(TEMP_HOME, ".hermes", "config.yaml"), "utf8");
|
|
694
|
-
expect(hermes).toContain("default: gpt-5.4");
|
|
695
|
-
expect(hermes).toContain("provider: custom");
|
|
696
|
-
expect(hermes).toContain(`base_url: "http://127.0.0.1:${proxyPort}/v1"`);
|
|
697
|
-
expect(hermes).toContain("api_key: TOKENBUDDY_PROXY");
|
|
698
|
-
expect(hermes).toContain("api_mode: chat_completions");
|
|
699
|
-
|
|
700
|
-
const statusRes = await fetch(controlUrl("/providers/status"));
|
|
701
|
-
expect(statusRes.status).toBe(200);
|
|
702
|
-
const statusBody = await statusRes.json() as { clients: Array<{ id: string; configured: boolean; configPath?: string }> };
|
|
703
|
-
expect(statusBody.clients).toEqual(expect.arrayContaining([
|
|
704
|
-
expect.objectContaining({
|
|
705
|
-
id: "openclaw",
|
|
706
|
-
configured: true,
|
|
707
|
-
configPath: path.join(TEMP_HOME, ".openclaw", "openclaw.json")
|
|
708
|
-
}),
|
|
709
|
-
expect.objectContaining({
|
|
710
|
-
id: "hermes",
|
|
711
|
-
configured: true,
|
|
712
|
-
configPath: path.join(TEMP_HOME, ".hermes", "config.yaml")
|
|
713
|
-
})
|
|
714
|
-
]));
|
|
715
|
-
});
|
|
716
|
-
});
|
|
717
|
-
|
|
718
|
-
describe("provider mode and provider configs", () => {
|
|
719
|
-
it("defaults to manual mode and reports auto as locked without payment", async () => {
|
|
720
|
-
const res = await fetch(controlUrl("/routing/provider-mode"));
|
|
721
|
-
expect(res.status).toBe(200);
|
|
722
|
-
const body = await res.json() as {
|
|
723
|
-
mode: string;
|
|
724
|
-
manualEnabled: boolean;
|
|
725
|
-
autoEnabled: boolean;
|
|
726
|
-
paymentReady: boolean;
|
|
727
|
-
paymentRequired: boolean;
|
|
728
|
-
locked: { auto: boolean };
|
|
729
|
-
};
|
|
730
|
-
expect(body.mode).toBe("manual");
|
|
731
|
-
expect(body.manualEnabled).toBe(true);
|
|
732
|
-
expect(body.autoEnabled).toBe(false);
|
|
733
|
-
expect(body.paymentReady).toBe(false);
|
|
734
|
-
expect(body.paymentRequired).toBe(true);
|
|
735
|
-
expect(body.locked.auto).toBe(true);
|
|
736
|
-
});
|
|
737
|
-
|
|
738
|
-
it("rejects enabling auto mode until a non-mock payment is ready", async () => {
|
|
739
|
-
const res = await fetch(controlUrl("/routing/provider-mode"), {
|
|
740
|
-
method: "PUT",
|
|
741
|
-
headers: { "content-type": "application/json" },
|
|
742
|
-
body: JSON.stringify({ mode: "auto" })
|
|
743
|
-
});
|
|
744
|
-
expect(res.status).toBe(409);
|
|
745
|
-
const body = await res.json() as { error: { code: string }; bindTarget: string };
|
|
746
|
-
expect(body.error.code).toBe("payment_required");
|
|
747
|
-
expect(body.bindTarget).toBe("/overview?bind=clawtip");
|
|
748
|
-
});
|
|
749
|
-
|
|
750
|
-
it("enables auto mode after ClawTip is bound", async () => {
|
|
751
|
-
markClawtipReady();
|
|
752
|
-
const res = await fetch(controlUrl("/routing/provider-mode"), {
|
|
753
|
-
method: "PUT",
|
|
754
|
-
headers: { "content-type": "application/json" },
|
|
755
|
-
body: JSON.stringify({ mode: "auto" })
|
|
756
|
-
});
|
|
757
|
-
expect(res.status).toBe(200);
|
|
758
|
-
const body = await res.json() as {
|
|
759
|
-
applied: boolean;
|
|
760
|
-
mode: string;
|
|
761
|
-
manualEnabled: boolean;
|
|
762
|
-
autoEnabled: boolean;
|
|
763
|
-
paymentReady: boolean;
|
|
764
|
-
paymentLabel?: string;
|
|
765
|
-
};
|
|
766
|
-
expect(body.applied).toBe(true);
|
|
767
|
-
expect(body.mode).toBe("auto");
|
|
768
|
-
expect(body.manualEnabled).toBe(false);
|
|
769
|
-
expect(body.autoEnabled).toBe(true);
|
|
770
|
-
expect(body.paymentReady).toBe(true);
|
|
771
|
-
expect(body.paymentLabel).toBe("ClawTip");
|
|
772
|
-
});
|
|
773
|
-
|
|
774
|
-
it("creates, lists, and deletes manual providers without exposing secret fields", async () => {
|
|
775
|
-
const createRes = await fetch(controlUrl("/routing/manual-providers"), {
|
|
776
|
-
method: "POST",
|
|
777
|
-
headers: { "content-type": "application/json" },
|
|
778
|
-
body: JSON.stringify({
|
|
779
|
-
id: "local-openai",
|
|
780
|
-
name: "Local OpenAI",
|
|
781
|
-
baseUrl: "https://api.openai.example/v1/",
|
|
782
|
-
apiKeyEnv: "TB_TEST_PROVIDER_OPENAI_KEY",
|
|
783
|
-
models: ["gpt-4o", "gpt-4o"],
|
|
784
|
-
supportedProtocols: ["chat_completions"],
|
|
785
|
-
enabled: true
|
|
786
|
-
})
|
|
787
|
-
});
|
|
788
|
-
expect(createRes.status).toBe(201);
|
|
789
|
-
const created = await createRes.json() as {
|
|
790
|
-
provider: { id: string; baseUrl: string; keyRef?: { kind: string; name: string; configured: boolean }; apiKeyEnv?: string };
|
|
791
|
-
};
|
|
792
|
-
expect(created.provider.id).toBe("local-openai");
|
|
793
|
-
expect(created.provider.baseUrl).toBe("https://api.openai.example/v1");
|
|
794
|
-
expect(created.provider.keyRef).toEqual({
|
|
795
|
-
kind: "env",
|
|
796
|
-
name: "TB_TEST_PROVIDER_OPENAI_KEY",
|
|
797
|
-
configured: false
|
|
798
|
-
});
|
|
799
|
-
expect(created.provider.apiKeyEnv).toBeUndefined();
|
|
800
|
-
|
|
801
|
-
const listRes = await fetch(controlUrl("/routing/manual-providers"));
|
|
802
|
-
expect(listRes.status).toBe(200);
|
|
803
|
-
const listBody = await listRes.json() as { providers: Array<{ id: string; apiKeyEnv?: string }> };
|
|
804
|
-
expect(listBody.providers.map((provider) => provider.id)).toEqual(["local-openai"]);
|
|
805
|
-
expect(listBody.providers[0].apiKeyEnv).toBeUndefined();
|
|
806
|
-
|
|
807
|
-
const deleteRes = await fetch(controlUrl("/routing/manual-providers/local-openai"), { method: "DELETE" });
|
|
808
|
-
expect(deleteRes.status).toBe(200);
|
|
809
|
-
const afterDeleteRes = await fetch(controlUrl("/routing/manual-providers"));
|
|
810
|
-
const afterDelete = await afterDeleteRes.json() as { providers: unknown[] };
|
|
811
|
-
expect(afterDelete.providers).toEqual([]);
|
|
812
|
-
});
|
|
813
|
-
|
|
814
|
-
it("rejects manual providers with raw API keys", async () => {
|
|
815
|
-
const res = await fetch(controlUrl("/routing/manual-providers"), {
|
|
816
|
-
method: "POST",
|
|
817
|
-
headers: { "content-type": "application/json" },
|
|
818
|
-
body: JSON.stringify({
|
|
819
|
-
id: "local-openai",
|
|
820
|
-
name: "Local OpenAI",
|
|
821
|
-
baseUrl: "https://api.openai.example/v1",
|
|
822
|
-
apiKey: "sk-secret",
|
|
823
|
-
models: ["gpt-4o"]
|
|
824
|
-
})
|
|
825
|
-
});
|
|
826
|
-
expect(res.status).toBe(400);
|
|
827
|
-
const body = await res.json() as { error: { code: string; message: string } };
|
|
828
|
-
expect(body.error.code).toBe("manual_provider_create_failed");
|
|
829
|
-
expect(body.error.message).toMatch(/raw apiKey/);
|
|
830
|
-
expect(body.error.message).not.toContain("sk-secret");
|
|
831
|
-
});
|
|
832
|
-
|
|
833
|
-
it("probes and creates a local manual provider without exposing the raw API key", async () => {
|
|
834
|
-
const providerKey = "provider-local-ui-key";
|
|
835
|
-
const provider = await startJsonServer((req, res) => {
|
|
836
|
-
expect(req.headers.authorization).toBe(`Bearer ${providerKey}`);
|
|
837
|
-
if (req.method === "GET" && req.url === "/v1/models") {
|
|
838
|
-
res.writeHead(200, { "content-type": "application/json" });
|
|
839
|
-
res.end(JSON.stringify({
|
|
840
|
-
object: "list",
|
|
841
|
-
data: [
|
|
842
|
-
{ id: "gpt-local-ui", object: "model" },
|
|
843
|
-
{ id: "gpt-local-ui-fast", object: "model" }
|
|
844
|
-
]
|
|
845
|
-
}));
|
|
846
|
-
return;
|
|
847
|
-
}
|
|
848
|
-
if (req.method === "POST" && req.url === "/v1/chat/completions") {
|
|
849
|
-
res.writeHead(200, { "content-type": "application/json" });
|
|
850
|
-
res.end(JSON.stringify({
|
|
851
|
-
id: "chatcmpl-local-ui",
|
|
852
|
-
object: "chat.completion",
|
|
853
|
-
choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }],
|
|
854
|
-
usage: { prompt_tokens: 4, completion_tokens: 3 }
|
|
855
|
-
}));
|
|
856
|
-
return;
|
|
857
|
-
}
|
|
858
|
-
res.writeHead(404, { "content-type": "application/json" });
|
|
859
|
-
res.end(JSON.stringify({ error: { message: "not found" } }));
|
|
860
|
-
});
|
|
861
|
-
|
|
862
|
-
try {
|
|
863
|
-
const probeRes = await fetch(controlUrl("/routing/manual-providers/probe"), {
|
|
864
|
-
method: "POST",
|
|
865
|
-
headers: { "content-type": "application/json" },
|
|
866
|
-
body: JSON.stringify({
|
|
867
|
-
baseUrl: `${provider.url}/v1`,
|
|
868
|
-
apiKey: providerKey
|
|
869
|
-
})
|
|
870
|
-
});
|
|
871
|
-
expect(probeRes.status).toBe(200);
|
|
872
|
-
const probeBody = await probeRes.json() as { modelIds: string[] };
|
|
873
|
-
expect(probeBody.modelIds).toEqual(["gpt-local-ui", "gpt-local-ui-fast"]);
|
|
874
|
-
|
|
875
|
-
const createRes = await fetch(controlUrl("/routing/manual-providers/local"), {
|
|
876
|
-
method: "POST",
|
|
877
|
-
headers: { "content-type": "application/json" },
|
|
878
|
-
body: JSON.stringify({
|
|
879
|
-
id: "local-ui",
|
|
880
|
-
name: "Local UI",
|
|
881
|
-
baseUrl: `${provider.url}/v1`,
|
|
882
|
-
apiKey: providerKey
|
|
883
|
-
})
|
|
884
|
-
});
|
|
885
|
-
expect(createRes.status).toBe(201);
|
|
886
|
-
const created = await createRes.json() as {
|
|
887
|
-
provider: { id: string; models: string[]; keyRef?: { kind: string; name: string; configured: boolean }; apiKey?: string };
|
|
888
|
-
};
|
|
889
|
-
expect(created.provider).toMatchObject({
|
|
890
|
-
id: "local-ui",
|
|
891
|
-
models: ["gpt-local-ui", "gpt-local-ui-fast"],
|
|
892
|
-
keyRef: { kind: "secret", name: "local:local-ui", configured: true }
|
|
893
|
-
});
|
|
894
|
-
expect(created.provider.apiKey).toBeUndefined();
|
|
895
|
-
expect(JSON.stringify(created)).not.toContain(providerKey);
|
|
896
|
-
|
|
897
|
-
const modelsRes = await fetch(proxyUrl("/v1/models"));
|
|
898
|
-
expect(modelsRes.status).toBe(200);
|
|
899
|
-
const models = await modelsRes.json() as { data: Array<{ id: string; sellerId: string }> };
|
|
900
|
-
expect(models.data).toEqual(expect.arrayContaining([
|
|
901
|
-
expect.objectContaining({ id: "gpt-local-ui", sellerId: "local-ui" })
|
|
902
|
-
]));
|
|
903
|
-
|
|
904
|
-
const chatRes = await fetch(proxyUrl("/v1/chat/completions"), {
|
|
905
|
-
method: "POST",
|
|
906
|
-
headers: { "content-type": "application/json" },
|
|
907
|
-
body: JSON.stringify({
|
|
908
|
-
model: "gpt-local-ui",
|
|
909
|
-
messages: [{ role: "user", content: "hello" }]
|
|
910
|
-
})
|
|
911
|
-
});
|
|
912
|
-
expect(chatRes.status).toBe(200);
|
|
913
|
-
} finally {
|
|
914
|
-
await closeServer(provider.server);
|
|
915
|
-
}
|
|
916
|
-
});
|
|
917
|
-
|
|
918
|
-
it("updates a local manual provider using the stored secret", async () => {
|
|
919
|
-
const providerKey = "provider-local-edit-key";
|
|
920
|
-
const provider = await startJsonServer((req, res) => {
|
|
921
|
-
expect(req.headers.authorization).toBe(`Bearer ${providerKey}`);
|
|
922
|
-
if (req.method === "GET" && req.url === "/v1/models") {
|
|
923
|
-
res.writeHead(200, { "content-type": "application/json" });
|
|
924
|
-
res.end(JSON.stringify({
|
|
925
|
-
object: "list",
|
|
926
|
-
data: [
|
|
927
|
-
{ id: "gpt-local-edited", object: "model" },
|
|
928
|
-
{ id: "gpt-local-edited-fast", object: "model" }
|
|
929
|
-
]
|
|
930
|
-
}));
|
|
931
|
-
return;
|
|
932
|
-
}
|
|
933
|
-
res.writeHead(404, { "content-type": "application/json" });
|
|
934
|
-
res.end(JSON.stringify({ error: { message: "not found" } }));
|
|
935
|
-
});
|
|
936
|
-
|
|
937
|
-
try {
|
|
938
|
-
const createRes = await fetch(controlUrl("/routing/manual-providers/local"), {
|
|
939
|
-
method: "POST",
|
|
940
|
-
headers: { "content-type": "application/json" },
|
|
941
|
-
body: JSON.stringify({
|
|
942
|
-
id: "local-edit",
|
|
943
|
-
name: "Local Edit",
|
|
944
|
-
baseUrl: `${provider.url}/v1`,
|
|
945
|
-
apiKey: providerKey
|
|
946
|
-
})
|
|
947
|
-
});
|
|
948
|
-
expect(createRes.status).toBe(201);
|
|
949
|
-
|
|
950
|
-
const updateRes = await fetch(controlUrl("/routing/manual-providers/local/local-edit"), {
|
|
951
|
-
method: "PUT",
|
|
952
|
-
headers: { "content-type": "application/json" },
|
|
953
|
-
body: JSON.stringify({
|
|
954
|
-
name: "Local Edited",
|
|
955
|
-
baseUrl: `${provider.url}/v1`
|
|
956
|
-
})
|
|
957
|
-
});
|
|
958
|
-
expect(updateRes.status).toBe(200);
|
|
959
|
-
const updated = await updateRes.json() as {
|
|
960
|
-
provider: { id: string; name: string; models: string[]; keyRef?: { kind: string; name: string; configured: boolean }; apiKey?: string };
|
|
961
|
-
};
|
|
962
|
-
expect(updated.provider).toMatchObject({
|
|
963
|
-
id: "local-edit",
|
|
964
|
-
name: "Local Edited",
|
|
965
|
-
models: ["gpt-local-edited", "gpt-local-edited-fast"],
|
|
966
|
-
keyRef: { kind: "secret", name: "local:local-edit", configured: true }
|
|
967
|
-
});
|
|
968
|
-
expect(updated.provider.apiKey).toBeUndefined();
|
|
969
|
-
expect(JSON.stringify(updated)).not.toContain(providerKey);
|
|
970
|
-
} finally {
|
|
971
|
-
await closeServer(provider.server);
|
|
972
|
-
}
|
|
973
|
-
});
|
|
974
|
-
|
|
975
|
-
it("blocks local manual provider creation when model probing fails", async () => {
|
|
976
|
-
const providerKey = "provider-local-bad-key";
|
|
977
|
-
const provider = await startJsonServer((_req, res) => {
|
|
978
|
-
res.writeHead(401, { "content-type": "application/json" });
|
|
979
|
-
res.end(JSON.stringify({ error: { message: "unauthorized" } }));
|
|
980
|
-
});
|
|
981
|
-
|
|
982
|
-
try {
|
|
983
|
-
const createRes = await fetch(controlUrl("/routing/manual-providers/local"), {
|
|
984
|
-
method: "POST",
|
|
985
|
-
headers: { "content-type": "application/json" },
|
|
986
|
-
body: JSON.stringify({
|
|
987
|
-
id: "local-bad",
|
|
988
|
-
name: "Local Bad",
|
|
989
|
-
baseUrl: `${provider.url}/v1`,
|
|
990
|
-
apiKey: providerKey
|
|
991
|
-
})
|
|
992
|
-
});
|
|
993
|
-
expect(createRes.status).toBe(400);
|
|
994
|
-
const body = await createRes.json() as { error: { code: string; message: string } };
|
|
995
|
-
expect(body.error.code).toBe("manual_provider_local_create_failed");
|
|
996
|
-
expect(body.error.message).toContain("authentication failed");
|
|
997
|
-
expect(body.error.message).not.toContain(providerKey);
|
|
998
|
-
|
|
999
|
-
const listRes = await fetch(controlUrl("/routing/manual-providers"));
|
|
1000
|
-
const listBody = await listRes.json() as { providers: unknown[] };
|
|
1001
|
-
expect(listBody.providers).toEqual([]);
|
|
1002
|
-
} finally {
|
|
1003
|
-
await closeServer(provider.server);
|
|
1004
|
-
}
|
|
1005
|
-
});
|
|
1006
|
-
|
|
1007
|
-
it("saves disabled auto provider drafts without payment but gates enabled configs", async () => {
|
|
1008
|
-
const draftRes = await fetch(controlUrl("/routing/auto-provider"), {
|
|
1009
|
-
method: "PUT",
|
|
1010
|
-
headers: { "content-type": "application/json" },
|
|
1011
|
-
body: JSON.stringify({
|
|
1012
|
-
enabled: false,
|
|
1013
|
-
range: "custom",
|
|
1014
|
-
scorer: "discount",
|
|
1015
|
-
modelIds: ["gpt-4o"],
|
|
1016
|
-
sellerIds: ["tbs-86d81e"]
|
|
1017
|
-
})
|
|
1018
|
-
});
|
|
1019
|
-
expect(draftRes.status).toBe(200);
|
|
1020
|
-
const draft = await draftRes.json() as { config: { enabled: boolean; range: string; maxConcurrentProviders: number }; paymentRequired: boolean };
|
|
1021
|
-
expect(draft.config.enabled).toBe(false);
|
|
1022
|
-
expect(draft.config.range).toBe("custom");
|
|
1023
|
-
expect(draft.config.maxConcurrentProviders).toBe(10);
|
|
1024
|
-
expect(draft.paymentRequired).toBe(true);
|
|
1025
|
-
|
|
1026
|
-
const blockedRes = await fetch(controlUrl("/routing/auto-provider"), {
|
|
1027
|
-
method: "PUT",
|
|
1028
|
-
headers: { "content-type": "application/json" },
|
|
1029
|
-
body: JSON.stringify({
|
|
1030
|
-
enabled: true,
|
|
1031
|
-
range: "recommended",
|
|
1032
|
-
scorer: "balanced",
|
|
1033
|
-
modelIds: ["gpt-4o"]
|
|
1034
|
-
})
|
|
1035
|
-
});
|
|
1036
|
-
expect(blockedRes.status).toBe(409);
|
|
1037
|
-
const blocked = await blockedRes.json() as { error: { code: string } };
|
|
1038
|
-
expect(blocked.error.code).toBe("payment_required");
|
|
1039
|
-
});
|
|
1040
|
-
|
|
1041
|
-
it("applies enabled auto provider configs to the active routing strategy", async () => {
|
|
1042
|
-
markClawtipReady();
|
|
1043
|
-
|
|
1044
|
-
const emptyCustomRes = await fetch(controlUrl("/routing/auto-provider"), {
|
|
1045
|
-
method: "PUT",
|
|
1046
|
-
headers: { "content-type": "application/json" },
|
|
1047
|
-
body: JSON.stringify({
|
|
1048
|
-
enabled: true,
|
|
1049
|
-
range: "custom",
|
|
1050
|
-
scorer: "balanced",
|
|
1051
|
-
modelIds: ["gpt-4o"],
|
|
1052
|
-
sellerIds: []
|
|
1053
|
-
})
|
|
1054
|
-
});
|
|
1055
|
-
expect(emptyCustomRes.status).toBe(200);
|
|
1056
|
-
const emptyCustom = await emptyCustomRes.json() as { config: { enabled: boolean; range: string; sellerIds: string[] }; autoEnabled: boolean; strategy?: unknown };
|
|
1057
|
-
expect(emptyCustom.config).toMatchObject({ enabled: false, range: "custom", sellerIds: [] });
|
|
1058
|
-
expect(emptyCustom.autoEnabled).toBe(false);
|
|
1059
|
-
expect(emptyCustom.strategy).toBeUndefined();
|
|
1060
|
-
|
|
1061
|
-
const emptyCustomModeRes = await fetch(controlUrl("/routing/provider-mode"), {
|
|
1062
|
-
method: "PUT",
|
|
1063
|
-
headers: { "content-type": "application/json" },
|
|
1064
|
-
body: JSON.stringify({ mode: "auto" })
|
|
1065
|
-
});
|
|
1066
|
-
expect(emptyCustomModeRes.status).toBe(200);
|
|
1067
|
-
const emptyCustomMode = await emptyCustomModeRes.json() as { autoEnabled: boolean; strategy?: unknown };
|
|
1068
|
-
expect(emptyCustomMode.autoEnabled).toBe(false);
|
|
1069
|
-
expect(emptyCustomMode.strategy).toBeUndefined();
|
|
1070
|
-
|
|
1071
|
-
const recommendedRes = await fetch(controlUrl("/routing/auto-provider"), {
|
|
1072
|
-
method: "PUT",
|
|
1073
|
-
headers: { "content-type": "application/json" },
|
|
1074
|
-
body: JSON.stringify({
|
|
1075
|
-
enabled: true,
|
|
1076
|
-
range: "recommended",
|
|
1077
|
-
scorer: "speed",
|
|
1078
|
-
modelIds: ["gpt-4o"]
|
|
1079
|
-
})
|
|
1080
|
-
});
|
|
1081
|
-
expect(recommendedRes.status).toBe(200);
|
|
1082
|
-
const recommended = await recommendedRes.json() as { strategy: { mode: string; scorer: string }; mode: string; autoEnabled: boolean };
|
|
1083
|
-
expect(recommended.mode).toBe("auto");
|
|
1084
|
-
expect(recommended.autoEnabled).toBe(true);
|
|
1085
|
-
expect(recommended.strategy).toMatchObject({ mode: "fullAuto", scorer: "speed" });
|
|
1086
|
-
|
|
1087
|
-
const customRes = await fetch(controlUrl("/routing/auto-provider"), {
|
|
1088
|
-
method: "PUT",
|
|
1089
|
-
headers: { "content-type": "application/json" },
|
|
1090
|
-
body: JSON.stringify({
|
|
1091
|
-
enabled: true,
|
|
1092
|
-
range: "custom",
|
|
1093
|
-
scorer: "discount",
|
|
1094
|
-
modelIds: ["gpt-4o"],
|
|
1095
|
-
sellerIds: ["tbs-86d81e", "tbs-719577"]
|
|
1096
|
-
})
|
|
1097
|
-
});
|
|
1098
|
-
expect(customRes.status).toBe(200);
|
|
1099
|
-
const custom = await customRes.json() as { strategy: { mode: string; scorer: string; sellerIds?: string[] } };
|
|
1100
|
-
expect(custom.strategy).toMatchObject({
|
|
1101
|
-
mode: "fixedSet",
|
|
1102
|
-
scorer: "discount",
|
|
1103
|
-
sellerIds: ["tbs-86d81e", "tbs-719577"]
|
|
1104
|
-
});
|
|
1105
|
-
|
|
1106
|
-
const strategyRes = await fetch(controlUrl("/routing/strategy"));
|
|
1107
|
-
const strategy = await strategyRes.json() as { strategy: { mode: string; scorer: string; sellerIds?: string[] } };
|
|
1108
|
-
expect(strategy.strategy).toMatchObject({
|
|
1109
|
-
mode: "fixedSet",
|
|
1110
|
-
scorer: "discount",
|
|
1111
|
-
sellerIds: ["tbs-86d81e", "tbs-719577"]
|
|
1112
|
-
});
|
|
1113
|
-
});
|
|
1114
|
-
|
|
1115
|
-
it("routes manual provider requests with ordered fallback and records local observations", async () => {
|
|
1116
|
-
const previousA = process.env.TB_TEST_PROVIDER_A_KEY;
|
|
1117
|
-
const previousB = process.env.TB_TEST_PROVIDER_B_KEY;
|
|
1118
|
-
process.env.TB_TEST_PROVIDER_A_KEY = "provider-a-test-key";
|
|
1119
|
-
process.env.TB_TEST_PROVIDER_B_KEY = "provider-b-test-key";
|
|
1120
|
-
|
|
1121
|
-
const providerA = await startJsonServer((req, res) => {
|
|
1122
|
-
expect(req.headers.authorization).toBe("Bearer provider-a-test-key");
|
|
1123
|
-
res.writeHead(500, { "content-type": "application/json" });
|
|
1124
|
-
res.end(JSON.stringify({ error: { message: "temporary upstream failure" } }));
|
|
1125
|
-
});
|
|
1126
|
-
const providerB = await startJsonServer((req, res) => {
|
|
1127
|
-
expect(req.headers.authorization).toBe("Bearer provider-b-test-key");
|
|
1128
|
-
let body = "";
|
|
1129
|
-
req.on("data", (chunk) => {
|
|
1130
|
-
body += chunk.toString("utf8");
|
|
1131
|
-
});
|
|
1132
|
-
req.on("end", () => {
|
|
1133
|
-
const parsed = JSON.parse(body) as { model: string };
|
|
1134
|
-
expect(parsed.model).toBe("gpt-manual");
|
|
1135
|
-
res.writeHead(200, { "content-type": "application/json" });
|
|
1136
|
-
res.end(JSON.stringify({
|
|
1137
|
-
id: "chatcmpl-manual",
|
|
1138
|
-
object: "chat.completion",
|
|
1139
|
-
choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }],
|
|
1140
|
-
usage: { prompt_tokens: 3, completion_tokens: 2 }
|
|
1141
|
-
}));
|
|
1142
|
-
});
|
|
1143
|
-
});
|
|
1144
|
-
|
|
1145
|
-
try {
|
|
1146
|
-
for (const provider of [
|
|
1147
|
-
{ id: "local-a", name: "Local A", baseUrl: `${providerA.url}/v1`, apiKeyEnv: "TB_TEST_PROVIDER_A_KEY" },
|
|
1148
|
-
{ id: "local-b", name: "Local B", baseUrl: `${providerB.url}/v1`, apiKeyEnv: "TB_TEST_PROVIDER_B_KEY" }
|
|
1149
|
-
]) {
|
|
1150
|
-
const createRes = await fetch(controlUrl("/routing/manual-providers"), {
|
|
1151
|
-
method: "POST",
|
|
1152
|
-
headers: { "content-type": "application/json" },
|
|
1153
|
-
body: JSON.stringify({
|
|
1154
|
-
...provider,
|
|
1155
|
-
models: ["gpt-manual"],
|
|
1156
|
-
supportedProtocols: ["chat_completions"],
|
|
1157
|
-
enabled: true
|
|
1158
|
-
})
|
|
1159
|
-
});
|
|
1160
|
-
expect(createRes.status).toBe(201);
|
|
1161
|
-
}
|
|
1162
|
-
|
|
1163
|
-
const modelsRes = await fetch(proxyUrl("/v1/models"));
|
|
1164
|
-
expect(modelsRes.status).toBe(200);
|
|
1165
|
-
const models = await modelsRes.json() as { data: Array<{ id: string; sellerId: string; paymentMethods: string[] }> };
|
|
1166
|
-
expect(models.data.map((model) => [model.id, model.sellerId, model.paymentMethods[0]])).toEqual([
|
|
1167
|
-
["gpt-manual", "local-a", "provider_key"],
|
|
1168
|
-
["gpt-manual", "local-b", "provider_key"]
|
|
1169
|
-
]);
|
|
1170
|
-
|
|
1171
|
-
const response = await fetch(proxyUrl("/v1/chat/completions"), {
|
|
1172
|
-
method: "POST",
|
|
1173
|
-
headers: { "content-type": "application/json" },
|
|
1174
|
-
body: JSON.stringify({
|
|
1175
|
-
model: "gpt-manual",
|
|
1176
|
-
messages: [{ role: "user", content: "hello" }]
|
|
1177
|
-
})
|
|
1178
|
-
});
|
|
1179
|
-
expect(response.status).toBe(200);
|
|
1180
|
-
const body = await response.json() as { choices: unknown[] };
|
|
1181
|
-
expect(body.choices.length).toBe(1);
|
|
1182
|
-
|
|
1183
|
-
const purchases = await (await fetch(controlUrl("/ledger/purchases"))).json() as { purchases: unknown[] };
|
|
1184
|
-
expect(purchases.purchases).toEqual([]);
|
|
1185
|
-
const inferences = await (await fetch(controlUrl("/ledger/inferences"))).json() as {
|
|
1186
|
-
inferences: Array<{ sellerKey: string; paymentMethod?: string; priceVersion?: string; routeReason?: string; fallbackCount?: number }>;
|
|
1187
|
-
};
|
|
1188
|
-
expect(inferences.inferences).toHaveLength(1);
|
|
1189
|
-
expect(inferences.inferences[0]).toMatchObject({
|
|
1190
|
-
sellerKey: "local-b",
|
|
1191
|
-
paymentMethod: "provider_key",
|
|
1192
|
-
priceVersion: "local-provider:local-b",
|
|
1193
|
-
routeReason: "manual:fallback:routes_2",
|
|
1194
|
-
fallbackCount: 1
|
|
1195
|
-
});
|
|
1196
|
-
|
|
1197
|
-
const providersRes = await fetch(controlUrl("/routing/manual-providers"));
|
|
1198
|
-
const providersBody = await providersRes.json() as {
|
|
1199
|
-
providers: Array<{ id: string; current?: boolean; lastAccess?: string; status?: string; errorClass?: string }>;
|
|
1200
|
-
};
|
|
1201
|
-
expect(providersBody.providers).toEqual([
|
|
1202
|
-
expect.objectContaining({ id: "local-a", current: false, status: "degraded", errorClass: "upstream_5xx" }),
|
|
1203
|
-
expect.objectContaining({ id: "local-b", current: true, status: "healthy" })
|
|
1204
|
-
]);
|
|
1205
|
-
expect(providersBody.providers[0].lastAccess).toBeTruthy();
|
|
1206
|
-
expect(providersBody.providers[1].lastAccess).toBeTruthy();
|
|
1207
|
-
} finally {
|
|
1208
|
-
await closeServer(providerA.server);
|
|
1209
|
-
await closeServer(providerB.server);
|
|
1210
|
-
if (previousA === undefined) {
|
|
1211
|
-
delete process.env.TB_TEST_PROVIDER_A_KEY;
|
|
1212
|
-
} else {
|
|
1213
|
-
process.env.TB_TEST_PROVIDER_A_KEY = previousA;
|
|
1214
|
-
}
|
|
1215
|
-
if (previousB === undefined) {
|
|
1216
|
-
delete process.env.TB_TEST_PROVIDER_B_KEY;
|
|
1217
|
-
} else {
|
|
1218
|
-
process.env.TB_TEST_PROVIDER_B_KEY = previousB;
|
|
1219
|
-
}
|
|
1220
|
-
}
|
|
1221
|
-
});
|
|
1222
|
-
|
|
1223
|
-
it("locks manual routing to one provider without falling back", async () => {
|
|
1224
|
-
const previousA = process.env.TB_TEST_PROVIDER_LOCK_A_KEY;
|
|
1225
|
-
const previousB = process.env.TB_TEST_PROVIDER_LOCK_B_KEY;
|
|
1226
|
-
process.env.TB_TEST_PROVIDER_LOCK_A_KEY = "provider-lock-a-key";
|
|
1227
|
-
process.env.TB_TEST_PROVIDER_LOCK_B_KEY = "provider-lock-b-key";
|
|
1228
|
-
let providerBHits = 0;
|
|
1229
|
-
|
|
1230
|
-
const providerA = await startJsonServer((req, res) => {
|
|
1231
|
-
expect(req.headers.authorization).toBe("Bearer provider-lock-a-key");
|
|
1232
|
-
res.writeHead(500, { "content-type": "application/json" });
|
|
1233
|
-
res.end(JSON.stringify({ error: { message: "locked upstream failed" } }));
|
|
1234
|
-
});
|
|
1235
|
-
const providerB = await startJsonServer((req, res) => {
|
|
1236
|
-
providerBHits += 1;
|
|
1237
|
-
expect(req.headers.authorization).toBe("Bearer provider-lock-b-key");
|
|
1238
|
-
res.writeHead(200, { "content-type": "application/json" });
|
|
1239
|
-
res.end(JSON.stringify({
|
|
1240
|
-
id: "chatcmpl-should-not-use-backup",
|
|
1241
|
-
object: "chat.completion",
|
|
1242
|
-
choices: [{ index: 0, message: { role: "assistant", content: "backup" }, finish_reason: "stop" }],
|
|
1243
|
-
usage: { prompt_tokens: 1, completion_tokens: 1 }
|
|
1244
|
-
}));
|
|
1245
|
-
});
|
|
1246
|
-
|
|
1247
|
-
try {
|
|
1248
|
-
for (const provider of [
|
|
1249
|
-
{ id: "lock-a", name: "Lock A", baseUrl: `${providerA.url}/v1`, apiKeyEnv: "TB_TEST_PROVIDER_LOCK_A_KEY" },
|
|
1250
|
-
{ id: "lock-b", name: "Lock B", baseUrl: `${providerB.url}/v1`, apiKeyEnv: "TB_TEST_PROVIDER_LOCK_B_KEY" }
|
|
1251
|
-
]) {
|
|
1252
|
-
const createRes = await fetch(controlUrl("/routing/manual-providers"), {
|
|
1253
|
-
method: "POST",
|
|
1254
|
-
headers: { "content-type": "application/json" },
|
|
1255
|
-
body: JSON.stringify({
|
|
1256
|
-
...provider,
|
|
1257
|
-
models: ["gpt-locked"],
|
|
1258
|
-
supportedProtocols: ["chat_completions"],
|
|
1259
|
-
enabled: true
|
|
1260
|
-
})
|
|
1261
|
-
});
|
|
1262
|
-
expect(createRes.status).toBe(201);
|
|
1263
|
-
}
|
|
1264
|
-
|
|
1265
|
-
const lockRes = await fetch(controlUrl("/routing/manual-providers/routing"), {
|
|
1266
|
-
method: "PUT",
|
|
1267
|
-
headers: { "content-type": "application/json" },
|
|
1268
|
-
body: JSON.stringify({ policy: "locked", lockedProviderId: "lock-a" })
|
|
1269
|
-
});
|
|
1270
|
-
expect(lockRes.status).toBe(200);
|
|
1271
|
-
const lockBody = await lockRes.json() as { routing: { policy: string; lockedProviderId?: string } };
|
|
1272
|
-
expect(lockBody.routing).toEqual({ policy: "locked", lockedProviderId: "lock-a" });
|
|
1273
|
-
|
|
1274
|
-
const response = await fetch(proxyUrl("/v1/chat/completions"), {
|
|
1275
|
-
method: "POST",
|
|
1276
|
-
headers: { "content-type": "application/json" },
|
|
1277
|
-
body: JSON.stringify({
|
|
1278
|
-
model: "gpt-locked",
|
|
1279
|
-
messages: [{ role: "user", content: "hello" }]
|
|
1280
|
-
})
|
|
1281
|
-
});
|
|
1282
|
-
expect(response.status).toBe(500);
|
|
1283
|
-
expect(providerBHits).toBe(0);
|
|
1284
|
-
|
|
1285
|
-
const providersRes = await fetch(controlUrl("/routing/manual-providers"));
|
|
1286
|
-
const providersBody = await providersRes.json() as {
|
|
1287
|
-
routing: { policy: string; lockedProviderId?: string };
|
|
1288
|
-
providers: Array<{ id: string; current?: boolean; status?: string; errorClass?: string }>;
|
|
1289
|
-
};
|
|
1290
|
-
expect(providersBody.routing).toEqual({ policy: "locked", lockedProviderId: "lock-a" });
|
|
1291
|
-
expect(providersBody.providers).toEqual([
|
|
1292
|
-
expect.objectContaining({ id: "lock-a", current: false, status: "degraded", errorClass: "upstream_5xx" }),
|
|
1293
|
-
expect.objectContaining({ id: "lock-b" })
|
|
1294
|
-
]);
|
|
1295
|
-
|
|
1296
|
-
const fallbackRes = await fetch(controlUrl("/routing/manual-providers/routing"), {
|
|
1297
|
-
method: "PUT",
|
|
1298
|
-
headers: { "content-type": "application/json" },
|
|
1299
|
-
body: JSON.stringify({ policy: "fallback" })
|
|
1300
|
-
});
|
|
1301
|
-
expect(fallbackRes.status).toBe(200);
|
|
1302
|
-
const fallbackBody = await fallbackRes.json() as { routing: { policy: string; lockedProviderId?: string } };
|
|
1303
|
-
expect(fallbackBody.routing).toEqual({ policy: "fallback" });
|
|
1304
|
-
} finally {
|
|
1305
|
-
await closeServer(providerA.server);
|
|
1306
|
-
await closeServer(providerB.server);
|
|
1307
|
-
if (previousA === undefined) {
|
|
1308
|
-
delete process.env.TB_TEST_PROVIDER_LOCK_A_KEY;
|
|
1309
|
-
} else {
|
|
1310
|
-
process.env.TB_TEST_PROVIDER_LOCK_A_KEY = previousA;
|
|
1311
|
-
}
|
|
1312
|
-
if (previousB === undefined) {
|
|
1313
|
-
delete process.env.TB_TEST_PROVIDER_LOCK_B_KEY;
|
|
1314
|
-
} else {
|
|
1315
|
-
process.env.TB_TEST_PROVIDER_LOCK_B_KEY = previousB;
|
|
1316
|
-
}
|
|
1317
|
-
}
|
|
1318
|
-
});
|
|
1319
|
-
|
|
1320
|
-
it("reorders manual provider fallback priority", async () => {
|
|
1321
|
-
const previousA = process.env.TB_TEST_PROVIDER_ORDER_A_KEY;
|
|
1322
|
-
const previousB = process.env.TB_TEST_PROVIDER_ORDER_B_KEY;
|
|
1323
|
-
process.env.TB_TEST_PROVIDER_ORDER_A_KEY = "provider-order-a-key";
|
|
1324
|
-
process.env.TB_TEST_PROVIDER_ORDER_B_KEY = "provider-order-b-key";
|
|
1325
|
-
let providerAHits = 0;
|
|
1326
|
-
let providerBHits = 0;
|
|
1327
|
-
|
|
1328
|
-
const providerA = await startJsonServer((req, res) => {
|
|
1329
|
-
providerAHits += 1;
|
|
1330
|
-
expect(req.headers.authorization).toBe("Bearer provider-order-a-key");
|
|
1331
|
-
res.writeHead(200, { "content-type": "application/json" });
|
|
1332
|
-
res.end(JSON.stringify({
|
|
1333
|
-
id: "chatcmpl-order-a",
|
|
1334
|
-
object: "chat.completion",
|
|
1335
|
-
choices: [{ index: 0, message: { role: "assistant", content: "a" }, finish_reason: "stop" }],
|
|
1336
|
-
usage: { prompt_tokens: 1, completion_tokens: 1 }
|
|
1337
|
-
}));
|
|
1338
|
-
});
|
|
1339
|
-
const providerB = await startJsonServer((req, res) => {
|
|
1340
|
-
providerBHits += 1;
|
|
1341
|
-
expect(req.headers.authorization).toBe("Bearer provider-order-b-key");
|
|
1342
|
-
res.writeHead(200, { "content-type": "application/json" });
|
|
1343
|
-
res.end(JSON.stringify({
|
|
1344
|
-
id: "chatcmpl-order-b",
|
|
1345
|
-
object: "chat.completion",
|
|
1346
|
-
choices: [{ index: 0, message: { role: "assistant", content: "b" }, finish_reason: "stop" }],
|
|
1347
|
-
usage: { prompt_tokens: 1, completion_tokens: 1 }
|
|
1348
|
-
}));
|
|
1349
|
-
});
|
|
1350
|
-
|
|
1351
|
-
try {
|
|
1352
|
-
for (const provider of [
|
|
1353
|
-
{ id: "order-a", name: "Order A", baseUrl: `${providerA.url}/v1`, apiKeyEnv: "TB_TEST_PROVIDER_ORDER_A_KEY" },
|
|
1354
|
-
{ id: "order-b", name: "Order B", baseUrl: `${providerB.url}/v1`, apiKeyEnv: "TB_TEST_PROVIDER_ORDER_B_KEY" }
|
|
1355
|
-
]) {
|
|
1356
|
-
const createRes = await fetch(controlUrl("/routing/manual-providers"), {
|
|
1357
|
-
method: "POST",
|
|
1358
|
-
headers: { "content-type": "application/json" },
|
|
1359
|
-
body: JSON.stringify({
|
|
1360
|
-
...provider,
|
|
1361
|
-
models: ["gpt-order"],
|
|
1362
|
-
supportedProtocols: ["chat_completions"],
|
|
1363
|
-
enabled: true
|
|
1364
|
-
})
|
|
1365
|
-
});
|
|
1366
|
-
expect(createRes.status).toBe(201);
|
|
1367
|
-
}
|
|
1368
|
-
|
|
1369
|
-
const orderRes = await fetch(controlUrl("/routing/manual-providers/order"), {
|
|
1370
|
-
method: "PUT",
|
|
1371
|
-
headers: { "content-type": "application/json" },
|
|
1372
|
-
body: JSON.stringify({ providerIds: ["order-b", "order-a"] })
|
|
1373
|
-
});
|
|
1374
|
-
expect(orderRes.status).toBe(200);
|
|
1375
|
-
const orderBody = await orderRes.json() as { providers: Array<{ id: string }> };
|
|
1376
|
-
expect(orderBody.providers.map((provider) => provider.id)).toEqual(["order-b", "order-a"]);
|
|
1377
|
-
|
|
1378
|
-
const response = await fetch(proxyUrl("/v1/chat/completions"), {
|
|
1379
|
-
method: "POST",
|
|
1380
|
-
headers: { "content-type": "application/json" },
|
|
1381
|
-
body: JSON.stringify({
|
|
1382
|
-
model: "gpt-order",
|
|
1383
|
-
messages: [{ role: "user", content: "hello" }]
|
|
1384
|
-
})
|
|
1385
|
-
});
|
|
1386
|
-
expect(response.status).toBe(200);
|
|
1387
|
-
expect(providerBHits).toBe(1);
|
|
1388
|
-
expect(providerAHits).toBe(0);
|
|
1389
|
-
} finally {
|
|
1390
|
-
await closeServer(providerA.server);
|
|
1391
|
-
await closeServer(providerB.server);
|
|
1392
|
-
if (previousA === undefined) {
|
|
1393
|
-
delete process.env.TB_TEST_PROVIDER_ORDER_A_KEY;
|
|
1394
|
-
} else {
|
|
1395
|
-
process.env.TB_TEST_PROVIDER_ORDER_A_KEY = previousA;
|
|
1396
|
-
}
|
|
1397
|
-
if (previousB === undefined) {
|
|
1398
|
-
delete process.env.TB_TEST_PROVIDER_ORDER_B_KEY;
|
|
1399
|
-
} else {
|
|
1400
|
-
process.env.TB_TEST_PROVIDER_ORDER_B_KEY = previousB;
|
|
1401
|
-
}
|
|
1402
|
-
}
|
|
1403
|
-
});
|
|
1404
|
-
});
|
|
1405
|
-
|
|
1406
|
-
// ─── GET /routing/strategy ────────────────────────────────────
|
|
1407
|
-
describe("GET /routing/strategy", () => {
|
|
1408
|
-
it("returns default strategy with source=default when no store value", async () => {
|
|
1409
|
-
const res = await fetch(controlUrl("/routing/strategy"));
|
|
1410
|
-
expect(res.status).toBe(200);
|
|
1411
|
-
const body = await res.json() as {
|
|
1412
|
-
strategy: { mode: string; scorer: string };
|
|
1413
|
-
source: string;
|
|
1414
|
-
};
|
|
1415
|
-
expect(body.strategy.mode).toBe("fullAuto");
|
|
1416
|
-
expect(body.strategy.scorer).toBe("balanced");
|
|
1417
|
-
expect(body.source).toBe("default");
|
|
1418
|
-
});
|
|
1419
|
-
|
|
1420
|
-
it("returns source=store after PUT /routing/strategy writes a value", async () => {
|
|
1421
|
-
// 写入策略
|
|
1422
|
-
const putRes = await fetch(controlUrl("/routing/strategy"), {
|
|
1423
|
-
method: "PUT",
|
|
1424
|
-
headers: { "content-type": "application/json" },
|
|
1425
|
-
body: JSON.stringify({ mode: "fixedSet", scorer: "speed", sellerIds: ["tbs-86d81e", "tbs-719577"] })
|
|
1426
|
-
});
|
|
1427
|
-
expect(putRes.status).toBe(200);
|
|
1428
|
-
// 再读
|
|
1429
|
-
const getRes = await fetch(controlUrl("/routing/strategy"));
|
|
1430
|
-
const body = await getRes.json() as { strategy: { mode: string; scorer: string; sellerIds?: string[] }; source: string };
|
|
1431
|
-
expect(body.strategy.mode).toBe("fixedSet");
|
|
1432
|
-
expect(body.strategy.scorer).toBe("speed");
|
|
1433
|
-
expect(body.strategy.sellerIds).toEqual(["tbs-86d81e", "tbs-719577"]);
|
|
1434
|
-
expect(body.source).toBe("store");
|
|
1435
|
-
});
|
|
1436
|
-
});
|
|
1437
|
-
|
|
1438
|
-
// ─── GET /routing/preview ─────────────────────────────────────
|
|
1439
|
-
describe("GET /routing/preview", () => {
|
|
1440
|
-
it("returns a plan with the default (fullAuto, balanced) strategy", async () => {
|
|
1441
|
-
const res = await fetch(controlUrl("/routing/preview?modelId=claude-sonnet-4-5"));
|
|
1442
|
-
expect(res.status).toBe(200);
|
|
1443
|
-
const body = await res.json() as {
|
|
1444
|
-
modelId: string;
|
|
1445
|
-
plan: { mode: string; scorer: string; routes: Array<{ sellerId: string }>; reason: string };
|
|
1446
|
-
};
|
|
1447
|
-
expect(body.modelId).toBe("claude-sonnet-4-5");
|
|
1448
|
-
expect(body.plan.mode).toBe("fullAuto");
|
|
1449
|
-
expect(body.plan.scorer).toBe("balanced");
|
|
1450
|
-
expect(body.plan.routes.length).toBeGreaterThan(0);
|
|
1451
|
-
expect(body.plan.reason).toMatch(/^fullAuto:balanced:routes_\d+$/);
|
|
1452
|
-
});
|
|
1453
|
-
|
|
1454
|
-
it("resolves fixedByModel for the requested model", async () => {
|
|
1455
|
-
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"));
|
|
1456
|
-
expect(res.status).toBe(200);
|
|
1457
|
-
const body = await res.json() as {
|
|
1458
|
-
plan: { mode: string; scorer: string; routes: Array<{ seller: { id: string } }>; reason: string };
|
|
1459
|
-
};
|
|
1460
|
-
expect(body.plan.mode).toBe("fixed");
|
|
1461
|
-
expect(body.plan.scorer).toBe("speed");
|
|
1462
|
-
expect(body.plan.routes.map((route) => route.seller.id)).toEqual(["tbs-825edb"]);
|
|
1463
|
-
expect(body.plan.reason).toBe("fixed:speed:routes_1");
|
|
1464
|
-
});
|
|
1465
|
-
|
|
1466
|
-
it("rejects an invalid mode in the query string", async () => {
|
|
1467
|
-
const res = await fetch(controlUrl("/routing/preview?mode=invalid"));
|
|
1468
|
-
expect(res.status).toBe(400);
|
|
1469
|
-
const body = await res.json() as { error: { code: string } };
|
|
1470
|
-
expect(body.error.code).toBe("routing_preview_failed");
|
|
1471
|
-
});
|
|
1472
|
-
|
|
1473
|
-
it("returns 409 with registry_not_loaded when snapshot is cleared", async () => {
|
|
1474
|
-
daemon.setLastRegistrySnapshotForTest(null);
|
|
1475
|
-
const res = await fetch(controlUrl("/routing/preview?modelId=claude-sonnet-4-5"));
|
|
1476
|
-
expect(res.status).toBe(409);
|
|
1477
|
-
const body = await res.json() as { error: { code: string } };
|
|
1478
|
-
expect(body.error.code).toBe("registry_not_loaded");
|
|
1479
|
-
});
|
|
1480
|
-
});
|
|
1481
|
-
|
|
1482
|
-
// ─── PUT /routing/strategy ────────────────────────────────────
|
|
1483
|
-
describe("PUT /routing/strategy", () => {
|
|
1484
|
-
it("rejects fixed mode without sellerId", async () => {
|
|
1485
|
-
const res = await fetch(controlUrl("/routing/strategy"), {
|
|
1486
|
-
method: "PUT",
|
|
1487
|
-
headers: { "content-type": "application/json" },
|
|
1488
|
-
body: JSON.stringify({ mode: "fixed", scorer: "balanced" })
|
|
1489
|
-
});
|
|
1490
|
-
expect(res.status).toBe(400);
|
|
1491
|
-
const body = await res.json() as { error: { code: string; message: string } };
|
|
1492
|
-
expect(body.error.code).toBe("routing_strategy_apply_failed");
|
|
1493
|
-
expect(body.error.message).toMatch(/fixed/);
|
|
1494
|
-
});
|
|
1495
|
-
|
|
1496
|
-
it("rejects an invalid mode value", async () => {
|
|
1497
|
-
const res = await fetch(controlUrl("/routing/strategy"), {
|
|
1498
|
-
method: "PUT",
|
|
1499
|
-
headers: { "content-type": "application/json" },
|
|
1500
|
-
body: JSON.stringify({ mode: "auto", scorer: "balanced" })
|
|
1501
|
-
});
|
|
1502
|
-
expect(res.status).toBe(400);
|
|
1503
|
-
});
|
|
1504
|
-
|
|
1505
|
-
it("applies a valid fullAuto strategy and returns the preview", async () => {
|
|
1506
|
-
const res = await fetch(controlUrl("/routing/strategy"), {
|
|
1507
|
-
method: "PUT",
|
|
1508
|
-
headers: { "content-type": "application/json" },
|
|
1509
|
-
body: JSON.stringify({ mode: "fullAuto", scorer: "discount" })
|
|
1510
|
-
});
|
|
1511
|
-
expect(res.status).toBe(200);
|
|
1512
|
-
const body = await res.json() as Record<string, unknown>;
|
|
1513
|
-
const body2 = body as {
|
|
1514
|
-
applied: boolean;
|
|
1515
|
-
strategy: { mode: string; scorer: string };
|
|
1516
|
-
preview: { modelId?: string; mode?: string; scorer?: string; routes?: unknown[]; error?: string };
|
|
1517
|
-
};
|
|
1518
|
-
expect(body2.applied).toBe(true);
|
|
1519
|
-
expect(body2.strategy.mode).toBe("fullAuto");
|
|
1520
|
-
expect(body2.strategy.scorer).toBe("discount");
|
|
1521
|
-
expect(body2.preview.mode).toBe("fullAuto");
|
|
1522
|
-
expect(body2.preview.scorer).toBe("discount");
|
|
1523
|
-
expect((body2.preview.routes ?? []).length).toBeGreaterThan(0);
|
|
1524
|
-
});
|
|
1525
|
-
|
|
1526
|
-
it("persists applied strategy across GET requests (hot reload)", async () => {
|
|
1527
|
-
await fetch(controlUrl("/routing/strategy"), {
|
|
1528
|
-
method: "PUT",
|
|
1529
|
-
headers: { "content-type": "application/json" },
|
|
1530
|
-
body: JSON.stringify({ mode: "fixed", scorer: "balanced", sellerId: "tbs-86d81e" })
|
|
1531
|
-
});
|
|
1532
|
-
// 再读,daemon 内部 routing 应当是 fixed
|
|
1533
|
-
const res = await fetch(controlUrl("/routing/strategy"));
|
|
1534
|
-
const body = await res.json() as { strategy: { mode: string; sellerId?: string } };
|
|
1535
|
-
expect(body.strategy.mode).toBe("fixed");
|
|
1536
|
-
expect(body.strategy.sellerId).toBe("tbs-86d81e");
|
|
1537
|
-
});
|
|
1538
|
-
|
|
1539
|
-
it("persists fixed sellers per model and previews the matching seller", async () => {
|
|
1540
|
-
const putRes = await fetch(controlUrl("/routing/strategy"), {
|
|
1541
|
-
method: "PUT",
|
|
1542
|
-
headers: { "content-type": "application/json" },
|
|
1543
|
-
body: JSON.stringify({
|
|
1544
|
-
mode: "fixed",
|
|
1545
|
-
scorer: "speed",
|
|
1546
|
-
fixedByModel: {
|
|
1547
|
-
"claude-sonnet-4-5": "tbs-719577",
|
|
1548
|
-
"gpt-4o": "tbs-825edb"
|
|
1549
|
-
}
|
|
1550
|
-
})
|
|
1551
|
-
});
|
|
1552
|
-
expect(putRes.status).toBe(200);
|
|
1553
|
-
|
|
1554
|
-
const getRes = await fetch(controlUrl("/routing/strategy"));
|
|
1555
|
-
const strategyBody = await getRes.json() as {
|
|
1556
|
-
strategy: { mode: string; fixedByModel?: Record<string, string> };
|
|
1557
|
-
};
|
|
1558
|
-
expect(strategyBody.strategy.mode).toBe("fixed");
|
|
1559
|
-
expect(strategyBody.strategy.fixedByModel).toEqual({
|
|
1560
|
-
"claude-sonnet-4-5": "tbs-719577",
|
|
1561
|
-
"gpt-4o": "tbs-825edb"
|
|
1562
|
-
});
|
|
1563
|
-
|
|
1564
|
-
const previewRes = await fetch(controlUrl("/routing/preview?modelId=claude-sonnet-4-5"));
|
|
1565
|
-
expect(previewRes.status).toBe(200);
|
|
1566
|
-
const previewBody = await previewRes.json() as {
|
|
1567
|
-
plan: { routes: Array<{ seller: { id: string } }>; reason: string };
|
|
1568
|
-
};
|
|
1569
|
-
expect(previewBody.plan.routes.map((route) => route.seller.id)).toEqual(["tbs-719577"]);
|
|
1570
|
-
expect(previewBody.plan.reason).toBe("fixed:speed:routes_1");
|
|
1571
|
-
});
|
|
1572
|
-
});
|
|
1573
|
-
|
|
1574
|
-
// ─── PUT /prewarm/focus-set ───────────────────────────────────
|
|
1575
|
-
describe("PUT /prewarm/focus-set", () => {
|
|
1576
|
-
it("sets an explicit focus set and reports source=explicit", async () => {
|
|
1577
|
-
const res = await fetch(controlUrl("/prewarm/focus-set"), {
|
|
1578
|
-
method: "PUT",
|
|
1579
|
-
headers: { "content-type": "application/json" },
|
|
1580
|
-
body: JSON.stringify({ models: ["claude-sonnet-4-5", "gpt-4o"] })
|
|
1581
|
-
});
|
|
1582
|
-
expect(res.status).toBe(200);
|
|
1583
|
-
const body = await res.json() as { ok: boolean; focusSet: string[]; source: string };
|
|
1584
|
-
expect(body.ok).toBe(true);
|
|
1585
|
-
expect(body.focusSet).toEqual(["claude-sonnet-4-5", "gpt-4o"]);
|
|
1586
|
-
expect(body.source).toBe("explicit");
|
|
1587
|
-
});
|
|
1588
|
-
|
|
1589
|
-
it("dedupes and trims the models array", async () => {
|
|
1590
|
-
const res = await fetch(controlUrl("/prewarm/focus-set"), {
|
|
1591
|
-
method: "PUT",
|
|
1592
|
-
headers: { "content-type": "application/json" },
|
|
1593
|
-
body: JSON.stringify({ models: [" gpt-4o ", "gpt-4o", "claude-sonnet-4-5", ""] })
|
|
1594
|
-
});
|
|
1595
|
-
expect(res.status).toBe(200);
|
|
1596
|
-
const body = await res.json() as { focusSet: string[] };
|
|
1597
|
-
expect(body.focusSet).toEqual(["gpt-4o", "claude-sonnet-4-5"]);
|
|
1598
|
-
});
|
|
1599
|
-
|
|
1600
|
-
it("rejects a body without models and without clear flag", async () => {
|
|
1601
|
-
const res = await fetch(controlUrl("/prewarm/focus-set"), {
|
|
1602
|
-
method: "PUT",
|
|
1603
|
-
headers: { "content-type": "application/json" },
|
|
1604
|
-
body: JSON.stringify({})
|
|
1605
|
-
});
|
|
1606
|
-
expect(res.status).toBe(400);
|
|
1607
|
-
const body = await res.json() as { error: { code: string } };
|
|
1608
|
-
expect(body.error.code).toBe("invalid_focus_set");
|
|
1609
|
-
});
|
|
1610
|
-
|
|
1611
|
-
it("clear=true falls back to env/historical (not explicit)", async () => {
|
|
1612
|
-
// 先设置一个 explicit
|
|
1613
|
-
await fetch(controlUrl("/prewarm/focus-set"), {
|
|
1614
|
-
method: "PUT",
|
|
1615
|
-
headers: { "content-type": "application/json" },
|
|
1616
|
-
body: JSON.stringify({ models: ["gpt-4o"] })
|
|
1617
|
-
});
|
|
1618
|
-
// clear
|
|
1619
|
-
const res = await fetch(controlUrl("/prewarm/focus-set"), {
|
|
1620
|
-
method: "PUT",
|
|
1621
|
-
headers: { "content-type": "application/json" },
|
|
1622
|
-
body: JSON.stringify({ clear: true })
|
|
1623
|
-
});
|
|
1624
|
-
expect(res.status).toBe(200);
|
|
1625
|
-
const body = await res.json() as { ok: boolean; source: string };
|
|
1626
|
-
expect(body.ok).toBe(true);
|
|
1627
|
-
expect(body.source).not.toBe("explicit");
|
|
1628
|
-
});
|
|
1629
|
-
});
|
|
1630
|
-
});
|