@tokenbuddy/tokenbuddy 1.0.8 → 1.0.11

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 (71) hide show
  1. package/dist/src/buyer-store.d.ts +13 -0
  2. package/dist/src/buyer-store.d.ts.map +1 -1
  3. package/dist/src/buyer-store.js +21 -2
  4. package/dist/src/buyer-store.js.map +1 -1
  5. package/dist/src/cli.d.ts.map +1 -1
  6. package/dist/src/cli.js +54 -0
  7. package/dist/src/cli.js.map +1 -1
  8. package/dist/src/credit-tracker.d.ts +118 -0
  9. package/dist/src/credit-tracker.d.ts.map +1 -0
  10. package/dist/src/credit-tracker.js +220 -0
  11. package/dist/src/credit-tracker.js.map +1 -0
  12. package/dist/src/daemon.d.ts +49 -4
  13. package/dist/src/daemon.d.ts.map +1 -1
  14. package/dist/src/daemon.js +541 -405
  15. package/dist/src/daemon.js.map +1 -1
  16. package/dist/src/model-index.d.ts +86 -0
  17. package/dist/src/model-index.d.ts.map +1 -0
  18. package/dist/src/model-index.js +214 -0
  19. package/dist/src/model-index.js.map +1 -0
  20. package/dist/src/prewarm-cache.d.ts +149 -0
  21. package/dist/src/prewarm-cache.d.ts.map +1 -0
  22. package/dist/src/prewarm-cache.js +288 -0
  23. package/dist/src/prewarm-cache.js.map +1 -0
  24. package/dist/src/prewarm-scheduler.d.ts +150 -0
  25. package/dist/src/prewarm-scheduler.d.ts.map +1 -0
  26. package/dist/src/prewarm-scheduler.js +484 -0
  27. package/dist/src/prewarm-scheduler.js.map +1 -0
  28. package/dist/src/provider-install.d.ts.map +1 -1
  29. package/dist/src/provider-install.js +9 -1
  30. package/dist/src/provider-install.js.map +1 -1
  31. package/dist/src/route-failover.d.ts +96 -0
  32. package/dist/src/route-failover.d.ts.map +1 -0
  33. package/dist/src/route-failover.js +177 -0
  34. package/dist/src/route-failover.js.map +1 -0
  35. package/dist/src/seller-catalog.d.ts +26 -0
  36. package/dist/src/seller-catalog.d.ts.map +1 -1
  37. package/dist/src/seller-catalog.js +40 -0
  38. package/dist/src/seller-catalog.js.map +1 -1
  39. package/dist/src/seller-pool.d.ts +127 -0
  40. package/dist/src/seller-pool.d.ts.map +1 -0
  41. package/dist/src/seller-pool.js +243 -0
  42. package/dist/src/seller-pool.js.map +1 -0
  43. package/dist/src/stream-failover.d.ts +78 -0
  44. package/dist/src/stream-failover.d.ts.map +1 -0
  45. package/dist/src/stream-failover.js +93 -0
  46. package/dist/src/stream-failover.js.map +1 -0
  47. package/package.json +1 -1
  48. package/src/buyer-store.ts +32 -2
  49. package/src/cli.ts +61 -0
  50. package/src/credit-tracker.test.ts +165 -0
  51. package/src/credit-tracker.ts +269 -0
  52. package/src/daemon.ts +569 -445
  53. package/src/model-index.test.ts +184 -0
  54. package/src/model-index.ts +266 -0
  55. package/src/prewarm-cache.test.ts +281 -0
  56. package/src/prewarm-cache.ts +373 -0
  57. package/src/prewarm-scheduler.test.ts +367 -0
  58. package/src/prewarm-scheduler.ts +581 -0
  59. package/src/provider-install.ts +9 -1
  60. package/src/route-failover.test.ts +193 -0
  61. package/src/route-failover.ts +233 -0
  62. package/src/seller-catalog-413.test.ts +61 -0
  63. package/src/seller-catalog.ts +47 -0
  64. package/src/seller-pool.test.ts +231 -0
  65. package/src/seller-pool.ts +333 -0
  66. package/src/stream-failover.test.ts +52 -0
  67. package/src/stream-failover.ts +129 -0
  68. package/src/thousand-seller.test.ts +151 -0
  69. package/tests/daemon-413-fallback.test.ts +92 -0
  70. package/tests/e2e.test.ts +3 -2
  71. package/tests/tokenbuddy.test.ts +68 -11
@@ -0,0 +1,193 @@
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 { RouteFailover, type DecideContext } from "../src/route-failover.js";
5
+ import { SellerPool } from "../src/seller-pool.js";
6
+ import type { RegistrySeller } from "../src/seller-catalog.js";
7
+
8
+ function makeSeller(overrides: Partial<RegistrySeller> & { id: string; models?: string[] }): RegistrySeller {
9
+ return {
10
+ id: overrides.id,
11
+ name: overrides.name ?? overrides.id,
12
+ url: overrides.url ?? `https://${overrides.id}.example.com`,
13
+ supportedProtocols: overrides.supportedProtocols ?? ["chat_completions"],
14
+ paymentMethods: overrides.paymentMethods ?? ["clawtip"],
15
+ models: overrides.models
16
+ };
17
+ }
18
+
19
+ function buildHarness(seed: { id: string; healthScore?: number }[] = []) {
20
+ const sellers: RegistrySeller[] = seed.map((s) => makeSeller({ id: s.id, models: ["gpt-4o"] }));
21
+ const index = new ModelIndex();
22
+ index.rebuild(sellers, { registryVersion: 1, defaultSellerId: seed[0]?.id });
23
+ const cache = new PrewarmCache();
24
+ cache.commitWarm({
25
+ modelId: "gpt-4o",
26
+ protocol: "chat_completions",
27
+ paymentMethod: "clawtip",
28
+ candidates: seed.map((s) => ({
29
+ sellerId: s.id,
30
+ url: `https://${s.id}.example.com`,
31
+ healthScore: s.healthScore ?? 80
32
+ }))
33
+ });
34
+ const credit = new CreditTracker();
35
+ const pool = new SellerPool({ modelIndex: index, cache, creditTracker: credit });
36
+ pool.sync();
37
+ const failover = new RouteFailover({ pool, creditTracker: credit });
38
+ return { index, cache, credit, pool, failover, sellers };
39
+ }
40
+
41
+ describe("RouteFailover", () => {
42
+ test("pickNext returns the highest-scoring candidate from the pool", () => {
43
+ const { failover } = buildHarness([{ id: "s1", healthScore: 50 }, { id: "s2", healthScore: 90 }, { id: "s3", healthScore: 70 }]);
44
+ const next = failover.pickNext("gpt-4o", "chat_completions", "clawtip");
45
+ expect(next?.sellerId).toBe("s2");
46
+ });
47
+
48
+ test("pickNext returns undefined when the pool is exhausted", () => {
49
+ const index = new ModelIndex();
50
+ index.rebuild([], { registryVersion: 1 });
51
+ const cache = new PrewarmCache();
52
+ const credit = new CreditTracker();
53
+ const pool = new SellerPool({ modelIndex: index, cache, creditTracker: credit });
54
+ pool.sync();
55
+ const failover = new RouteFailover({ pool, creditTracker: credit });
56
+ expect(failover.pickNext("gpt-4o", "chat_completions", "clawtip")).toBeUndefined();
57
+ });
58
+
59
+ test("decide on hard_4xx immediately returns failover_next and never retries", () => {
60
+ const { failover, credit } = buildHarness([{ id: "s1" }]);
61
+ credit.recordPurchase("s1", 1_000_000, 800_000);
62
+ const context: DecideContext = {
63
+ sellerId: "s1",
64
+ status: 404,
65
+ errorKind: "hard_4xx",
66
+ errorMessage: "model not found",
67
+ attempt: 0
68
+ };
69
+ const decision = failover.decide(context, 1);
70
+ expect(decision.action).toBe("failover_next");
71
+ expect(decision.reason).toBe("hard_failure:hard_4xx");
72
+ expect(decision.freshPurchase).toBe(true);
73
+ // Hard failure transfers leftover to wasted.
74
+ expect(decision.wastedCreditMicros).toBeGreaterThan(0);
75
+ });
76
+
77
+ test("decide on auth_invalid opens the circuit and surfaces failover", () => {
78
+ const { failover, pool } = buildHarness([{ id: "s1" }]);
79
+ const context: DecideContext = {
80
+ sellerId: "s1",
81
+ status: 401,
82
+ errorKind: "auth_invalid",
83
+ errorMessage: "token_invalid",
84
+ attempt: 0
85
+ };
86
+ const decision = failover.decide(context, 1);
87
+ expect(decision.action).toBe("failover_next");
88
+ expect(pool.snapshot()[0].circuit).toBe("open");
89
+ });
90
+
91
+ test("decide on soft_5xx inside fresh-purchase window retries the same seller with jitter", () => {
92
+ const { failover, credit } = buildHarness([{ id: "s1" }]);
93
+ credit.recordPurchase("s1", 1_000_000, 1_000_000);
94
+ const decision = failover.decide(
95
+ { sellerId: "s1", status: 503, errorKind: "soft_5xx", errorMessage: "upstream", attempt: 0 },
96
+ 1
97
+ );
98
+ expect(decision.action).toBe("retry_same_seller");
99
+ expect(decision.retryDelayMs).toBeGreaterThanOrEqual(100);
100
+ expect(decision.retryDelayMs).toBeLessThanOrEqual(400);
101
+ expect(decision.freshPurchase).toBe(true);
102
+ expect(decision.reason).toBe("soft_failure_fresh_purchase_window");
103
+ });
104
+
105
+ test("decide on soft_5xx after exhausting retries returns failover_next", () => {
106
+ const { failover, credit } = buildHarness([{ id: "s1" }]);
107
+ credit.recordPurchase("s1", 1_000_000, 1_000_000);
108
+ const decision = failover.decide(
109
+ { sellerId: "s1", status: 503, errorKind: "soft_5xx", errorMessage: "upstream", attempt: 2 },
110
+ 1
111
+ );
112
+ expect(decision.action).toBe("failover_next");
113
+ expect(decision.reason).toBe("soft_failure_exhausted_retries");
114
+ });
115
+
116
+ test("decide on soft_5xx outside the fresh-purchase window still allows the first retry", () => {
117
+ const { failover, credit } = buildHarness([{ id: "s1" }]);
118
+ // Record a purchase, then spend it past the threshold so the window exits.
119
+ credit.recordPurchase("s1", 1_000_000, 1_000_000);
120
+ credit.recordSpend("s1", 100_000);
121
+ const decision = failover.decide(
122
+ { sellerId: "s1", status: 503, errorKind: "soft_5xx", errorMessage: "upstream", attempt: 0 },
123
+ 1
124
+ );
125
+ expect(decision.action).toBe("retry_same_seller");
126
+ expect(decision.freshPurchase).toBe(false);
127
+ expect(decision.reason).toBe("soft_failure_retry");
128
+ });
129
+
130
+ test("decide on deadline uses the soft-error path", () => {
131
+ const { failover, credit } = buildHarness([{ id: "s1" }]);
132
+ credit.recordPurchase("s1", 1_000_000, 1_000_000);
133
+ const decision = failover.decide(
134
+ { sellerId: "s1", errorKind: "deadline", errorMessage: "buyer timeout", attempt: 0 },
135
+ 1
136
+ );
137
+ expect(decision.action).toBe("retry_same_seller");
138
+ expect(decision.freshPurchase).toBe(true);
139
+ });
140
+
141
+ test("budgetExceeded flag is set when the per-minute purchase budget is exhausted", () => {
142
+ const { failover, credit } = buildHarness([{ id: "s1" }]);
143
+ // Burn through the per-minute budget.
144
+ credit.recordPurchase("s1", 100, 100);
145
+ credit.recordPurchase("s2" in credit.summary().perSeller ? "s2" : "s1", 100, 100);
146
+ credit.recordPurchase("s1", 100, 100);
147
+ credit.recordPurchase("s1", 100, 100);
148
+ const decision = failover.decide(
149
+ { sellerId: "s1", errorKind: "soft_5xx", attempt: 0 },
150
+ 1
151
+ );
152
+ expect(decision.budgetExceeded).toBe(true);
153
+ });
154
+
155
+ test("recordSuccess resets failure counters and reports to the credit tracker", () => {
156
+ const { failover, credit, pool } = buildHarness([{ id: "s1" }]);
157
+ credit.recordPurchase("s1", 1_000_000, 1_000_000);
158
+ failover.decide(
159
+ { sellerId: "s1", errorKind: "soft_5xx", attempt: 0 },
160
+ 1
161
+ );
162
+ failover.decide(
163
+ { sellerId: "s1", errorKind: "soft_5xx", attempt: 1 },
164
+ 1
165
+ );
166
+ failover.recordSuccess("s1", 750_000);
167
+ const entry = pool.snapshot()[0];
168
+ expect(entry.consecutiveFailures).toBe(0);
169
+ expect(credit.getEntry("s1")?.currentBalanceMicros).toBe(750_000);
170
+ });
171
+
172
+ test("shouldAbort returns true when no more candidates are available", () => {
173
+ const { failover } = buildHarness([{ id: "s1" }]);
174
+ expect(failover.shouldAbort(1, false)).toBe(true);
175
+ expect(failover.shouldAbort(1, true)).toBe(false);
176
+ });
177
+
178
+ test("canAutoPurchase mirrors the credit tracker budget", () => {
179
+ const { failover, credit } = buildHarness([{ id: "s1" }]);
180
+ expect(failover.canAutoPurchase()).toBe(true);
181
+ credit.recordPurchase("s1", 100, 100);
182
+ credit.recordPurchase("s1", 100, 100);
183
+ credit.recordPurchase("s1", 100, 100);
184
+ expect(failover.canAutoPurchase()).toBe(false);
185
+ });
186
+
187
+ test("wastedMicrosFor returns the latest known balance for a seller", () => {
188
+ const { failover, credit } = buildHarness([{ id: "s1" }]);
189
+ expect(failover.wastedMicrosFor("s1")).toBe(0);
190
+ credit.recordPurchase("s1", 1_000_000, 500_000);
191
+ expect(failover.wastedMicrosFor("s1")).toBe(500_000);
192
+ });
193
+ });
@@ -0,0 +1,233 @@
1
+ import { createModuleLogger } from "@tokenbuddy/logging";
2
+ import type { RegistrySeller } from "./seller-catalog.js";
3
+ import type { SellerPool, FailureKind, PoolEntry } from "./seller-pool.js";
4
+ import type { CreditTracker } from "./credit-tracker.js";
5
+
6
+ const logger = createModuleLogger("tb-proxyd:route-failover");
7
+
8
+ export type RouteAction = "retry_same_seller" | "failover_next" | "fail_fast" | "abort";
9
+
10
+ export interface FailoverDecision {
11
+ action: RouteAction;
12
+ retryDelayMs?: number;
13
+ reason: string;
14
+ wastedCreditMicros?: number;
15
+ freshPurchase: boolean;
16
+ retryAttemptsBeforeFailover: number;
17
+ budgetExceeded: boolean;
18
+ }
19
+
20
+ export interface RouteCandidate {
21
+ routeIndex: number;
22
+ entry: PoolEntry;
23
+ registrySeller: RegistrySeller;
24
+ sellerId: string;
25
+ url: string;
26
+ }
27
+
28
+ export interface DecideContext {
29
+ sellerId: string;
30
+ status?: number;
31
+ errorKind: FailureKind;
32
+ errorMessage?: string;
33
+ attempt: number;
34
+ }
35
+
36
+ export interface RouteFailoverOptions {
37
+ pool: SellerPool;
38
+ creditTracker: CreditTracker;
39
+ // v1.1 / v1.2 design defaults; exposed for tests.
40
+ softRetryAttempts?: number; // default 2
41
+ softRetryJitterMinMs?: number; // default 100
42
+ softRetryJitterMaxMs?: number; // default 400
43
+ random?: () => number;
44
+ now?: () => number;
45
+ }
46
+
47
+ const DEFAULTS = {
48
+ softRetryAttempts: 2,
49
+ softRetryJitterMinMs: 100,
50
+ softRetryJitterMaxMs: 400
51
+ };
52
+
53
+ /**
54
+ * Decision engine that wraps the v2 `SellerPool` and the
55
+ * `CreditTracker`. v1.2 §17.3 - §17.8 defines the rules: the controller
56
+ * calls `decide()` after a failure to learn whether to retry the same
57
+ * seller, fail over to the next candidate, fail fast, or give up.
58
+ *
59
+ * The controller is intentionally side-effect-light: it delegates
60
+ * bookkeeping (circuit transitions, wasted-credit transfer) to the pool
61
+ * and tracker via `recordFailure`, and only returns a structured
62
+ * decision. This makes the rules unit-testable without a live HTTP server.
63
+ */
64
+ export class RouteFailover {
65
+ private readonly pool: SellerPool;
66
+ private readonly creditTracker: CreditTracker;
67
+ private readonly softRetryAttempts: number;
68
+ private readonly softRetryJitterMinMs: number;
69
+ private readonly softRetryJitterMaxMs: number;
70
+ private readonly random: () => number;
71
+ private readonly now: () => number;
72
+
73
+ constructor(options: RouteFailoverOptions) {
74
+ this.pool = options.pool;
75
+ this.creditTracker = options.creditTracker;
76
+ this.softRetryAttempts = options.softRetryAttempts ?? DEFAULTS.softRetryAttempts;
77
+ this.softRetryJitterMinMs = options.softRetryJitterMinMs ?? DEFAULTS.softRetryJitterMinMs;
78
+ this.softRetryJitterMaxMs = options.softRetryJitterMaxMs ?? DEFAULTS.softRetryJitterMaxMs;
79
+ this.random = options.random ?? Math.random;
80
+ this.now = options.now ?? Date.now;
81
+ }
82
+
83
+ /**
84
+ * Pick the next candidate from the pool. Returns `undefined` when the
85
+ * pool is exhausted, which the caller treats as `abort` (no more routes
86
+ * to try). The returned `routeIndex` is informational (used for log
87
+ * lines) and tracks the absolute ordering across attempts; the controller
88
+ * maintains its own counter.
89
+ */
90
+ pickNext(modelId: string, protocol: string, paymentMethod: string, limit: number = 4): RouteCandidate | undefined {
91
+ const result = this.pool.pick({ modelId, protocol, paymentMethod, limit });
92
+ if (result.candidates.length === 0) {
93
+ return undefined;
94
+ }
95
+ const first = result.candidates[0];
96
+ return {
97
+ routeIndex: 0,
98
+ entry: first.entry,
99
+ registrySeller: first.registrySeller,
100
+ sellerId: first.entry.sellerId,
101
+ url: first.entry.url
102
+ };
103
+ }
104
+
105
+ /**
106
+ * Mark a successful inference against `sellerId` with the latest known
107
+ * balance. Mirrors `SellerPool.recordSuccess` semantics: resets the
108
+ * failure counter, closes the circuit, and reports the spend to the
109
+ * credit tracker.
110
+ */
111
+ recordSuccess(sellerId: string, balanceMicros: number): void {
112
+ this.pool.recordSuccess(sellerId, balanceMicros, this.now());
113
+ }
114
+
115
+ /**
116
+ * Decide what to do after a failure. Returns a structured
117
+ * `FailoverDecision`; the controller (daemon.ts) executes the action.
118
+ * The decision also drives accounting: on a failover, the wasted
119
+ * balance is reported in `wastedCreditMicros` for the doctor's summary.
120
+ */
121
+ decide(context: DecideContext, totalCandidates: number): FailoverDecision {
122
+ const isHard = context.errorKind === "hard_4xx" || context.errorKind === "auth_invalid" || context.errorKind === "no_compatible";
123
+ const isSoft = context.errorKind === "soft_5xx" || context.errorKind === "deadline";
124
+ const info = this.pool.inspect(context.sellerId);
125
+ const freshPurchase = info.freshPurchase;
126
+ const budgetExceeded = !this.creditTracker.canAutoPurchase(this.now());
127
+
128
+ // Always mark the failure on the pool so circuit thresholds advance.
129
+ const updated = this.pool.recordFailure(context.sellerId, context.errorKind, {
130
+ reason: context.errorMessage,
131
+ now: this.now()
132
+ });
133
+
134
+ if (isHard) {
135
+ // Hard failures are not eligible for retry; the seller is wrong
136
+ // for this request. The pool has already transferred leftover
137
+ // credit to the wasted bucket.
138
+ return {
139
+ action: "failover_next",
140
+ reason: `hard_failure:${context.errorKind}`,
141
+ wastedCreditMicros: this.creditTracker.getEntry(context.sellerId)?.leftoverCreditMicros,
142
+ freshPurchase,
143
+ retryAttemptsBeforeFailover: context.attempt,
144
+ budgetExceeded
145
+ };
146
+ }
147
+
148
+ if (isSoft && freshPurchase && context.attempt < this.softRetryAttempts) {
149
+ // Soft failure inside the fresh-purchase window: retry the same
150
+ // seller with jittered backoff. This is the v1.1 protection against
151
+ // throwing freshly bought credit onto a flaky upstream.
152
+ const span = Math.max(0, this.softRetryJitterMaxMs - this.softRetryJitterMinMs);
153
+ const delay = this.softRetryJitterMinMs + Math.floor(this.random() * span);
154
+ logger.info("route.retry_same_seller.soft", "soft failure in fresh-purchase window; retrying same seller", {
155
+ sellerId: context.sellerId,
156
+ attempt: context.attempt,
157
+ delayMs: delay
158
+ });
159
+ return {
160
+ action: "retry_same_seller",
161
+ retryDelayMs: delay,
162
+ reason: "soft_failure_fresh_purchase_window",
163
+ freshPurchase: true,
164
+ retryAttemptsBeforeFailover: context.attempt,
165
+ budgetExceeded
166
+ };
167
+ }
168
+
169
+ if (isSoft && context.attempt < this.softRetryAttempts) {
170
+ // Soft failure but not in the fresh-purchase window: still retry
171
+ // once (caller asked for `attempt < softRetryAttempts`) but log the
172
+ // reason. This keeps the "give the seller a second chance" window
173
+ // available for cold sellers without burning any credit.
174
+ const span = Math.max(0, this.softRetryJitterMaxMs - this.softRetryJitterMinMs);
175
+ const delay = this.softRetryJitterMinMs + Math.floor(this.random() * span);
176
+ logger.info("route.retry_same_seller.soft", "soft failure retry outside fresh-purchase window", {
177
+ sellerId: context.sellerId,
178
+ attempt: context.attempt,
179
+ delayMs: delay
180
+ });
181
+ return {
182
+ action: "retry_same_seller",
183
+ retryDelayMs: delay,
184
+ reason: "soft_failure_retry",
185
+ freshPurchase: false,
186
+ retryAttemptsBeforeFailover: context.attempt,
187
+ budgetExceeded
188
+ };
189
+ }
190
+
191
+ // Default: fail over. The wasted credit is whatever current balance
192
+ // remained on the seller after the failure was recorded (the pool
193
+ // transfers it to the leftover bucket for hard failures; for soft
194
+ // failures we surface the *pre-failure* balance so the user sees the
195
+ // impact of the cut-over).
196
+ const wasted = updated?.lastFailAt
197
+ ? this.creditTracker.getEntry(context.sellerId)?.currentBalanceMicros
198
+ : undefined;
199
+ return {
200
+ action: "failover_next",
201
+ reason: isSoft ? "soft_failure_exhausted_retries" : `${context.errorKind}`,
202
+ wastedCreditMicros: wasted,
203
+ freshPurchase,
204
+ retryAttemptsBeforeFailover: context.attempt,
205
+ budgetExceeded
206
+ };
207
+ }
208
+
209
+ /**
210
+ * Convenience: returns true when the next request should be aborted
211
+ * outright (no more candidates).
212
+ */
213
+ shouldAbort(totalCandidates: number, hasMoreCandidates: boolean): boolean {
214
+ return !hasMoreCandidates;
215
+ }
216
+
217
+ /**
218
+ * Inspect the auto-purchase budget. The controller calls this before
219
+ * triggering a fresh `purchase/create` round-trip so it can refuse to
220
+ * auto-purchase once the session is at risk.
221
+ */
222
+ canAutoPurchase(): boolean {
223
+ return this.creditTracker.canAutoPurchase(this.now());
224
+ }
225
+
226
+ /**
227
+ * Surface the wasted credit so far for the route currently failing over.
228
+ * Returned in micros.
229
+ */
230
+ wastedMicrosFor(sellerId: string): number {
231
+ return this.creditTracker.getEntry(sellerId)?.currentBalanceMicros ?? 0;
232
+ }
233
+ }
@@ -0,0 +1,61 @@
1
+ import { RegistryTooLargeError, fetchSellerRegistry } from "../src/seller-catalog.js";
2
+
3
+ /**
4
+ * v1.2 §18.9: registry-too-large fallback. The bootstrap returns 413
5
+ * with a structured body when the serialized registry exceeds 1MB; the
6
+ * buyer must surface this as a typed error so the daemon can fall back
7
+ * to the last-known snapshot.
8
+ */
9
+ describe("RegistryTooLargeError + fetchSellerRegistry 413", () => {
10
+ const originalFetch = globalThis.fetch;
11
+
12
+ afterEach(() => {
13
+ globalThis.fetch = originalFetch;
14
+ });
15
+
16
+ function mockJsonResponse(status: number, body: unknown): Response {
17
+ return new Response(JSON.stringify(body), {
18
+ status,
19
+ headers: { "Content-Type": "application/json" }
20
+ });
21
+ }
22
+
23
+ test("fetchSellerRegistry throws RegistryTooLargeError on HTTP 413 with the bootstrap's payload", async () => {
24
+ globalThis.fetch = (async () =>
25
+ mockJsonResponse(413, {
26
+ error: "registry_too_large",
27
+ sizeBytes: 1_500_000,
28
+ sellerCount: 2_400,
29
+ maxBytes: 1_000_000
30
+ })) as unknown as typeof globalThis.fetch;
31
+ await expect(
32
+ fetchSellerRegistry("https://bootstrap.example/registry/sellers")
33
+ ).rejects.toMatchObject({
34
+ name: "RegistryTooLargeError",
35
+ status: 413,
36
+ sizeBytes: 1_500_000,
37
+ sellerCount: 2_400,
38
+ maxBytes: 1_000_000
39
+ });
40
+ });
41
+
42
+ test("fetchSellerRegistry still surfaces generic non-2xx as plain errors", async () => {
43
+ globalThis.fetch = (async () => mockJsonResponse(500, { error: "internal" })) as unknown as typeof globalThis.fetch;
44
+ await expect(
45
+ fetchSellerRegistry("https://bootstrap.example/registry/sellers")
46
+ ).rejects.toThrow(/registry returned 500/);
47
+ });
48
+
49
+ test("RegistryTooLargeError is a stable Error subclass with a useful message", () => {
50
+ const err = new RegistryTooLargeError({
51
+ status: 413,
52
+ sizeBytes: 2_500_000,
53
+ sellerCount: 1500,
54
+ maxBytes: 1_000_000
55
+ });
56
+ expect(err).toBeInstanceOf(Error);
57
+ expect(err.name).toBe("RegistryTooLargeError");
58
+ expect(err.message).toContain("1500 sellers");
59
+ expect(err.message).toContain("2500000");
60
+ });
61
+ });
@@ -10,6 +10,13 @@ export interface RegistrySeller {
10
10
  url: string;
11
11
  supportedProtocols?: string[];
12
12
  paymentMethods?: string[];
13
+ /**
14
+ * v1.2: authoritative model list for buyer-side model-index routing.
15
+ * Optional at the buyer boundary for backward compatibility with older
16
+ * registry payloads, but the upstream wallet-bootstrap registry schema
17
+ * requires it. Missing entries are reported via `models_refresh.seller_missing_models`.
18
+ */
19
+ models?: string[];
13
20
  }
14
21
 
15
22
  export interface SellerRegistryDocument {
@@ -111,8 +118,48 @@ function manifestModels(manifest: SellerManifest): ManifestModelRecord[] {
111
118
  .filter((model): model is ManifestModelRecord => Boolean(model?.id && typeof model.id === "string"));
112
119
  }
113
120
 
121
+ /**
122
+ * v1.2 §18.9: thrown when the bootstrap's `/registry/sellers` endpoint
123
+ * returns HTTP 413 with the `X-TokenBuddy-Registry-Too-Large: 1` header.
124
+ * The daemon catches this in `TokenbuddyDaemon.fetchRegistry` and falls
125
+ * back to the last-known snapshot so the buyer stays routable while the
126
+ * operator shrinks the registry below the 1MB cap.
127
+ */
128
+ export class RegistryTooLargeError extends Error {
129
+ readonly status: number;
130
+ readonly sizeBytes: number;
131
+ readonly sellerCount: number;
132
+ readonly maxBytes: number;
133
+ constructor(detail: { status: number; sizeBytes: number; sellerCount: number; maxBytes: number }) {
134
+ super(`registry response exceeds ${detail.maxBytes} bytes (got ${detail.sizeBytes}, ${detail.sellerCount} sellers, status ${detail.status})`);
135
+ this.name = "RegistryTooLargeError";
136
+ this.status = detail.status;
137
+ this.sizeBytes = detail.sizeBytes;
138
+ this.sellerCount = detail.sellerCount;
139
+ this.maxBytes = detail.maxBytes;
140
+ }
141
+ }
142
+
114
143
  export async function fetchSellerRegistry(registryUrl: string): Promise<SellerRegistryDocument> {
115
144
  const response = await fetch(registryUrl);
145
+ if (response.status === 413) {
146
+ // v1.2 §18.9: parse the structured 413 body so the caller can
147
+ // decide whether to fall back to a stale snapshot.
148
+ let sizeBytes = 0;
149
+ let sellerCount = 0;
150
+ let maxBytes = 0;
151
+ try {
152
+ const body = (await response.json()) as { sizeBytes?: number; sellerCount?: number; maxBytes?: number };
153
+ sizeBytes = body.sizeBytes ?? 0;
154
+ sellerCount = body.sellerCount ?? 0;
155
+ maxBytes = body.maxBytes ?? 0;
156
+ } catch {
157
+ // Fall through with zeroes; the error message still carries the
158
+ // status code which is enough for the daemon's stale-cache
159
+ // branch to fire.
160
+ }
161
+ throw new RegistryTooLargeError({ status: response.status, sizeBytes, sellerCount, maxBytes });
162
+ }
116
163
  if (!response.ok) {
117
164
  throw new Error(`registry returned ${response.status}`);
118
165
  }