@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.
Files changed (143) hide show
  1. package/dist/src/buyer-store.d.ts +6 -1
  2. package/dist/src/buyer-store.js +43 -4
  3. package/dist/src/cli.js +2 -2
  4. package/dist/src/daemon.d.ts +12 -0
  5. package/dist/src/daemon.js +791 -61
  6. package/dist/src/doctor-diagnostics.js +1 -6
  7. package/dist/src/provider-install.d.ts +2 -2
  8. package/dist/src/provider-install.js +248 -2
  9. package/dist/src/seller-catalog.d.ts +21 -0
  10. package/dist/src/seller-catalog.js +17 -0
  11. package/dist/src/seller-route-planner.d.ts +4 -1
  12. package/dist/src/seller-route-planner.js +3 -0
  13. package/dist/src/seller-routing-strategy.d.ts +3 -0
  14. package/dist/src/terminal-detect.d.ts +1 -1
  15. package/dist/src/terminal-detect.js +3 -2
  16. package/package.json +15 -2
  17. package/static/ui/assets/index-Djfl9tw5.js +271 -0
  18. package/static/ui/assets/index-DkfztCkn.css +1 -0
  19. package/static/ui/index.html +2 -2
  20. package/dist/src/buyer-store.d.ts.map +0 -1
  21. package/dist/src/buyer-store.js.map +0 -1
  22. package/dist/src/clawtip-bootstrap.d.ts.map +0 -1
  23. package/dist/src/clawtip-bootstrap.js.map +0 -1
  24. package/dist/src/cli.d.ts.map +0 -1
  25. package/dist/src/cli.js.map +0 -1
  26. package/dist/src/credit-tracker.d.ts.map +0 -1
  27. package/dist/src/credit-tracker.js.map +0 -1
  28. package/dist/src/daemon.d.ts.map +0 -1
  29. package/dist/src/daemon.js.map +0 -1
  30. package/dist/src/doctor-clawtip-wallet.d.ts.map +0 -1
  31. package/dist/src/doctor-clawtip-wallet.js.map +0 -1
  32. package/dist/src/doctor-diagnostics.d.ts.map +0 -1
  33. package/dist/src/doctor-diagnostics.js.map +0 -1
  34. package/dist/src/index.d.ts.map +0 -1
  35. package/dist/src/index.js.map +0 -1
  36. package/dist/src/init-clawtip-activation.d.ts.map +0 -1
  37. package/dist/src/init-clawtip-activation.js.map +0 -1
  38. package/dist/src/init-payment-options.d.ts.map +0 -1
  39. package/dist/src/init-payment-options.js.map +0 -1
  40. package/dist/src/init-setup.d.ts.map +0 -1
  41. package/dist/src/init-setup.js.map +0 -1
  42. package/dist/src/model-index.d.ts.map +0 -1
  43. package/dist/src/model-index.js.map +0 -1
  44. package/dist/src/package-update.d.ts.map +0 -1
  45. package/dist/src/package-update.js.map +0 -1
  46. package/dist/src/prewarm-cache.d.ts.map +0 -1
  47. package/dist/src/prewarm-cache.js.map +0 -1
  48. package/dist/src/prewarm-scheduler.d.ts.map +0 -1
  49. package/dist/src/prewarm-scheduler.js.map +0 -1
  50. package/dist/src/provider-install.d.ts.map +0 -1
  51. package/dist/src/provider-install.js.map +0 -1
  52. package/dist/src/provider-routing-config.d.ts.map +0 -1
  53. package/dist/src/provider-routing-config.js.map +0 -1
  54. package/dist/src/registry-trust.d.ts.map +0 -1
  55. package/dist/src/registry-trust.js.map +0 -1
  56. package/dist/src/route-failover.d.ts.map +0 -1
  57. package/dist/src/route-failover.js.map +0 -1
  58. package/dist/src/seller-catalog.d.ts.map +0 -1
  59. package/dist/src/seller-catalog.js.map +0 -1
  60. package/dist/src/seller-concurrency-limiter.d.ts.map +0 -1
  61. package/dist/src/seller-concurrency-limiter.js.map +0 -1
  62. package/dist/src/seller-metadata-cache.d.ts.map +0 -1
  63. package/dist/src/seller-metadata-cache.js.map +0 -1
  64. package/dist/src/seller-pool.d.ts.map +0 -1
  65. package/dist/src/seller-pool.js.map +0 -1
  66. package/dist/src/seller-route-planner.d.ts.map +0 -1
  67. package/dist/src/seller-route-planner.js.map +0 -1
  68. package/dist/src/seller-routing-config.d.ts.map +0 -1
  69. package/dist/src/seller-routing-config.js.map +0 -1
  70. package/dist/src/seller-routing-strategy.d.ts.map +0 -1
  71. package/dist/src/seller-routing-strategy.js.map +0 -1
  72. package/dist/src/stream-failover.d.ts.map +0 -1
  73. package/dist/src/stream-failover.js.map +0 -1
  74. package/dist/src/tb-clawtip-proof.d.ts.map +0 -1
  75. package/dist/src/tb-clawtip-proof.js.map +0 -1
  76. package/dist/src/tb-proxyd.d.ts.map +0 -1
  77. package/dist/src/tb-proxyd.js.map +0 -1
  78. package/dist/src/terminal-detect.d.ts.map +0 -1
  79. package/dist/src/terminal-detect.js.map +0 -1
  80. package/dist/src/terminal-image.d.ts.map +0 -1
  81. package/dist/src/terminal-image.js.map +0 -1
  82. package/src/buyer-store.ts +0 -1090
  83. package/src/clawtip-bootstrap.ts +0 -65
  84. package/src/cli.ts +0 -2243
  85. package/src/credit-tracker.ts +0 -295
  86. package/src/daemon.ts +0 -5475
  87. package/src/doctor-clawtip-wallet.ts +0 -95
  88. package/src/doctor-diagnostics.ts +0 -1026
  89. package/src/index.ts +0 -16
  90. package/src/init-clawtip-activation.ts +0 -695
  91. package/src/init-payment-options.ts +0 -373
  92. package/src/init-setup.ts +0 -165
  93. package/src/model-index.ts +0 -278
  94. package/src/package-update.ts +0 -311
  95. package/src/prewarm-cache.ts +0 -485
  96. package/src/prewarm-scheduler.ts +0 -675
  97. package/src/provider-install.ts +0 -1006
  98. package/src/provider-routing-config.ts +0 -410
  99. package/src/registry-trust.ts +0 -51
  100. package/src/route-failover.ts +0 -304
  101. package/src/seller-catalog.ts +0 -505
  102. package/src/seller-concurrency-limiter.ts +0 -161
  103. package/src/seller-metadata-cache.ts +0 -91
  104. package/src/seller-pool.ts +0 -557
  105. package/src/seller-route-planner.ts +0 -513
  106. package/src/seller-routing-config.ts +0 -211
  107. package/src/seller-routing-strategy.ts +0 -362
  108. package/src/stream-failover.ts +0 -152
  109. package/src/tb-clawtip-proof.ts +0 -28
  110. package/src/tb-proxyd.ts +0 -101
  111. package/src/terminal-detect.ts +0 -333
  112. package/src/terminal-image.ts +0 -228
  113. package/static/ui/assets/index-0MVXD7bH.css +0 -1
  114. package/static/ui/assets/index-BVbeDEwq.js +0 -271
  115. package/static/ui/assets/index-BVbeDEwq.js.map +0 -1
  116. package/tests/cli-routing.test.ts +0 -363
  117. package/tests/control-plane-ui-endpoints.test.ts +0 -1630
  118. package/tests/credit-tracker.test.ts +0 -165
  119. package/tests/daemon-413-fallback.test.ts +0 -92
  120. package/tests/daemon-classify.test.ts +0 -452
  121. package/tests/daemon-roles.test.ts +0 -92
  122. package/tests/daemon-trusted-registry-cache.test.ts +0 -132
  123. package/tests/e2e.test.ts +0 -366
  124. package/tests/image-generation-e2e.test.ts +0 -230
  125. package/tests/model-index.test.ts +0 -198
  126. package/tests/package-update.test.ts +0 -147
  127. package/tests/prewarm-cache.test.ts +0 -296
  128. package/tests/prewarm-scheduler.test.ts +0 -367
  129. package/tests/provider-routing-config.test.ts +0 -150
  130. package/tests/registry-trust.test.ts +0 -28
  131. package/tests/route-failover.test.ts +0 -222
  132. package/tests/seller-catalog-413.test.ts +0 -120
  133. package/tests/seller-catalog-utilities.test.ts +0 -124
  134. package/tests/seller-concurrency-limiter.test.ts +0 -83
  135. package/tests/seller-metadata-cache.test.ts +0 -89
  136. package/tests/seller-pool.test.ts +0 -365
  137. package/tests/seller-route-planner.test.ts +0 -312
  138. package/tests/seller-routing-config.test.ts +0 -124
  139. package/tests/seller-routing-strategy.test.ts +0 -167
  140. package/tests/stream-failover.test.ts +0 -52
  141. package/tests/thousand-seller.test.ts +0 -151
  142. package/tests/tokenbuddy.test.ts +0 -4043
  143. 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
- });