@tokenbuddy/tokenbuddy 1.0.13 → 1.0.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/dist/src/buyer-store.d.ts +23 -0
  2. package/dist/src/buyer-store.d.ts.map +1 -1
  3. package/dist/src/buyer-store.js +31 -6
  4. package/dist/src/buyer-store.js.map +1 -1
  5. package/dist/src/clawtip-bootstrap.d.ts +23 -0
  6. package/dist/src/clawtip-bootstrap.d.ts.map +1 -0
  7. package/dist/src/clawtip-bootstrap.js +47 -0
  8. package/dist/src/clawtip-bootstrap.js.map +1 -0
  9. package/dist/src/cli.d.ts +24 -33
  10. package/dist/src/cli.d.ts.map +1 -1
  11. package/dist/src/cli.js +157 -58
  12. package/dist/src/cli.js.map +1 -1
  13. package/dist/src/daemon.d.ts +79 -1
  14. package/dist/src/daemon.d.ts.map +1 -1
  15. package/dist/src/daemon.js +984 -23
  16. package/dist/src/daemon.js.map +1 -1
  17. package/dist/src/model-index.d.ts +1 -1
  18. package/dist/src/model-index.d.ts.map +1 -1
  19. package/dist/src/model-index.js +4 -0
  20. package/dist/src/model-index.js.map +1 -1
  21. package/dist/src/prewarm-cache.d.ts +4 -0
  22. package/dist/src/prewarm-cache.d.ts.map +1 -1
  23. package/dist/src/prewarm-cache.js +2 -1
  24. package/dist/src/prewarm-cache.js.map +1 -1
  25. package/dist/src/prewarm-scheduler.d.ts +2 -0
  26. package/dist/src/prewarm-scheduler.d.ts.map +1 -1
  27. package/dist/src/prewarm-scheduler.js +4 -2
  28. package/dist/src/prewarm-scheduler.js.map +1 -1
  29. package/dist/src/route-failover.d.ts.map +1 -1
  30. package/dist/src/route-failover.js +10 -0
  31. package/dist/src/route-failover.js.map +1 -1
  32. package/dist/src/seller-catalog.d.ts +17 -0
  33. package/dist/src/seller-catalog.d.ts.map +1 -1
  34. package/dist/src/seller-catalog.js +15 -1
  35. package/dist/src/seller-catalog.js.map +1 -1
  36. package/dist/src/seller-pool.d.ts +12 -1
  37. package/dist/src/seller-pool.d.ts.map +1 -1
  38. package/dist/src/seller-pool.js +61 -7
  39. package/dist/src/seller-pool.js.map +1 -1
  40. package/dist/src/seller-route-planner.d.ts +11 -1
  41. package/dist/src/seller-route-planner.d.ts.map +1 -1
  42. package/dist/src/seller-route-planner.js +21 -9
  43. package/dist/src/seller-route-planner.js.map +1 -1
  44. package/dist/src/seller-routing-config.d.ts +2 -0
  45. package/dist/src/seller-routing-config.d.ts.map +1 -1
  46. package/dist/src/seller-routing-config.js +11 -1
  47. package/dist/src/seller-routing-config.js.map +1 -1
  48. package/package.json +1 -1
  49. package/src/buyer-store.ts +70 -7
  50. package/src/clawtip-bootstrap.ts +64 -0
  51. package/src/cli.ts +201 -76
  52. package/src/daemon.ts +1132 -25
  53. package/src/model-index.ts +4 -1
  54. package/src/prewarm-cache.ts +6 -1
  55. package/src/prewarm-scheduler.ts +6 -2
  56. package/src/route-failover.ts +11 -0
  57. package/src/seller-catalog.ts +24 -1
  58. package/src/seller-pool.ts +69 -7
  59. package/src/seller-route-planner.ts +33 -11
  60. package/src/seller-routing-config.ts +14 -1
  61. package/static/clawtip/recharge.png +0 -0
  62. package/tests/control-plane-ui-endpoints.test.ts +559 -0
  63. package/tests/daemon-classify.test.ts +9 -0
  64. package/tests/model-index.test.ts +14 -0
  65. package/tests/route-failover.test.ts +16 -0
  66. package/tests/seller-catalog-utilities.test.ts +54 -0
  67. package/tests/seller-pool.test.ts +56 -0
  68. package/tests/seller-route-planner.test.ts +40 -0
  69. package/tests/seller-routing-config.test.ts +13 -0
  70. package/tests/tokenbuddy.test.ts +200 -7
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  dedupeCatalogEntries,
3
+ discoverSellerBackedModels,
3
4
  filterCatalogByProtocol,
4
5
  filterCatalogBySeller,
5
6
  manifestModelIds,
@@ -67,4 +68,57 @@ describe("seller catalog utilities", () => {
67
68
 
68
69
  expect(manifestModelIds(manifest)).toEqual(["gpt-5.4", "claude"]);
69
70
  });
71
+
72
+ test("discovers models from active buyer-visible registry sellers only", async () => {
73
+ const fetchMock = jest.spyOn(globalThis, "fetch").mockImplementation(async (url) => {
74
+ const href = String(url);
75
+ if (href.endsWith("/registry/sellers")) {
76
+ return jsonResponse({
77
+ version: 8,
78
+ sellers: [
79
+ {
80
+ id: "active",
81
+ status: "active",
82
+ url: "https://active.example.com",
83
+ supportedProtocols: ["chat_completions"],
84
+ paymentMethods: ["clawtip"]
85
+ },
86
+ {
87
+ id: "pending",
88
+ status: "pending",
89
+ url: "https://pending.example.com",
90
+ supportedProtocols: ["chat_completions"],
91
+ paymentMethods: ["clawtip"]
92
+ }
93
+ ]
94
+ });
95
+ }
96
+ if (href === "https://active.example.com/manifest") {
97
+ return jsonResponse({
98
+ sellerId: "active",
99
+ supportedProtocols: ["chat_completions"],
100
+ paymentMethods: ["clawtip"],
101
+ models: [{ id: "gpt-5.4" }]
102
+ });
103
+ }
104
+ throw new Error(`unexpected fetch ${href}`);
105
+ });
106
+
107
+ try {
108
+ const catalog = await discoverSellerBackedModels("https://bootstrap.example.com/registry/sellers");
109
+
110
+ expect(catalog.sellers.map((seller) => seller.id)).toEqual(["active"]);
111
+ expect(catalog.models.map((model) => model.sellerId)).toEqual(["active"]);
112
+ expect(fetchMock).not.toHaveBeenCalledWith("https://pending.example.com/manifest");
113
+ } finally {
114
+ fetchMock.mockRestore();
115
+ }
116
+ });
70
117
  });
118
+
119
+ function jsonResponse(body: unknown): Response {
120
+ return new Response(JSON.stringify(body), {
121
+ status: 200,
122
+ headers: { "Content-Type": "application/json" }
123
+ });
124
+ }
@@ -132,6 +132,62 @@ describe("SellerPool", () => {
132
132
  expect(pool.snapshot()[0].circuit).toBe("open");
133
133
  });
134
134
 
135
+ test("busy_capacity temporarily blocks selection without opening the circuit", () => {
136
+ const clock = makeClock();
137
+ const ctx = build([{ id: "s1", healthScore: 90 }, { id: "s2", healthScore: 50 }]);
138
+ const pool = new SellerPool({
139
+ modelIndex: ctx.index,
140
+ cache: ctx.cache,
141
+ creditTracker: ctx.credit,
142
+ failureThreshold: 1,
143
+ capacityBlockMs: 1000,
144
+ now: () => clock.now
145
+ });
146
+ pool.sync();
147
+
148
+ const blocked = pool.recordFailure("s1", "busy_capacity");
149
+ expect(blocked?.circuit).toBe("closed");
150
+ expect(blocked?.consecutiveFailures).toBe(0);
151
+ expect(blocked?.recentFailures).toEqual([]);
152
+ expect(blocked?.capacityBlockedUntil).toBe(clock.now + 1000);
153
+
154
+ let result = pool.pick({ modelId: "gpt-4o", protocol: "chat_completions", paymentMethod: "clawtip" });
155
+ expect(result.candidates.map((c) => c.entry.sellerId)).toEqual(["s2"]);
156
+
157
+ clock.advance(1001);
158
+ result = pool.pick({ modelId: "gpt-4o", protocol: "chat_completions", paymentMethod: "clawtip" });
159
+ expect(result.candidates.map((c) => c.entry.sellerId)).toEqual(["s1", "s2"]);
160
+
161
+ pool.recordFailure("s1", "busy_capacity");
162
+ pool.recordSuccess("s1", 250_000);
163
+ expect(pool.snapshot().find((entry) => entry.sellerId === "s1")?.capacityBlockedUntil).toBeUndefined();
164
+ });
165
+
166
+ test("sync preserves registry fallback runtime state for sellers still in the registry", () => {
167
+ const clock = makeClock();
168
+ const index = new ModelIndex();
169
+ const sellers = [makeSeller({ id: "s1", models: ["gpt-4o"] })];
170
+ index.rebuild(sellers, { registryVersion: 1 });
171
+ const cache = new PrewarmCache();
172
+ const credit = new CreditTracker();
173
+ const pool = new SellerPool({
174
+ modelIndex: index,
175
+ cache,
176
+ creditTracker: credit,
177
+ capacityBlockMs: 1000,
178
+ now: () => clock.now
179
+ });
180
+
181
+ pool.ensureRegistrySellers(sellers);
182
+ pool.recordFailure("s1", "busy_capacity");
183
+ pool.sync();
184
+
185
+ expect(pool.snapshot()[0].capacityBlockedUntil).toBe(clock.now + 1000);
186
+ index.rebuild([], { registryVersion: 2 });
187
+ pool.sync();
188
+ expect(pool.snapshot()).toEqual([]);
189
+ });
190
+
135
191
  test("recordSuccess closes the circuit and reports to the credit tracker", () => {
136
192
  const clock = makeClock();
137
193
  const ctx = build([{ id: "s1" }]);
@@ -5,6 +5,7 @@ function seller(overrides: Partial<RegistrySeller> & { id: string; models?: stri
5
5
  return {
6
6
  id: overrides.id,
7
7
  name: overrides.name ?? overrides.id,
8
+ status: overrides.status,
8
9
  url: overrides.url ?? `https://${overrides.id}.example.com`,
9
10
  supportedProtocols: overrides.supportedProtocols ?? ["chat_completions"],
10
11
  paymentMethods: overrides.paymentMethods ?? ["clawtip"],
@@ -80,6 +81,20 @@ describe("seller route planner", () => {
80
81
  expect(result.reason).toBe("fullAuto:balanced:routes_1");
81
82
  });
82
83
 
84
+ test("only active registry sellers are visible to buyer routing", () => {
85
+ const result = plan({
86
+ registrySellers: [
87
+ seller({ id: "legacy-no-status" }),
88
+ seller({ id: "active", status: "active" }),
89
+ seller({ id: "pending", status: "pending" }),
90
+ seller({ id: "draining", status: "draining" }),
91
+ seller({ id: "offline", status: "offline" })
92
+ ]
93
+ });
94
+
95
+ expect(result.routes.map((route) => route.seller.id)).toEqual(["legacy-no-status", "active"]);
96
+ });
97
+
83
98
  test("fixed mode fails closed when selected seller is outside compatibility", () => {
84
99
  const result = plan({
85
100
  routing: { mode: "fixed", sellerId: "s3", scorer: "speed" }
@@ -147,4 +162,29 @@ describe("seller route planner", () => {
147
162
 
148
163
  expect(result.routes.map((route) => route.seller.id)).toEqual(["s2"]);
149
164
  });
165
+
166
+ test("active capacity blocks are excluded before strategy selection", () => {
167
+ const now = 10_000;
168
+ const blocked = plan({
169
+ now,
170
+ routing: { mode: "fullAuto", scorer: "speed" },
171
+ sellerMetrics: [
172
+ { sellerId: "s1", healthScore: 100, avgLatencyMs: 10, capacityBlockedUntil: now + 1000 },
173
+ { sellerId: "s2", healthScore: 20, avgLatencyMs: 100 }
174
+ ]
175
+ });
176
+
177
+ expect(blocked.routes.map((route) => route.seller.id)).toEqual(["s2"]);
178
+
179
+ const expired = plan({
180
+ now: now + 1001,
181
+ routing: { mode: "fullAuto", scorer: "speed" },
182
+ sellerMetrics: [
183
+ { sellerId: "s1", healthScore: 100, avgLatencyMs: 10, capacityBlockedUntil: now + 1000 },
184
+ { sellerId: "s2", healthScore: 20, avgLatencyMs: 100 }
185
+ ]
186
+ });
187
+
188
+ expect(expired.routes.map((route) => route.seller.id)).toEqual(["s1", "s2"]);
189
+ });
150
190
  });
@@ -19,10 +19,18 @@ describe("seller routing config", () => {
19
19
  expect(normalizeSellerRoutingConfig({
20
20
  mode: "fixed",
21
21
  sellerId: " tbs-1 ",
22
+ fixedByModel: {
23
+ " gpt-4o ": " tbs-2 ",
24
+ empty: "",
25
+ ignored: 42
26
+ },
22
27
  scorer: "speed"
23
28
  })).toEqual({
24
29
  mode: "fixed",
25
30
  sellerId: "tbs-1",
31
+ fixedByModel: {
32
+ "gpt-4o": "tbs-2"
33
+ },
26
34
  scorer: "speed"
27
35
  });
28
36
 
@@ -102,6 +110,11 @@ describe("seller routing config", () => {
102
110
 
103
111
  test("validates required fixed strategy parameters", () => {
104
112
  expect(() => assertSellerRoutingConfig({ mode: "fixed", scorer: "balanced" })).toThrow("--seller");
113
+ expect(() => assertSellerRoutingConfig({
114
+ mode: "fixed",
115
+ scorer: "balanced",
116
+ fixedByModel: { "gpt-4o": "tbs-1" }
117
+ })).not.toThrow();
105
118
  expect(() => assertSellerRoutingConfig({ mode: "fixedSet", sellerIds: [], scorer: "balanced" })).toThrow("--seller-set");
106
119
  });
107
120
 
@@ -6,6 +6,7 @@ import {
6
6
  buildCli,
7
7
  fetchClawtipBootstrap,
8
8
  normalizeClawtipBootstrapResourceUrl,
9
+ restartLaunchAgent,
9
10
  } from "../src/cli.js";
10
11
  import {
11
12
  checkOpenClawRuntime,
@@ -71,7 +72,15 @@ describe("TokenBuddy CLI command surface", () => {
71
72
  .filter(command => command !== "help")
72
73
  .sort();
73
74
 
74
- expect(commandNames).toEqual(["doctor", "init", "models", "payment", "routing"]);
75
+ expect(commandNames).toEqual(["daemon", "doctor", "init", "models", "payment", "routing", "ui"]);
76
+ });
77
+
78
+ test("tb daemon help exposes restart", () => {
79
+ const program = buildCli();
80
+ const daemon = program.commands.find(command => command.name() === "daemon");
81
+
82
+ expect(daemon).toBeDefined();
83
+ expect(daemon!.commands.map(command => command.name()).sort()).toEqual(["restart"]);
75
84
  });
76
85
 
77
86
  test("tb payment help only exposes list, add, and remove", () => {
@@ -151,6 +160,64 @@ describe("TokenBuddy CLI command surface", () => {
151
160
  expect(plist).not.toContain("payCredential");
152
161
  expect(plist).not.toContain("PAYMENT_PROOF");
153
162
  });
163
+
164
+ test("restartLaunchAgent kickstarts the installed LaunchAgent and waits for readiness", async () => {
165
+ const launchctlCalls: string[][] = [];
166
+ const result = await restartLaunchAgent(17820, {
167
+ platform: "darwin",
168
+ plistPath: "/Users/example/Library/LaunchAgents/com.tokenbuddy.proxyd.plist",
169
+ existsSync: () => true,
170
+ runLaunchctl: (args) => {
171
+ launchctlCalls.push(args);
172
+ },
173
+ probeDaemonStatus: async () => ({
174
+ running: true,
175
+ status: { pid: 100, controlPort: 17820, proxyPort: 17821 }
176
+ }),
177
+ waitForDaemonStatus: async () => ({
178
+ running: true,
179
+ status: { pid: 200, controlPort: 17820, proxyPort: 17821 }
180
+ })
181
+ });
182
+
183
+ expect(result).toMatchObject({
184
+ attempted: true,
185
+ restarted: true,
186
+ method: "launchd",
187
+ plistPath: "/Users/example/Library/LaunchAgents/com.tokenbuddy.proxyd.plist",
188
+ after: {
189
+ running: true,
190
+ status: { pid: 200 }
191
+ }
192
+ });
193
+ expect(launchctlCalls).toEqual([[
194
+ "kickstart",
195
+ "-k",
196
+ expect.stringMatching(/^gui\/\d+\/com\.tokenbuddy\.proxyd$/)
197
+ ]]);
198
+ });
199
+
200
+ test("restartLaunchAgent reports missing LaunchAgent plist without calling launchctl", async () => {
201
+ const launchctlCalls: string[][] = [];
202
+ const result = await restartLaunchAgent(17820, {
203
+ platform: "darwin",
204
+ plistPath: "/Users/example/Library/LaunchAgents/com.tokenbuddy.proxyd.plist",
205
+ existsSync: () => false,
206
+ runLaunchctl: (args) => {
207
+ launchctlCalls.push(args);
208
+ },
209
+ probeDaemonStatus: async () => ({ running: false, error: "offline" }),
210
+ waitForDaemonStatus: async () => ({ running: false, error: "not called" })
211
+ });
212
+
213
+ expect(result).toMatchObject({
214
+ attempted: false,
215
+ restarted: false,
216
+ method: "launchd",
217
+ error: expect.stringContaining("tb init")
218
+ });
219
+ expect(launchctlCalls).toEqual([]);
220
+ });
154
221
  });
155
222
 
156
223
  describe("BuyerStore safe SQLite persistence", () => {
@@ -2518,7 +2585,10 @@ describe("TokenBuddy seller routing strategies", () => {
2518
2585
  const events: Array<{ seller: string; url?: string }> = [];
2519
2586
  let primaryPurchaseSucceeds = false;
2520
2587
  let primaryInferenceFails = false;
2588
+ let primaryInferenceBusy = false;
2521
2589
  const dbPath = path.resolve(__dirname, "../../data-test/manual-routing-test.db");
2590
+ const routeEvents = (): Array<{ seller: string; url?: string }> => events
2591
+ .filter((event) => event.url !== "/primary/health" && event.url !== "/backup/health");
2522
2592
 
2523
2593
  const readJsonBody = (req: http.IncomingMessage): Promise<any> => new Promise((resolve) => {
2524
2594
  let body = "";
@@ -2615,6 +2685,11 @@ describe("TokenBuddy seller routing strategies", () => {
2615
2685
 
2616
2686
  if (req.url === "/primary/v1/chat/completions") {
2617
2687
  events.push({ seller: "primary-seller", url: req.url });
2688
+ if (primaryInferenceBusy) {
2689
+ res.statusCode = 429;
2690
+ res.end(JSON.stringify({ error: { code: "busy_capacity", message: "primary seller capacity is full" } }));
2691
+ return;
2692
+ }
2618
2693
  if (primaryInferenceFails) {
2619
2694
  res.statusCode = 500;
2620
2695
  res.end(JSON.stringify({ error: { code: "upstream_failed", message: "primary seller failed" } }));
@@ -2686,6 +2761,7 @@ describe("TokenBuddy seller routing strategies", () => {
2686
2761
  events.length = 0;
2687
2762
  primaryPurchaseSucceeds = false;
2688
2763
  primaryInferenceFails = false;
2764
+ primaryInferenceBusy = false;
2689
2765
  rmSqliteFiles(dbPath);
2690
2766
  const store = new BuyerStore({ dbPath });
2691
2767
  store.savePayment({
@@ -2738,7 +2814,7 @@ describe("TokenBuddy seller routing strategies", () => {
2738
2814
  // v1.2: the buyer no longer fetches the seller manifest per request.
2739
2815
  // The registry's `models` field is the source of truth. Auto-purchase
2740
2816
  // is still attempted once before failing over.
2741
- expect(events).toEqual([
2817
+ expect(routeEvents()).toEqual([
2742
2818
  { seller: "primary-seller", url: "/primary/purchase/create" }
2743
2819
  ]);
2744
2820
 
@@ -2784,7 +2860,7 @@ describe("TokenBuddy seller routing strategies", () => {
2784
2860
  // v1.2: the buyer no longer fetches the seller manifest per request.
2785
2861
  // The backup-seller is selected via the fixed seller routing config; the manifest
2786
2862
  // is sourced from the registry's `models` field.
2787
- expect(events).toEqual([
2863
+ expect(routeEvents()).toEqual([
2788
2864
  { seller: "backup-seller", url: "/backup/purchase/create" },
2789
2865
  { seller: "backup-seller", url: "/backup/purchase/complete" },
2790
2866
  { seller: "backup-seller", url: "/backup/v1/chat/completions" }
@@ -2826,11 +2902,72 @@ describe("TokenBuddy seller routing strategies", () => {
2826
2902
  });
2827
2903
 
2828
2904
  expect(response.ok).toBe(true);
2829
- expect(events).toEqual([
2905
+ expect(routeEvents()).toEqual([
2906
+ { seller: "backup-seller", url: "/backup/purchase/create" },
2907
+ { seller: "backup-seller", url: "/backup/purchase/complete" },
2908
+ { seller: "backup-seller", url: "/backup/v1/chat/completions" }
2909
+ ]);
2910
+ });
2911
+
2912
+ test("daemon applies tb routing set fullAuto without restart", async () => {
2913
+ daemon.stop();
2914
+ events.length = 0;
2915
+ const store = new BuyerStore({ dbPath });
2916
+ store.saveDaemonRuntimeConfig("routing", {
2917
+ mode: "fixed",
2918
+ sellerId: "primary-seller",
2919
+ scorer: "discount"
2920
+ });
2921
+ store.close();
2922
+
2923
+ daemon = new TokenbuddyDaemon({
2924
+ controlPort: 0,
2925
+ proxyPort: 0,
2926
+ dbPath,
2927
+ sellerRegistryUrl: `http://127.0.0.1:${sellerPort}/registry/sellers`
2928
+ });
2929
+ daemon.start();
2930
+ daemonControlPort = ((daemon as any).controlServer.address() as AddressInfo).port;
2931
+ daemonProxyPort = ((daemon as any).proxyServer.address() as AddressInfo).port;
2932
+
2933
+ const initialStatus = await (await fetch(`http://127.0.0.1:${daemonControlPort}/status`)).json() as any;
2934
+ expect(initialStatus.sellerRoutingMode).toBe("fixed");
2935
+ expect(initialStatus.sellerRoutingScorer).toBe("discount");
2936
+ expect(initialStatus.selectedSellerId).toBe("primary-seller");
2937
+
2938
+ const refreshedStore = new BuyerStore({ dbPath });
2939
+ refreshedStore.saveDaemonRuntimeConfig("routing", {
2940
+ mode: "fullAuto",
2941
+ scorer: "balanced"
2942
+ });
2943
+ refreshedStore.close();
2944
+
2945
+ const reloadedStatus = await (await fetch(`http://127.0.0.1:${daemonControlPort}/status`)).json() as any;
2946
+ expect(reloadedStatus.sellerRoutingMode).toBe("fullAuto");
2947
+ expect(reloadedStatus.sellerRoutingScorer).toBe("balanced");
2948
+ expect(reloadedStatus.selectedSellerId).toBeUndefined();
2949
+ const prewarmBeforeRequest = await (await fetch(`http://127.0.0.1:${daemonControlPort}/v1.2/prewarm`)).json() as any;
2950
+ const scheduledBeforeRequest = prewarmBeforeRequest.scheduler.totalScheduled;
2951
+
2952
+ const response = await fetch(`http://127.0.0.1:${daemonProxyPort}/v1/chat/completions`, {
2953
+ method: "POST",
2954
+ headers: { "Content-Type": "application/json" },
2955
+ body: JSON.stringify({
2956
+ model: "gpt-manual",
2957
+ messages: [{ role: "user", content: "fullAuto should reload without restart" }]
2958
+ })
2959
+ });
2960
+
2961
+ expect(response.ok).toBe(true);
2962
+ expect((await response.json() as any).id).toBe("backup-chat");
2963
+ expect(routeEvents()).toEqual([
2964
+ { seller: "primary-seller", url: "/primary/purchase/create" },
2830
2965
  { seller: "backup-seller", url: "/backup/purchase/create" },
2831
2966
  { seller: "backup-seller", url: "/backup/purchase/complete" },
2832
2967
  { seller: "backup-seller", url: "/backup/v1/chat/completions" }
2833
2968
  ]);
2969
+ const prewarmAfterRequest = await (await fetch(`http://127.0.0.1:${daemonControlPort}/v1.2/prewarm`)).json() as any;
2970
+ expect(prewarmAfterRequest.scheduler.totalScheduled).toBeGreaterThan(scheduledBeforeRequest);
2834
2971
  });
2835
2972
 
2836
2973
  test("fixedSet routing only uses sellers in the configured pool", async () => {
@@ -2866,7 +3003,7 @@ describe("TokenBuddy seller routing strategies", () => {
2866
3003
  });
2867
3004
 
2868
3005
  expect(response.ok).toBe(true);
2869
- expect(events).toEqual([
3006
+ expect(routeEvents()).toEqual([
2870
3007
  { seller: "backup-seller", url: "/backup/purchase/create" },
2871
3008
  { seller: "backup-seller", url: "/backup/purchase/complete" },
2872
3009
  { seller: "backup-seller", url: "/backup/v1/chat/completions" }
@@ -2911,7 +3048,7 @@ describe("TokenBuddy seller routing strategies", () => {
2911
3048
 
2912
3049
  expect(response.ok).toBe(true);
2913
3050
  expect((await response.json() as any).id).toBe("backup-chat");
2914
- expect(events).toEqual([
3051
+ expect(routeEvents()).toEqual([
2915
3052
  { seller: "primary-seller", url: "/primary/purchase/create" },
2916
3053
  { seller: "primary-seller", url: "/primary/purchase/complete" },
2917
3054
  { seller: "primary-seller", url: "/primary/v1/chat/completions" },
@@ -2939,6 +3076,62 @@ describe("TokenBuddy seller routing strategies", () => {
2939
3076
  expect(logs).not.toContain(rawPrompt);
2940
3077
  });
2941
3078
 
3079
+ test("fullAuto routing treats busy_capacity as a capacity block and starts the next request on backup", async () => {
3080
+ daemon.stop();
3081
+ events.length = 0;
3082
+ primaryPurchaseSucceeds = true;
3083
+ primaryInferenceBusy = true;
3084
+ daemon = new TokenbuddyDaemon({
3085
+ controlPort: 0,
3086
+ proxyPort: 0,
3087
+ dbPath,
3088
+ sellerRegistryUrl: `http://127.0.0.1:${sellerPort}/registry/sellers`,
3089
+ sellerRouting: {
3090
+ mode: "fullAuto",
3091
+ scorer: "balanced"
3092
+ }
3093
+ });
3094
+ daemon.start();
3095
+ daemonControlPort = ((daemon as any).controlServer.address() as AddressInfo).port;
3096
+ daemonProxyPort = ((daemon as any).proxyServer.address() as AddressInfo).port;
3097
+
3098
+ const first = await fetch(`http://127.0.0.1:${daemonProxyPort}/v1/chat/completions`, {
3099
+ method: "POST",
3100
+ headers: { "Content-Type": "application/json" },
3101
+ body: JSON.stringify({
3102
+ model: "gpt-manual",
3103
+ messages: [{ role: "user", content: "primary is at capacity" }]
3104
+ })
3105
+ });
3106
+
3107
+ expect(first.ok).toBe(true);
3108
+ expect((await first.json() as any).id).toBe("backup-chat");
3109
+ expect(routeEvents()).toEqual([
3110
+ { seller: "primary-seller", url: "/primary/purchase/create" },
3111
+ { seller: "primary-seller", url: "/primary/purchase/complete" },
3112
+ { seller: "primary-seller", url: "/primary/v1/chat/completions" },
3113
+ { seller: "backup-seller", url: "/backup/purchase/create" },
3114
+ { seller: "backup-seller", url: "/backup/purchase/complete" },
3115
+ { seller: "backup-seller", url: "/backup/v1/chat/completions" }
3116
+ ]);
3117
+
3118
+ events.length = 0;
3119
+ const second = await fetch(`http://127.0.0.1:${daemonProxyPort}/v1/chat/completions`, {
3120
+ method: "POST",
3121
+ headers: { "Content-Type": "application/json" },
3122
+ body: JSON.stringify({
3123
+ model: "gpt-manual",
3124
+ messages: [{ role: "user", content: "capacity block should still be active" }]
3125
+ })
3126
+ });
3127
+
3128
+ expect(second.ok).toBe(true);
3129
+ expect((await second.json() as any).id).toBe("backup-chat");
3130
+ expect(routeEvents()).toEqual([
3131
+ { seller: "backup-seller", url: "/backup/v1/chat/completions" }
3132
+ ]);
3133
+ });
3134
+
2942
3135
  test("fullAuto routing logs purchase failure failover before trying the backup seller", async () => {
2943
3136
  daemon.stop();
2944
3137
  events.length = 0;
@@ -2970,7 +3163,7 @@ describe("TokenBuddy seller routing strategies", () => {
2970
3163
 
2971
3164
  expect(response.ok).toBe(true);
2972
3165
  expect((await response.json() as any).id).toBe("backup-chat");
2973
- expect(events).toEqual([
3166
+ expect(routeEvents()).toEqual([
2974
3167
  { seller: "primary-seller", url: "/primary/purchase/create" },
2975
3168
  { seller: "backup-seller", url: "/backup/purchase/create" },
2976
3169
  { seller: "backup-seller", url: "/backup/purchase/complete" },