@tokenbuddy/tokenbuddy 1.0.28 → 1.0.30

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.
Files changed (57) hide show
  1. package/dist/src/daemon.d.ts +11 -4
  2. package/dist/src/daemon.d.ts.map +1 -1
  3. package/dist/src/daemon.js +130 -42
  4. package/dist/src/daemon.js.map +1 -1
  5. package/dist/src/doctor-diagnostics.d.ts.map +1 -1
  6. package/dist/src/doctor-diagnostics.js +7 -1
  7. package/dist/src/doctor-diagnostics.js.map +1 -1
  8. package/dist/src/prewarm-cache.d.ts +4 -0
  9. package/dist/src/prewarm-cache.d.ts.map +1 -1
  10. package/dist/src/prewarm-cache.js +1 -0
  11. package/dist/src/prewarm-cache.js.map +1 -1
  12. package/dist/src/prewarm-scheduler.d.ts +2 -0
  13. package/dist/src/prewarm-scheduler.d.ts.map +1 -1
  14. package/dist/src/prewarm-scheduler.js +4 -1
  15. package/dist/src/prewarm-scheduler.js.map +1 -1
  16. package/dist/src/provider-install.d.ts.map +1 -1
  17. package/dist/src/provider-install.js +196 -18
  18. package/dist/src/provider-install.js.map +1 -1
  19. package/dist/src/seller-catalog.d.ts +4 -0
  20. package/dist/src/seller-catalog.d.ts.map +1 -1
  21. package/dist/src/seller-catalog.js.map +1 -1
  22. package/dist/src/seller-pool.d.ts +13 -0
  23. package/dist/src/seller-pool.d.ts.map +1 -1
  24. package/dist/src/seller-pool.js +43 -2
  25. package/dist/src/seller-pool.js.map +1 -1
  26. package/dist/src/seller-route-planner.d.ts +9 -0
  27. package/dist/src/seller-route-planner.d.ts.map +1 -1
  28. package/dist/src/seller-route-planner.js +39 -15
  29. package/dist/src/seller-route-planner.js.map +1 -1
  30. package/dist/src/seller-routing-strategy.d.ts +6 -4
  31. package/dist/src/seller-routing-strategy.d.ts.map +1 -1
  32. package/dist/src/seller-routing-strategy.js +15 -12
  33. package/dist/src/seller-routing-strategy.js.map +1 -1
  34. package/dist/src/terminal-detect.d.ts +5 -5
  35. package/dist/src/terminal-detect.d.ts.map +1 -1
  36. package/dist/src/terminal-detect.js +79 -26
  37. package/dist/src/terminal-detect.js.map +1 -1
  38. package/package.json +1 -1
  39. package/src/daemon.ts +168 -46
  40. package/src/doctor-diagnostics.ts +5 -1
  41. package/src/prewarm-cache.ts +5 -0
  42. package/src/prewarm-scheduler.ts +6 -1
  43. package/src/provider-install.ts +203 -18
  44. package/src/seller-catalog.ts +4 -0
  45. package/src/seller-pool.ts +68 -2
  46. package/src/seller-route-planner.ts +61 -15
  47. package/src/seller-routing-strategy.ts +21 -16
  48. package/src/terminal-detect.ts +81 -24
  49. package/static/ui/assets/index-DEDEl8o2.js +236 -0
  50. package/static/ui/assets/{index-UAfOhbwC.js.map → index-DEDEl8o2.js.map} +1 -1
  51. package/static/ui/index.html +1 -1
  52. package/tests/control-plane-ui-endpoints.test.ts +73 -0
  53. package/tests/seller-pool.test.ts +55 -0
  54. package/tests/seller-route-planner.test.ts +45 -1
  55. package/tests/seller-routing-strategy.test.ts +6 -5
  56. package/tests/tokenbuddy.test.ts +346 -38
  57. package/static/ui/assets/index-UAfOhbwC.js +0 -236
@@ -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-UAfOhbwC.js"></script>
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 prefers lower TTFT and average inference time, then health", () => {
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: 800 }),
119
- candidate({ sellerId: "high-fast", registryOrder: 1, healthScore: 90, ttftMs: 100, avgInferenceMs: 100 }),
120
- candidate({ sellerId: "low-fast", registryOrder: 2, healthScore: 40, ttftMs: 10, avgInferenceMs: 10 })
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(["low-fast", "high-fast", "high-slow"]);
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", () => {