@tokenbuddy/tokenbuddy 1.0.29 → 1.0.31
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/daemon.d.ts +11 -4
- package/dist/src/daemon.d.ts.map +1 -1
- package/dist/src/daemon.js +130 -42
- package/dist/src/daemon.js.map +1 -1
- package/dist/src/doctor-diagnostics.d.ts.map +1 -1
- package/dist/src/doctor-diagnostics.js +7 -1
- package/dist/src/doctor-diagnostics.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 +1 -0
- 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 -1
- package/dist/src/prewarm-scheduler.js.map +1 -1
- package/dist/src/provider-install.d.ts.map +1 -1
- package/dist/src/provider-install.js +196 -18
- package/dist/src/provider-install.js.map +1 -1
- package/dist/src/seller-catalog.d.ts +4 -0
- package/dist/src/seller-catalog.d.ts.map +1 -1
- package/dist/src/seller-catalog.js.map +1 -1
- package/dist/src/seller-pool.d.ts +13 -0
- package/dist/src/seller-pool.d.ts.map +1 -1
- package/dist/src/seller-pool.js +43 -2
- package/dist/src/seller-pool.js.map +1 -1
- package/dist/src/seller-route-planner.d.ts +9 -0
- package/dist/src/seller-route-planner.d.ts.map +1 -1
- package/dist/src/seller-route-planner.js +39 -15
- package/dist/src/seller-route-planner.js.map +1 -1
- package/dist/src/seller-routing-strategy.d.ts +6 -4
- package/dist/src/seller-routing-strategy.d.ts.map +1 -1
- package/dist/src/seller-routing-strategy.js +15 -12
- package/dist/src/seller-routing-strategy.js.map +1 -1
- package/dist/src/terminal-detect.d.ts +5 -5
- package/dist/src/terminal-detect.d.ts.map +1 -1
- package/dist/src/terminal-detect.js +79 -26
- package/dist/src/terminal-detect.js.map +1 -1
- package/package.json +1 -1
- package/src/daemon.ts +168 -46
- package/src/doctor-diagnostics.ts +5 -1
- package/src/prewarm-cache.ts +5 -0
- package/src/prewarm-scheduler.ts +6 -1
- package/src/provider-install.ts +203 -18
- package/src/seller-catalog.ts +4 -0
- package/src/seller-pool.ts +68 -2
- package/src/seller-route-planner.ts +61 -15
- package/src/seller-routing-strategy.ts +21 -16
- package/src/terminal-detect.ts +81 -24
- package/static/ui/assets/index-DEDEl8o2.js +236 -0
- package/static/ui/assets/{index-UAfOhbwC.js.map → index-DEDEl8o2.js.map} +1 -1
- package/static/ui/index.html +1 -1
- package/tests/control-plane-ui-endpoints.test.ts +73 -0
- package/tests/seller-pool.test.ts +55 -0
- package/tests/seller-route-planner.test.ts +45 -1
- package/tests/seller-routing-strategy.test.ts +6 -5
- package/tests/tokenbuddy.test.ts +346 -38
- package/static/ui/assets/index-UAfOhbwC.js +0 -236
package/static/ui/index.html
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
<link rel="icon" type="image/png" sizes="192x192" href="/icons/tokenbuddy-192.png" />
|
|
13
13
|
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
|
|
14
14
|
<title>TokenBuddy · Local Control</title>
|
|
15
|
-
<script type="module" crossorigin src="/assets/index-
|
|
15
|
+
<script type="module" crossorigin src="/assets/index-DEDEl8o2.js"></script>
|
|
16
16
|
<link rel="stylesheet" crossorigin href="/assets/index-Bzbrp7Qe.css">
|
|
17
17
|
</head>
|
|
18
18
|
<body>
|
|
@@ -552,6 +552,79 @@ describe("TokenbuddyDaemon control-plane UI endpoints (PR-0)", () => {
|
|
|
552
552
|
expect(body.summary.configuredCount).toBeGreaterThanOrEqual(1);
|
|
553
553
|
expect(body.summary.installCommand).toBe("tb init");
|
|
554
554
|
});
|
|
555
|
+
|
|
556
|
+
it("applies tb-ui provider install requests to active OpenClaw and Hermes config files", async () => {
|
|
557
|
+
daemon.stop();
|
|
558
|
+
await startDaemon({ providerHomeDir: TEMP_HOME });
|
|
559
|
+
|
|
560
|
+
const res = await fetch(controlUrl("/providers/install/apply"), {
|
|
561
|
+
method: "POST",
|
|
562
|
+
headers: { "content-type": "application/json" },
|
|
563
|
+
body: JSON.stringify({
|
|
564
|
+
home: TEMP_HOME,
|
|
565
|
+
providers: ["openclaw", "hermes"],
|
|
566
|
+
proxyUrl: `http://127.0.0.1:${proxyPort}`,
|
|
567
|
+
providerSelections: {
|
|
568
|
+
openclaw: {
|
|
569
|
+
selectionKind: "single-model",
|
|
570
|
+
protocolPreference: "chat_completions",
|
|
571
|
+
defaultModel: "gpt-5.4"
|
|
572
|
+
},
|
|
573
|
+
hermes: {
|
|
574
|
+
selectionKind: "single-model",
|
|
575
|
+
protocolPreference: "chat_completions",
|
|
576
|
+
defaultModel: "gpt-5.4"
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
})
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
expect(res.status).toBe(200);
|
|
583
|
+
const body = await res.json() as { applied: Array<{ providerId: string; path: string; action: string }> };
|
|
584
|
+
expect(body.applied).toEqual(expect.arrayContaining([
|
|
585
|
+
expect.objectContaining({
|
|
586
|
+
providerId: "openclaw",
|
|
587
|
+
path: path.join(TEMP_HOME, ".openclaw", "openclaw.json")
|
|
588
|
+
}),
|
|
589
|
+
expect.objectContaining({
|
|
590
|
+
providerId: "hermes",
|
|
591
|
+
path: path.join(TEMP_HOME, ".hermes", "config.yaml")
|
|
592
|
+
})
|
|
593
|
+
]));
|
|
594
|
+
|
|
595
|
+
const openclaw = JSON.parse(fs.readFileSync(path.join(TEMP_HOME, ".openclaw", "openclaw.json"), "utf8"));
|
|
596
|
+
expect(openclaw.models.providers.tokenbuddy.baseUrl).toBe(`http://127.0.0.1:${proxyPort}/v1`);
|
|
597
|
+
expect(openclaw.models.providers.tokenbuddy.apiKey).toBe("TOKENBUDDY_PROXY");
|
|
598
|
+
expect(openclaw.models.providers.tokenbuddy.auth).toBe("api-key");
|
|
599
|
+
expect(openclaw.models.providers.tokenbuddy.api).toBe("openai-completions");
|
|
600
|
+
expect(openclaw.models.providers.tokenbuddy.models).toEqual(expect.arrayContaining([
|
|
601
|
+
expect.objectContaining({ id: "gpt-5.4", api: "openai-completions" })
|
|
602
|
+
]));
|
|
603
|
+
expect(openclaw.agents.defaults.model).toBe("tokenbuddy/gpt-5.4");
|
|
604
|
+
|
|
605
|
+
const hermes = fs.readFileSync(path.join(TEMP_HOME, ".hermes", "config.yaml"), "utf8");
|
|
606
|
+
expect(hermes).toContain("default: gpt-5.4");
|
|
607
|
+
expect(hermes).toContain("provider: custom");
|
|
608
|
+
expect(hermes).toContain(`base_url: "http://127.0.0.1:${proxyPort}/v1"`);
|
|
609
|
+
expect(hermes).toContain("api_key: TOKENBUDDY_PROXY");
|
|
610
|
+
expect(hermes).toContain("api_mode: chat_completions");
|
|
611
|
+
|
|
612
|
+
const statusRes = await fetch(controlUrl("/providers/status"));
|
|
613
|
+
expect(statusRes.status).toBe(200);
|
|
614
|
+
const statusBody = await statusRes.json() as { clients: Array<{ id: string; configured: boolean; configPath?: string }> };
|
|
615
|
+
expect(statusBody.clients).toEqual(expect.arrayContaining([
|
|
616
|
+
expect.objectContaining({
|
|
617
|
+
id: "openclaw",
|
|
618
|
+
configured: true,
|
|
619
|
+
configPath: path.join(TEMP_HOME, ".openclaw", "openclaw.json")
|
|
620
|
+
}),
|
|
621
|
+
expect.objectContaining({
|
|
622
|
+
id: "hermes",
|
|
623
|
+
configured: true,
|
|
624
|
+
configPath: path.join(TEMP_HOME, ".hermes", "config.yaml")
|
|
625
|
+
})
|
|
626
|
+
]));
|
|
627
|
+
});
|
|
555
628
|
});
|
|
556
629
|
|
|
557
630
|
// ─── GET /routing/strategy ────────────────────────────────────
|
|
@@ -235,6 +235,61 @@ describe("SellerPool", () => {
|
|
|
235
235
|
expect(ctx.credit.getEntry("s1")?.currentBalanceMicros).toBe(250_000);
|
|
236
236
|
});
|
|
237
237
|
|
|
238
|
+
test("recordRuntimeMetrics updates speed telemetry without changing credit", () => {
|
|
239
|
+
const ctx = build([{ id: "s1" }]);
|
|
240
|
+
const pool = new SellerPool({ modelIndex: ctx.index, cache: ctx.cache, creditTracker: ctx.credit });
|
|
241
|
+
pool.sync();
|
|
242
|
+
ctx.credit.recordPurchase("s1", 1_000_000, 250_000);
|
|
243
|
+
|
|
244
|
+
const entry = pool.recordRuntimeMetrics("s1", {
|
|
245
|
+
ttftMs: 123,
|
|
246
|
+
avgInferenceMs: 456,
|
|
247
|
+
avgTokensPerSecond: 78.9
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
expect(entry).toMatchObject({
|
|
251
|
+
sellerId: "s1",
|
|
252
|
+
ttftMs: 123,
|
|
253
|
+
avgInferenceMs: 456,
|
|
254
|
+
avgLatencyMs: 456,
|
|
255
|
+
avgTokensPerSecond: 78.9
|
|
256
|
+
});
|
|
257
|
+
expect(ctx.credit.getEntry("s1")?.currentBalanceMicros).toBe(250_000);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("sync preserves live runtime speed metrics when prewarm has no newer values", () => {
|
|
261
|
+
const ctx = build([{ id: "s1" }]);
|
|
262
|
+
const pool = new SellerPool({ modelIndex: ctx.index, cache: ctx.cache, creditTracker: ctx.credit });
|
|
263
|
+
pool.sync();
|
|
264
|
+
pool.recordRuntimeMetrics("s1", {
|
|
265
|
+
ttftMs: 123,
|
|
266
|
+
avgInferenceMs: 456,
|
|
267
|
+
avgTokensPerSecond: 78.9
|
|
268
|
+
}, 2_000_000);
|
|
269
|
+
ctx.cache.commitWarm({
|
|
270
|
+
modelId: "gpt-4o",
|
|
271
|
+
protocol: "chat_completions",
|
|
272
|
+
paymentMethod: "clawtip",
|
|
273
|
+
candidates: [{
|
|
274
|
+
sellerId: "s1",
|
|
275
|
+
url: "https://s1.example.com",
|
|
276
|
+
healthScore: 80,
|
|
277
|
+
lastSuccessAt: 1_000_000,
|
|
278
|
+
ttftMs: 1,
|
|
279
|
+
avgInferenceMs: 2,
|
|
280
|
+
avgTokensPerSecond: 0
|
|
281
|
+
}]
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
pool.sync();
|
|
285
|
+
|
|
286
|
+
expect(pool.snapshot()[0]).toMatchObject({
|
|
287
|
+
ttftMs: 123,
|
|
288
|
+
avgInferenceMs: 456,
|
|
289
|
+
avgTokensPerSecond: 78.9
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
238
293
|
test("hard failure kinds (hard_4xx, auth_invalid) immediately open the circuit and transfer leftover", () => {
|
|
239
294
|
const ctx = build([{ id: "s1" }]);
|
|
240
295
|
const pool = new SellerPool({ modelIndex: ctx.index, cache: ctx.cache, creditTracker: ctx.credit });
|
|
@@ -32,7 +32,7 @@ describe("seller route planner", () => {
|
|
|
32
32
|
test("uses compatible prewarm candidates before registry fallback", () => {
|
|
33
33
|
const result = plan({
|
|
34
34
|
prewarmCandidates: [
|
|
35
|
-
{ sellerId: "s2", url: "https://s2.example.com", healthScore: 95, avgLatencyMs: 120 },
|
|
35
|
+
{ sellerId: "s2", url: "https://s2.example.com", healthScore: 95, avgLatencyMs: 120, avgTokensPerSecond: 42.5 },
|
|
36
36
|
{ sellerId: "s1", url: "https://s1.example.com", healthScore: 50, avgLatencyMs: 80 },
|
|
37
37
|
{ sellerId: "missing", url: "https://missing.example.com", healthScore: 100, avgLatencyMs: 1 }
|
|
38
38
|
],
|
|
@@ -48,11 +48,55 @@ describe("seller route planner", () => {
|
|
|
48
48
|
expect(result.routes[0].metrics).toEqual({
|
|
49
49
|
healthScore: 95,
|
|
50
50
|
avgLatencyMs: 120,
|
|
51
|
+
avgTokensPerSecond: 42.5,
|
|
51
52
|
discountRatio: 0.01,
|
|
52
53
|
registryOrder: 1
|
|
53
54
|
});
|
|
54
55
|
});
|
|
55
56
|
|
|
57
|
+
test("prefers live runtime speed metrics over stale prewarm metrics", () => {
|
|
58
|
+
const result = plan({
|
|
59
|
+
routing: { mode: "fullAuto", scorer: "speed" },
|
|
60
|
+
prewarmCandidates: [
|
|
61
|
+
{ sellerId: "s1", url: "https://s1.example.com", healthScore: 90, avgLatencyMs: 100, avgTokensPerSecond: 1 },
|
|
62
|
+
{ sellerId: "s2", url: "https://s2.example.com", healthScore: 90, avgLatencyMs: 100, avgTokensPerSecond: 80 }
|
|
63
|
+
],
|
|
64
|
+
sellerMetrics: [
|
|
65
|
+
{ sellerId: "s1", ttftMs: 20, avgInferenceMs: 100, avgTokensPerSecond: 120 },
|
|
66
|
+
{ sellerId: "s2", ttftMs: 20, avgInferenceMs: 100, avgTokensPerSecond: 2 }
|
|
67
|
+
]
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
expect(result.routes.map((route) => route.seller.id)).toEqual(["s1", "s2"]);
|
|
71
|
+
expect(result.routes[0].metrics).toMatchObject({
|
|
72
|
+
ttftMs: 20,
|
|
73
|
+
avgInferenceMs: 100,
|
|
74
|
+
avgTokensPerSecond: 120
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("does not hide compatible registry sellers when prewarm cache is incomplete", () => {
|
|
79
|
+
const result = plan({
|
|
80
|
+
routing: { mode: "fullAuto", scorer: "discount" },
|
|
81
|
+
prewarmCandidates: [
|
|
82
|
+
{ sellerId: "s1", url: "https://s1.example.com", healthScore: 95, avgLatencyMs: 30 }
|
|
83
|
+
],
|
|
84
|
+
sellerMetrics: [
|
|
85
|
+
{ sellerId: "s1", discountRatio: 1 },
|
|
86
|
+
{ sellerId: "s2", discountRatio: 0.01 }
|
|
87
|
+
]
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
expect(result.source).toBe("prewarm_cache");
|
|
91
|
+
expect(result.sourceReason).toBe("prewarm_metrics_merged_with_registry");
|
|
92
|
+
expect(result.routes.map((route) => route.seller.id)).toEqual(["s2", "s1"]);
|
|
93
|
+
expect(result.diagnostics).toMatchObject({
|
|
94
|
+
prewarmCandidateCount: 1,
|
|
95
|
+
prewarmUsableCount: 1,
|
|
96
|
+
sourceCandidateCount: 2
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
56
100
|
test("falls back to registry candidates when prewarm has no usable sellers", () => {
|
|
57
101
|
const result = plan({
|
|
58
102
|
prewarmCandidates: [
|
|
@@ -12,6 +12,7 @@ function candidate(overrides: Partial<RoutingCandidate> & { sellerId: string; re
|
|
|
12
12
|
healthProbeLatencyMs: overrides.healthProbeLatencyMs,
|
|
13
13
|
ttftMs: overrides.ttftMs,
|
|
14
14
|
avgInferenceMs: overrides.avgInferenceMs,
|
|
15
|
+
avgTokensPerSecond: overrides.avgTokensPerSecond,
|
|
15
16
|
discountRatio: overrides.discountRatio,
|
|
16
17
|
registryOrder: overrides.registryOrder
|
|
17
18
|
};
|
|
@@ -112,17 +113,17 @@ describe("seller routing strategy", () => {
|
|
|
112
113
|
expect(ids).toEqual(["s1", "s3"]);
|
|
113
114
|
});
|
|
114
115
|
|
|
115
|
-
test("speed scorer
|
|
116
|
+
test("speed scorer uses TTFT and ten-minute Tok/s, then health", () => {
|
|
116
117
|
const ids = planIds(
|
|
117
118
|
[
|
|
118
|
-
candidate({ sellerId: "high-slow", registryOrder: 0, healthScore: 90, ttftMs: 800, avgInferenceMs:
|
|
119
|
-
candidate({ sellerId: "high-fast", registryOrder: 1, healthScore: 90, ttftMs: 100, avgInferenceMs:
|
|
120
|
-
candidate({ sellerId: "low-fast", registryOrder: 2, healthScore: 40, ttftMs: 10, avgInferenceMs:
|
|
119
|
+
candidate({ sellerId: "high-slow", registryOrder: 0, healthScore: 90, ttftMs: 800, avgInferenceMs: 10, avgTokensPerSecond: 1 }),
|
|
120
|
+
candidate({ sellerId: "high-fast", registryOrder: 1, healthScore: 90, ttftMs: 100, avgInferenceMs: 1000, avgTokensPerSecond: 80 }),
|
|
121
|
+
candidate({ sellerId: "low-fast", registryOrder: 2, healthScore: 40, ttftMs: 10, avgInferenceMs: 1000, avgTokensPerSecond: 20 })
|
|
121
122
|
],
|
|
122
123
|
{ mode: "fullAuto", scorer: "speed" }
|
|
123
124
|
);
|
|
124
125
|
|
|
125
|
-
expect(ids).toEqual(["
|
|
126
|
+
expect(ids).toEqual(["high-fast", "low-fast", "high-slow"]);
|
|
126
127
|
});
|
|
127
128
|
|
|
128
129
|
test("discount scorer prefers lower discount ratio, then health", () => {
|