@tokenbuddy/tokenbuddy 1.0.36 → 1.0.38

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 (146) hide show
  1. package/dist/src/buyer-store.d.ts +7 -2
  2. package/dist/src/buyer-store.js +46 -7
  3. package/dist/src/cli.d.ts +1 -0
  4. package/dist/src/cli.js +15 -7
  5. package/dist/src/daemon.d.ts +12 -0
  6. package/dist/src/daemon.js +791 -61
  7. package/dist/src/doctor-diagnostics.js +1 -6
  8. package/dist/src/provider-install.d.ts +2 -2
  9. package/dist/src/provider-install.js +248 -2
  10. package/dist/src/seller-catalog.d.ts +21 -0
  11. package/dist/src/seller-catalog.js +17 -0
  12. package/dist/src/seller-route-planner.d.ts +4 -1
  13. package/dist/src/seller-route-planner.js +3 -0
  14. package/dist/src/seller-routing-strategy.d.ts +3 -0
  15. package/dist/src/terminal-detect.d.ts +1 -1
  16. package/dist/src/terminal-detect.js +3 -2
  17. package/dist/src/workdir.d.ts +10 -0
  18. package/dist/src/workdir.js +26 -0
  19. package/package.json +15 -2
  20. package/static/ui/assets/index-Djfl9tw5.js +271 -0
  21. package/static/ui/assets/index-DkfztCkn.css +1 -0
  22. package/static/ui/index.html +2 -2
  23. package/dist/src/buyer-store.d.ts.map +0 -1
  24. package/dist/src/buyer-store.js.map +0 -1
  25. package/dist/src/clawtip-bootstrap.d.ts.map +0 -1
  26. package/dist/src/clawtip-bootstrap.js.map +0 -1
  27. package/dist/src/cli.d.ts.map +0 -1
  28. package/dist/src/cli.js.map +0 -1
  29. package/dist/src/credit-tracker.d.ts.map +0 -1
  30. package/dist/src/credit-tracker.js.map +0 -1
  31. package/dist/src/daemon.d.ts.map +0 -1
  32. package/dist/src/daemon.js.map +0 -1
  33. package/dist/src/doctor-clawtip-wallet.d.ts.map +0 -1
  34. package/dist/src/doctor-clawtip-wallet.js.map +0 -1
  35. package/dist/src/doctor-diagnostics.d.ts.map +0 -1
  36. package/dist/src/doctor-diagnostics.js.map +0 -1
  37. package/dist/src/index.d.ts.map +0 -1
  38. package/dist/src/index.js.map +0 -1
  39. package/dist/src/init-clawtip-activation.d.ts.map +0 -1
  40. package/dist/src/init-clawtip-activation.js.map +0 -1
  41. package/dist/src/init-payment-options.d.ts.map +0 -1
  42. package/dist/src/init-payment-options.js.map +0 -1
  43. package/dist/src/init-setup.d.ts.map +0 -1
  44. package/dist/src/init-setup.js.map +0 -1
  45. package/dist/src/model-index.d.ts.map +0 -1
  46. package/dist/src/model-index.js.map +0 -1
  47. package/dist/src/package-update.d.ts.map +0 -1
  48. package/dist/src/package-update.js.map +0 -1
  49. package/dist/src/prewarm-cache.d.ts.map +0 -1
  50. package/dist/src/prewarm-cache.js.map +0 -1
  51. package/dist/src/prewarm-scheduler.d.ts.map +0 -1
  52. package/dist/src/prewarm-scheduler.js.map +0 -1
  53. package/dist/src/provider-install.d.ts.map +0 -1
  54. package/dist/src/provider-install.js.map +0 -1
  55. package/dist/src/provider-routing-config.d.ts.map +0 -1
  56. package/dist/src/provider-routing-config.js.map +0 -1
  57. package/dist/src/registry-trust.d.ts.map +0 -1
  58. package/dist/src/registry-trust.js.map +0 -1
  59. package/dist/src/route-failover.d.ts.map +0 -1
  60. package/dist/src/route-failover.js.map +0 -1
  61. package/dist/src/seller-catalog.d.ts.map +0 -1
  62. package/dist/src/seller-catalog.js.map +0 -1
  63. package/dist/src/seller-concurrency-limiter.d.ts.map +0 -1
  64. package/dist/src/seller-concurrency-limiter.js.map +0 -1
  65. package/dist/src/seller-metadata-cache.d.ts.map +0 -1
  66. package/dist/src/seller-metadata-cache.js.map +0 -1
  67. package/dist/src/seller-pool.d.ts.map +0 -1
  68. package/dist/src/seller-pool.js.map +0 -1
  69. package/dist/src/seller-route-planner.d.ts.map +0 -1
  70. package/dist/src/seller-route-planner.js.map +0 -1
  71. package/dist/src/seller-routing-config.d.ts.map +0 -1
  72. package/dist/src/seller-routing-config.js.map +0 -1
  73. package/dist/src/seller-routing-strategy.d.ts.map +0 -1
  74. package/dist/src/seller-routing-strategy.js.map +0 -1
  75. package/dist/src/stream-failover.d.ts.map +0 -1
  76. package/dist/src/stream-failover.js.map +0 -1
  77. package/dist/src/tb-clawtip-proof.d.ts.map +0 -1
  78. package/dist/src/tb-clawtip-proof.js.map +0 -1
  79. package/dist/src/tb-proxyd.d.ts.map +0 -1
  80. package/dist/src/tb-proxyd.js.map +0 -1
  81. package/dist/src/terminal-detect.d.ts.map +0 -1
  82. package/dist/src/terminal-detect.js.map +0 -1
  83. package/dist/src/terminal-image.d.ts.map +0 -1
  84. package/dist/src/terminal-image.js.map +0 -1
  85. package/src/buyer-store.ts +0 -1090
  86. package/src/clawtip-bootstrap.ts +0 -65
  87. package/src/cli.ts +0 -2243
  88. package/src/credit-tracker.ts +0 -295
  89. package/src/daemon.ts +0 -5475
  90. package/src/doctor-clawtip-wallet.ts +0 -95
  91. package/src/doctor-diagnostics.ts +0 -1026
  92. package/src/index.ts +0 -16
  93. package/src/init-clawtip-activation.ts +0 -695
  94. package/src/init-payment-options.ts +0 -373
  95. package/src/init-setup.ts +0 -165
  96. package/src/model-index.ts +0 -278
  97. package/src/package-update.ts +0 -311
  98. package/src/prewarm-cache.ts +0 -485
  99. package/src/prewarm-scheduler.ts +0 -675
  100. package/src/provider-install.ts +0 -1006
  101. package/src/provider-routing-config.ts +0 -410
  102. package/src/registry-trust.ts +0 -51
  103. package/src/route-failover.ts +0 -304
  104. package/src/seller-catalog.ts +0 -505
  105. package/src/seller-concurrency-limiter.ts +0 -161
  106. package/src/seller-metadata-cache.ts +0 -91
  107. package/src/seller-pool.ts +0 -557
  108. package/src/seller-route-planner.ts +0 -513
  109. package/src/seller-routing-config.ts +0 -211
  110. package/src/seller-routing-strategy.ts +0 -362
  111. package/src/stream-failover.ts +0 -152
  112. package/src/tb-clawtip-proof.ts +0 -28
  113. package/src/tb-proxyd.ts +0 -101
  114. package/src/terminal-detect.ts +0 -333
  115. package/src/terminal-image.ts +0 -228
  116. package/static/ui/assets/index-0MVXD7bH.css +0 -1
  117. package/static/ui/assets/index-BVbeDEwq.js +0 -271
  118. package/static/ui/assets/index-BVbeDEwq.js.map +0 -1
  119. package/tests/cli-routing.test.ts +0 -363
  120. package/tests/control-plane-ui-endpoints.test.ts +0 -1630
  121. package/tests/credit-tracker.test.ts +0 -165
  122. package/tests/daemon-413-fallback.test.ts +0 -92
  123. package/tests/daemon-classify.test.ts +0 -452
  124. package/tests/daemon-roles.test.ts +0 -92
  125. package/tests/daemon-trusted-registry-cache.test.ts +0 -132
  126. package/tests/e2e.test.ts +0 -366
  127. package/tests/image-generation-e2e.test.ts +0 -230
  128. package/tests/model-index.test.ts +0 -198
  129. package/tests/package-update.test.ts +0 -147
  130. package/tests/prewarm-cache.test.ts +0 -296
  131. package/tests/prewarm-scheduler.test.ts +0 -367
  132. package/tests/provider-routing-config.test.ts +0 -150
  133. package/tests/registry-trust.test.ts +0 -28
  134. package/tests/route-failover.test.ts +0 -222
  135. package/tests/seller-catalog-413.test.ts +0 -120
  136. package/tests/seller-catalog-utilities.test.ts +0 -124
  137. package/tests/seller-concurrency-limiter.test.ts +0 -83
  138. package/tests/seller-metadata-cache.test.ts +0 -89
  139. package/tests/seller-pool.test.ts +0 -365
  140. package/tests/seller-route-planner.test.ts +0 -312
  141. package/tests/seller-routing-config.test.ts +0 -124
  142. package/tests/seller-routing-strategy.test.ts +0 -167
  143. package/tests/stream-failover.test.ts +0 -52
  144. package/tests/thousand-seller.test.ts +0 -151
  145. package/tests/tokenbuddy.test.ts +0 -4043
  146. package/tsconfig.json +0 -8
@@ -1,365 +0,0 @@
1
- import { CreditTracker } from "../src/credit-tracker.js";
2
- import { ModelIndex } from "../src/model-index.js";
3
- import { PrewarmCache } from "../src/prewarm-cache.js";
4
- import { SellerPool, type FailureKind } from "../src/seller-pool.js";
5
- import type { RegistrySeller } from "../src/seller-catalog.js";
6
-
7
- function makeSeller(overrides: Partial<RegistrySeller> & { id: string; models?: string[] }): RegistrySeller {
8
- return {
9
- id: overrides.id,
10
- name: overrides.name ?? overrides.id,
11
- url: overrides.url ?? `https://${overrides.id}.example.com`,
12
- supportedProtocols: overrides.supportedProtocols ?? ["chat_completions"],
13
- paymentMethods: overrides.paymentMethods ?? ["clawtip"],
14
- models: overrides.models
15
- };
16
- }
17
-
18
- function makeClock(start = 1_000_000): { now: number; advance: (ms: number) => void } {
19
- const clock = { now: start, advance: (ms: number) => { clock.now += ms; } };
20
- return clock;
21
- }
22
-
23
- function build(seed: { id: string; healthScore?: number }[] = []): { index: ModelIndex; cache: PrewarmCache; credit: CreditTracker; sellers: RegistrySeller[] } {
24
- const sellers: RegistrySeller[] = seed.map((s) =>
25
- makeSeller({ id: s.id, models: ["gpt-4o"] })
26
- );
27
- const index = new ModelIndex();
28
- index.rebuild(sellers, { registryVersion: 1, defaultSellerId: seed[0]?.id });
29
- const cache = new PrewarmCache();
30
- cache.commitWarm({
31
- modelId: "gpt-4o",
32
- protocol: "chat_completions",
33
- paymentMethod: "clawtip",
34
- candidates: seed.map((s) => ({
35
- sellerId: s.id,
36
- url: `https://${s.id}.example.com`,
37
- healthScore: s.healthScore ?? 80
38
- }))
39
- });
40
- const credit = new CreditTracker();
41
- return { index, cache, credit, sellers };
42
- }
43
-
44
- describe("SellerPool", () => {
45
- test("sync rebuilds entries from the prewarm cache", () => {
46
- const ctx = build([{ id: "s1" }, { id: "s2" }]);
47
- const pool = new SellerPool({
48
- modelIndex: ctx.index,
49
- cache: ctx.cache,
50
- creditTracker: ctx.credit
51
- });
52
- const size = pool.sync();
53
- expect(size).toBe(2);
54
- expect(pool.snapshot().map((e) => e.sellerId).sort()).toEqual(["s1", "s2"]);
55
- });
56
-
57
- test("sync drops entries whose seller disappeared from the registry index", () => {
58
- const ctx = build([{ id: "s1" }, { id: "s2" }]);
59
- const pool = new SellerPool({
60
- modelIndex: ctx.index,
61
- cache: ctx.cache,
62
- creditTracker: ctx.credit
63
- });
64
- pool.sync();
65
- expect(pool.size()).toBe(2);
66
-
67
- // Mutate the index so only s1 remains.
68
- ctx.index.rebuild([makeSeller({ id: "s1", models: ["gpt-4o"] })], { registryVersion: 2 });
69
- const size = pool.sync();
70
- expect(size).toBe(1);
71
- expect(pool.snapshot().map((e) => e.sellerId)).toEqual(["s1"]);
72
- });
73
-
74
- test("pick returns candidates sorted by health score and skips open circuit entries", () => {
75
- const ctx = build([{ id: "s1", healthScore: 90 }, { id: "s2", healthScore: 50 }, { id: "s3", healthScore: 70 }]);
76
- const pool = new SellerPool({ modelIndex: ctx.index, cache: ctx.cache, creditTracker: ctx.credit });
77
- pool.sync();
78
-
79
- const result = pool.pick({ modelId: "gpt-4o", protocol: "chat_completions", paymentMethod: "clawtip" });
80
- expect(result.candidates.map((c) => c.entry.sellerId)).toEqual(["s1", "s3", "s2"]);
81
-
82
- // Force s1 into the open circuit and verify it is skipped.
83
- pool.markOpen("s1", "registry_gone");
84
- const filtered = pool.pick({ modelId: "gpt-4o", protocol: "chat_completions", paymentMethod: "clawtip" });
85
- expect(filtered.candidates.map((c) => c.entry.sellerId)).toEqual(["s3", "s2"]);
86
- });
87
-
88
- test("half_open recycle happens after openStateMs has elapsed", () => {
89
- const clock = makeClock();
90
- const ctx = build([{ id: "s1" }]);
91
- const pool = new SellerPool({
92
- modelIndex: ctx.index,
93
- cache: ctx.cache,
94
- creditTracker: ctx.credit,
95
- failureThreshold: 1, // open after the very first failure to keep the test focused
96
- openStateMs: 1000,
97
- now: () => clock.now
98
- });
99
- pool.sync();
100
- pool.recordFailure("s1", "soft_5xx");
101
- expect(pool.snapshot()[0].circuit).toBe("open");
102
-
103
- // Within the open window: still skipped.
104
- clock.advance(500);
105
- let result = pool.pick({ modelId: "gpt-4o", protocol: "chat_completions", paymentMethod: "clawtip" });
106
- expect(result.candidates).toEqual([]);
107
-
108
- // After the open window: recycled to half_open and re-included.
109
- clock.advance(600);
110
- result = pool.pick({ modelId: "gpt-4o", protocol: "chat_completions", paymentMethod: "clawtip" });
111
- expect(result.candidates.map((c) => c.entry.sellerId)).toEqual(["s1"]);
112
- expect(pool.snapshot()[0].circuit).toBe("half_open");
113
- });
114
-
115
- test("recycleOpenCircuits refreshes snapshot-based route planning state", () => {
116
- const clock = makeClock();
117
- const ctx = build([{ id: "s1" }, { id: "s2" }]);
118
- const pool = new SellerPool({
119
- modelIndex: ctx.index,
120
- cache: ctx.cache,
121
- creditTracker: ctx.credit,
122
- failureThreshold: 1,
123
- openStateMs: 1000,
124
- now: () => clock.now
125
- });
126
- pool.sync();
127
- pool.recordFailure("s1", "soft_5xx");
128
-
129
- clock.advance(500);
130
- expect(pool.recycleOpenCircuits()).toBe(0);
131
- expect(pool.snapshot().find((entry) => entry.sellerId === "s1")?.circuit).toBe("open");
132
-
133
- clock.advance(600);
134
- expect(pool.recycleOpenCircuits()).toBe(1);
135
- expect(pool.snapshot().find((entry) => entry.sellerId === "s1")?.circuit).toBe("half_open");
136
- });
137
-
138
- test("recordFailure escalates to open after the configured threshold", () => {
139
- const clock = makeClock();
140
- const ctx = build([{ id: "s1" }]);
141
- const pool = new SellerPool({
142
- modelIndex: ctx.index,
143
- cache: ctx.cache,
144
- creditTracker: ctx.credit,
145
- failureThreshold: 3,
146
- now: () => clock.now
147
- });
148
- pool.sync();
149
-
150
- pool.recordFailure("s1", "soft_5xx");
151
- expect(pool.snapshot()[0].circuit).toBe("closed");
152
- pool.recordFailure("s1", "soft_5xx");
153
- expect(pool.snapshot()[0].circuit).toBe("closed");
154
- pool.recordFailure("s1", "soft_5xx");
155
- expect(pool.snapshot()[0].circuit).toBe("open");
156
- });
157
-
158
- test("busy_capacity temporarily blocks selection without opening the circuit", () => {
159
- const clock = makeClock();
160
- const ctx = build([{ id: "s1", healthScore: 90 }, { id: "s2", healthScore: 50 }]);
161
- const pool = new SellerPool({
162
- modelIndex: ctx.index,
163
- cache: ctx.cache,
164
- creditTracker: ctx.credit,
165
- failureThreshold: 1,
166
- capacityBlockMs: 1000,
167
- now: () => clock.now
168
- });
169
- pool.sync();
170
-
171
- const blocked = pool.recordFailure("s1", "busy_capacity");
172
- expect(blocked?.circuit).toBe("closed");
173
- expect(blocked?.consecutiveFailures).toBe(0);
174
- expect(blocked?.recentFailures).toEqual([]);
175
- expect(blocked?.capacityBlockedUntil).toBe(clock.now + 1000);
176
-
177
- let result = pool.pick({ modelId: "gpt-4o", protocol: "chat_completions", paymentMethod: "clawtip" });
178
- expect(result.candidates.map((c) => c.entry.sellerId)).toEqual(["s2"]);
179
-
180
- clock.advance(1001);
181
- result = pool.pick({ modelId: "gpt-4o", protocol: "chat_completions", paymentMethod: "clawtip" });
182
- expect(result.candidates.map((c) => c.entry.sellerId)).toEqual(["s1", "s2"]);
183
-
184
- pool.recordFailure("s1", "busy_capacity");
185
- pool.recordSuccess("s1", 250_000);
186
- expect(pool.snapshot().find((entry) => entry.sellerId === "s1")?.capacityBlockedUntil).toBeUndefined();
187
- });
188
-
189
- test("sync preserves registry fallback runtime state for sellers still in the registry", () => {
190
- const clock = makeClock();
191
- const index = new ModelIndex();
192
- const sellers = [makeSeller({ id: "s1", models: ["gpt-4o"] })];
193
- index.rebuild(sellers, { registryVersion: 1 });
194
- const cache = new PrewarmCache();
195
- const credit = new CreditTracker();
196
- const pool = new SellerPool({
197
- modelIndex: index,
198
- cache,
199
- creditTracker: credit,
200
- capacityBlockMs: 1000,
201
- now: () => clock.now
202
- });
203
-
204
- pool.ensureRegistrySellers(sellers);
205
- pool.recordFailure("s1", "busy_capacity");
206
- pool.sync();
207
-
208
- expect(pool.snapshot()[0].capacityBlockedUntil).toBe(clock.now + 1000);
209
- index.rebuild([], { registryVersion: 2 });
210
- pool.sync();
211
- expect(pool.snapshot()).toEqual([]);
212
- });
213
-
214
- test("recordSuccess closes the circuit and reports to the credit tracker", () => {
215
- const clock = makeClock();
216
- const ctx = build([{ id: "s1" }]);
217
- const pool = new SellerPool({
218
- modelIndex: ctx.index,
219
- cache: ctx.cache,
220
- creditTracker: ctx.credit,
221
- failureThreshold: 1, // open after the first failure
222
- now: () => clock.now
223
- });
224
- pool.sync();
225
-
226
- ctx.credit.recordPurchase("s1", 1_000_000, 1_000_000);
227
- pool.recordFailure("s1", "soft_5xx");
228
- expect(pool.snapshot()[0].circuit).toBe("open");
229
-
230
- clock.advance(31_000); // wait past the open window
231
- pool.recordSuccess("s1", 250_000);
232
- const entry = pool.snapshot()[0];
233
- expect(entry.circuit).toBe("closed");
234
- expect(entry.consecutiveFailures).toBe(0);
235
- expect(ctx.credit.getEntry("s1")?.currentBalanceMicros).toBe(250_000);
236
- });
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
-
293
- test("hard failure kinds (hard_4xx, auth_invalid) immediately open the circuit and transfer leftover", () => {
294
- const ctx = build([{ id: "s1" }]);
295
- const pool = new SellerPool({ modelIndex: ctx.index, cache: ctx.cache, creditTracker: ctx.credit });
296
- pool.sync();
297
-
298
- ctx.credit.recordPurchase("s1", 1_000_000, 500_000);
299
- const kinds: FailureKind[] = ["hard_4xx", "auth_invalid"];
300
- for (const kind of kinds) {
301
- ctx.credit.recordPurchase("s1", 1_000_000, 500_000);
302
- const entry = pool.recordFailure("s1", kind, { reason: "test" });
303
- expect(entry?.circuit).toBe("open");
304
- }
305
- const summary = ctx.credit.summary();
306
- expect(summary.totalWastedMicros).toBeGreaterThan(0);
307
- });
308
-
309
- test("inspect surfaces freshPurchase and autoPurchaseAvailable flags", () => {
310
- const ctx = build([{ id: "s1" }]);
311
- const pool = new SellerPool({ modelIndex: ctx.index, cache: ctx.cache, creditTracker: ctx.credit });
312
- pool.sync();
313
-
314
- ctx.credit.recordPurchase("s1", 1_000_000, 1_000_000);
315
- const info = pool.inspect("s1");
316
- expect(info.entry?.sellerId).toBe("s1");
317
- expect(info.freshPurchase).toBe(true);
318
- expect(info.autoPurchaseAvailable).toBe(true);
319
- });
320
-
321
- test("markOpen force-opens a circuit without changing other state", () => {
322
- const ctx = build([{ id: "s1" }]);
323
- const pool = new SellerPool({ modelIndex: ctx.index, cache: ctx.cache, creditTracker: ctx.credit });
324
- pool.sync();
325
- pool.markOpen("s1", "registry_disappeared");
326
- expect(pool.snapshot()[0].circuit).toBe("open");
327
- });
328
-
329
- test("pick returns an empty result when the prewarm cache has no entry for the model", () => {
330
- const index = new ModelIndex();
331
- index.rebuild([makeSeller({ id: "s1", models: ["gpt-4o"] })], { registryVersion: 1 });
332
- const cache = new PrewarmCache();
333
- const credit = new CreditTracker();
334
- const pool = new SellerPool({ modelIndex: index, cache, creditTracker: credit });
335
- pool.sync();
336
-
337
- const result = pool.pick({ modelId: "gpt-4o", protocol: "chat_completions", paymentMethod: "clawtip" });
338
- expect(result.candidates).toEqual([]);
339
- // No prewarm has been committed for this model yet, so the pool
340
- // surfaces "no_prewarm_candidates" (not "prewarm_cache_empty"); the
341
- // distinction matters for the caller deciding whether to schedule a
342
- // lazy prewarm.
343
- expect(result.reason).toBe("no_prewarm_candidates");
344
- });
345
-
346
- test("pick falls back to the registry index when the cache has no entry yet", () => {
347
- const index = new ModelIndex();
348
- index.rebuild(
349
- [makeSeller({ id: "s1", models: ["gpt-4o"] }), makeSeller({ id: "s2", models: ["gpt-4o"] })],
350
- { registryVersion: 1 }
351
- );
352
- const cache = new PrewarmCache();
353
- const credit = new CreditTracker();
354
- const pool = new SellerPool({ modelIndex: index, cache, creditTracker: credit });
355
- pool.sync();
356
-
357
- // No probe has run yet, so pool is empty. pick should report "no candidates" and
358
- // also surface the registry-level model resolution so the caller can decide
359
- // whether to schedule a lazy prewarm.
360
- const result = pool.pick({ modelId: "gpt-4o", protocol: "chat_completions", paymentMethod: "clawtip" });
361
- expect(result.candidates).toEqual([]);
362
- expect(result.resolved.matched).toBe(true);
363
- expect(result.resolved.candidates).toHaveLength(2);
364
- });
365
- });
@@ -1,312 +0,0 @@
1
- import { planSellerRouteSet, type SellerRoutePlannerInput } from "../src/seller-route-planner.js";
2
- import type { RegistrySeller } from "../src/seller-catalog.js";
3
-
4
- function seller(overrides: Partial<RegistrySeller> & { id: string; models?: string[] }): RegistrySeller {
5
- return {
6
- id: overrides.id,
7
- name: overrides.name ?? overrides.id,
8
- status: overrides.status,
9
- url: overrides.url ?? `https://${overrides.id}.example.com`,
10
- supportedProtocols: overrides.supportedProtocols ?? ["chat_completions"],
11
- paymentMethods: overrides.paymentMethods ?? ["clawtip"],
12
- models: overrides.models ?? ["gpt-5.4"]
13
- };
14
- }
15
-
16
- function plan(overrides: Partial<SellerRoutePlannerInput> = {}) {
17
- return planSellerRouteSet({
18
- modelId: "gpt-5.4",
19
- protocol: "chat_completions",
20
- paymentMethod: "clawtip",
21
- registrySellers: [
22
- seller({ id: "s1" }),
23
- seller({ id: "s2" }),
24
- seller({ id: "s3", supportedProtocols: ["responses"] })
25
- ],
26
- routing: { mode: "fullAuto", scorer: "balanced" },
27
- ...overrides
28
- });
29
- }
30
-
31
- describe("seller route planner", () => {
32
- test("uses compatible prewarm candidates before registry fallback", () => {
33
- const result = plan({
34
- prewarmCandidates: [
35
- { sellerId: "s2", url: "https://s2.example.com", healthScore: 95, avgLatencyMs: 120, avgTokensPerSecond: 42.5 },
36
- { sellerId: "s1", url: "https://s1.example.com", healthScore: 50, avgLatencyMs: 80 },
37
- { sellerId: "missing", url: "https://missing.example.com", healthScore: 100, avgLatencyMs: 1 }
38
- ],
39
- sellerMetrics: [
40
- { sellerId: "s1", discountRatio: 0.5 },
41
- { sellerId: "s2", discountRatio: 0.01 }
42
- ]
43
- });
44
-
45
- expect(result.source).toBe("prewarm_cache");
46
- expect(result.routes.map((route) => route.seller.id)).toEqual(["s2", "s1"]);
47
- expect(result.candidateCount).toBe(2);
48
- expect(result.routes[0].metrics).toEqual({
49
- healthScore: 95,
50
- avgLatencyMs: 120,
51
- avgTokensPerSecond: 42.5,
52
- discountRatio: 0.01,
53
- registryOrder: 1
54
- });
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
-
100
- test("falls back to registry candidates when prewarm has no usable sellers", () => {
101
- const result = plan({
102
- prewarmCandidates: [
103
- { sellerId: "missing", url: "https://missing.example.com", healthScore: 100 },
104
- { sellerId: "s3", url: "https://s3.example.com", healthScore: 100 }
105
- ]
106
- });
107
-
108
- expect(result.source).toBe("registry_fallback");
109
- expect(result.sourceReason).toBe("prewarm_no_compatible_candidates");
110
- expect(result.routes.map((route) => route.seller.id)).toEqual(["s1", "s2"]);
111
- expect(result.diagnostics).toMatchObject({
112
- prewarmCandidateCount: 2,
113
- prewarmUsableCount: 0,
114
- prewarmMissingSellerIds: ["missing"],
115
- prewarmBlockedSellerIds: [],
116
- prewarmIncompatibleSellerIds: ["s3"]
117
- });
118
- });
119
-
120
- test("diagnostics explain prewarm fallback caused by blocked warm candidates", () => {
121
- const now = 10_000;
122
- const result = plan({
123
- now,
124
- prewarmCandidates: [
125
- { sellerId: "s1", url: "https://s1.example.com", healthScore: 100 },
126
- { sellerId: "s2", url: "https://s2.example.com", healthScore: 90 }
127
- ],
128
- sellerMetrics: [
129
- { sellerId: "s1", circuit: "open" },
130
- { sellerId: "s2", capacityBlockedUntil: now + 1000 }
131
- ]
132
- });
133
-
134
- expect(result.source).toBe("registry_fallback");
135
- expect(result.sourceReason).toBe("prewarm_no_compatible_candidates");
136
- expect(result.routes).toEqual([]);
137
- expect(result.diagnostics).toMatchObject({
138
- prewarmCandidateCount: 2,
139
- prewarmUsableCount: 0,
140
- prewarmBlockedSellerIds: ["s1", "s2"],
141
- blockedOpenCircuitCount: 1,
142
- blockedCapacityCount: 1,
143
- blockedSellerIds: ["s1", "s2"]
144
- });
145
- });
146
-
147
- test("filters registry fallback by requested model, protocol, and payment method", () => {
148
- const result = plan({
149
- registrySellers: [
150
- seller({ id: "ok", models: ["gpt-5.4"], supportedProtocols: ["chat_completions"], paymentMethods: ["clawtip"] }),
151
- seller({ id: "wrong-model", models: ["MiniMax-M2.7"], supportedProtocols: ["chat_completions"], paymentMethods: ["clawtip"] }),
152
- seller({ id: "wrong-protocol", models: ["gpt-5.4"], supportedProtocols: ["responses"], paymentMethods: ["clawtip"] }),
153
- seller({ id: "wrong-payment", models: ["gpt-5.4"], supportedProtocols: ["chat_completions"], paymentMethods: ["mock"] })
154
- ]
155
- });
156
-
157
- expect(result.source).toBe("registry_fallback");
158
- expect(result.routes.map((route) => route.seller.id)).toEqual(["ok"]);
159
- expect(result.reason).toBe("fullAuto:balanced:routes_1");
160
- expect(result.diagnostics).toEqual({
161
- registryVisibleCount: 4,
162
- prewarmCandidateCount: 0,
163
- prewarmUsableCount: 0,
164
- prewarmMissingSellerIds: [],
165
- prewarmBlockedSellerIds: [],
166
- prewarmIncompatibleSellerIds: [],
167
- sourceCandidateCount: 1,
168
- blockedOpenCircuitCount: 0,
169
- blockedCapacityCount: 0,
170
- blockedLocalConcurrencyCount: 0,
171
- blockedSellerIds: [],
172
- incompatibleCount: 3,
173
- incompatibleSellerIds: ["wrong-model", "wrong-payment", "wrong-protocol"]
174
- });
175
- });
176
-
177
- test("only active registry sellers are visible to buyer routing", () => {
178
- const result = plan({
179
- registrySellers: [
180
- seller({ id: "legacy-no-status" }),
181
- seller({ id: "active", status: "active" }),
182
- seller({ id: "pending", status: "pending" }),
183
- seller({ id: "draining", status: "draining" }),
184
- seller({ id: "offline", status: "offline" })
185
- ]
186
- });
187
-
188
- expect(result.routes.map((route) => route.seller.id)).toEqual(["legacy-no-status", "active"]);
189
- });
190
-
191
- test("fixed mode fails closed when selected seller is outside compatibility", () => {
192
- const result = plan({
193
- routing: { mode: "fixed", sellerId: "s3", scorer: "speed" }
194
- });
195
-
196
- expect(result.routes).toEqual([]);
197
- expect(result.reason).toBe("fixed_seller_not_compatible");
198
- });
199
-
200
- test("fixedSet mode does not select outside the configured seller pool", () => {
201
- const result = plan({
202
- routing: { mode: "fixedSet", sellerIds: ["s1", "s2"], scorer: "speed" },
203
- sellerMetrics: [
204
- { sellerId: "outside", healthScore: 100, avgLatencyMs: 10 },
205
- { sellerId: "s1", healthScore: 40, avgLatencyMs: 20 },
206
- { sellerId: "s2", healthScore: 90, avgLatencyMs: 30 }
207
- ],
208
- registrySellers: [
209
- seller({ id: "outside" }),
210
- seller({ id: "s1" }),
211
- seller({ id: "s2" })
212
- ]
213
- });
214
-
215
- expect(result.routes.map((route) => route.seller.id)).toEqual(["s2", "s1"]);
216
- });
217
-
218
- test("discount scorer uses planner metrics during registry fallback", () => {
219
- const result = plan({
220
- routing: { mode: "fullAuto", scorer: "discount" },
221
- sellerMetrics: [
222
- { sellerId: "s1", healthScore: 100, discountRatio: 1 },
223
- { sellerId: "s2", healthScore: 30, discountRatio: 0.01 }
224
- ]
225
- });
226
-
227
- expect(result.routes.map((route) => route.seller.id)).toEqual(["s2", "s1"]);
228
- });
229
-
230
- test("messages protocol accepts anthropic_messages registry sellers", () => {
231
- const result = plan({
232
- modelId: "MiniMax-M2.7",
233
- protocol: "messages",
234
- registrySellers: [
235
- seller({
236
- id: "minimax",
237
- models: ["MiniMax-M2.7"],
238
- supportedProtocols: ["anthropic_messages"],
239
- paymentMethods: ["clawtip"]
240
- })
241
- ]
242
- });
243
-
244
- expect(result.routes.map((route) => route.seller.id)).toEqual(["minimax"]);
245
- });
246
-
247
- test("open circuit observations are excluded before strategy selection", () => {
248
- const result = plan({
249
- routing: { mode: "fullAuto", scorer: "speed" },
250
- sellerMetrics: [
251
- { sellerId: "s1", healthScore: 100, circuit: "open" },
252
- { sellerId: "s2", healthScore: 20, circuit: "closed" }
253
- ]
254
- });
255
-
256
- expect(result.routes.map((route) => route.seller.id)).toEqual(["s2"]);
257
- expect(result.diagnostics.blockedOpenCircuitCount).toBe(1);
258
- expect(result.diagnostics.blockedSellerIds).toEqual(["s1"]);
259
- });
260
-
261
- test("active capacity blocks are excluded before strategy selection", () => {
262
- const now = 10_000;
263
- const blocked = plan({
264
- now,
265
- routing: { mode: "fullAuto", scorer: "speed" },
266
- sellerMetrics: [
267
- { sellerId: "s1", healthScore: 100, avgLatencyMs: 10, capacityBlockedUntil: now + 1000 },
268
- { sellerId: "s2", healthScore: 20, avgLatencyMs: 100 }
269
- ]
270
- });
271
-
272
- expect(blocked.routes.map((route) => route.seller.id)).toEqual(["s2"]);
273
- expect(blocked.diagnostics.blockedCapacityCount).toBe(1);
274
- expect(blocked.diagnostics.blockedSellerIds).toEqual(["s1"]);
275
-
276
- const expired = plan({
277
- now: now + 1001,
278
- routing: { mode: "fullAuto", scorer: "speed" },
279
- sellerMetrics: [
280
- { sellerId: "s1", healthScore: 100, avgLatencyMs: 10, capacityBlockedUntil: now + 1000 },
281
- { sellerId: "s2", healthScore: 20, avgLatencyMs: 100 }
282
- ]
283
- });
284
-
285
- expect(expired.routes.map((route) => route.seller.id)).toEqual(["s1", "s2"]);
286
- expect(expired.diagnostics.blockedCapacityCount).toBe(0);
287
- expect(expired.diagnostics.blockedSellerIds).toEqual([]);
288
- });
289
-
290
- test("local concurrency blocks are excluded before choosing prewarm or registry candidates", () => {
291
- const result = plan({
292
- prewarmCandidates: [
293
- { sellerId: "s1", url: "https://s1.example.com", healthScore: 100 }
294
- ],
295
- sellerMetrics: [
296
- { sellerId: "s1", localConcurrencyActive: 1, localConcurrencyLimit: 1 },
297
- { sellerId: "s2", localConcurrencyActive: 0, localConcurrencyLimit: 1 }
298
- ]
299
- });
300
-
301
- expect(result.source).toBe("registry_fallback");
302
- expect(result.sourceReason).toBe("prewarm_no_compatible_candidates");
303
- expect(result.routes.map((route) => route.seller.id)).toEqual(["s2"]);
304
- expect(result.diagnostics).toMatchObject({
305
- prewarmCandidateCount: 1,
306
- prewarmUsableCount: 0,
307
- prewarmBlockedSellerIds: ["s1"],
308
- blockedLocalConcurrencyCount: 1,
309
- blockedSellerIds: ["s1"]
310
- });
311
- });
312
- });