@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.
- package/dist/src/buyer-store.d.ts +7 -2
- package/dist/src/buyer-store.js +46 -7
- package/dist/src/cli.d.ts +1 -0
- package/dist/src/cli.js +15 -7
- package/dist/src/daemon.d.ts +12 -0
- package/dist/src/daemon.js +791 -61
- package/dist/src/doctor-diagnostics.js +1 -6
- package/dist/src/provider-install.d.ts +2 -2
- package/dist/src/provider-install.js +248 -2
- package/dist/src/seller-catalog.d.ts +21 -0
- package/dist/src/seller-catalog.js +17 -0
- package/dist/src/seller-route-planner.d.ts +4 -1
- package/dist/src/seller-route-planner.js +3 -0
- package/dist/src/seller-routing-strategy.d.ts +3 -0
- package/dist/src/terminal-detect.d.ts +1 -1
- package/dist/src/terminal-detect.js +3 -2
- package/dist/src/workdir.d.ts +10 -0
- package/dist/src/workdir.js +26 -0
- package/package.json +15 -2
- package/static/ui/assets/index-Djfl9tw5.js +271 -0
- package/static/ui/assets/index-DkfztCkn.css +1 -0
- package/static/ui/index.html +2 -2
- package/dist/src/buyer-store.d.ts.map +0 -1
- package/dist/src/buyer-store.js.map +0 -1
- package/dist/src/clawtip-bootstrap.d.ts.map +0 -1
- package/dist/src/clawtip-bootstrap.js.map +0 -1
- package/dist/src/cli.d.ts.map +0 -1
- package/dist/src/cli.js.map +0 -1
- package/dist/src/credit-tracker.d.ts.map +0 -1
- package/dist/src/credit-tracker.js.map +0 -1
- package/dist/src/daemon.d.ts.map +0 -1
- package/dist/src/daemon.js.map +0 -1
- package/dist/src/doctor-clawtip-wallet.d.ts.map +0 -1
- package/dist/src/doctor-clawtip-wallet.js.map +0 -1
- package/dist/src/doctor-diagnostics.d.ts.map +0 -1
- package/dist/src/doctor-diagnostics.js.map +0 -1
- package/dist/src/index.d.ts.map +0 -1
- package/dist/src/index.js.map +0 -1
- package/dist/src/init-clawtip-activation.d.ts.map +0 -1
- package/dist/src/init-clawtip-activation.js.map +0 -1
- package/dist/src/init-payment-options.d.ts.map +0 -1
- package/dist/src/init-payment-options.js.map +0 -1
- package/dist/src/init-setup.d.ts.map +0 -1
- package/dist/src/init-setup.js.map +0 -1
- package/dist/src/model-index.d.ts.map +0 -1
- package/dist/src/model-index.js.map +0 -1
- package/dist/src/package-update.d.ts.map +0 -1
- package/dist/src/package-update.js.map +0 -1
- package/dist/src/prewarm-cache.d.ts.map +0 -1
- package/dist/src/prewarm-cache.js.map +0 -1
- package/dist/src/prewarm-scheduler.d.ts.map +0 -1
- package/dist/src/prewarm-scheduler.js.map +0 -1
- package/dist/src/provider-install.d.ts.map +0 -1
- package/dist/src/provider-install.js.map +0 -1
- package/dist/src/provider-routing-config.d.ts.map +0 -1
- package/dist/src/provider-routing-config.js.map +0 -1
- package/dist/src/registry-trust.d.ts.map +0 -1
- package/dist/src/registry-trust.js.map +0 -1
- package/dist/src/route-failover.d.ts.map +0 -1
- package/dist/src/route-failover.js.map +0 -1
- package/dist/src/seller-catalog.d.ts.map +0 -1
- package/dist/src/seller-catalog.js.map +0 -1
- package/dist/src/seller-concurrency-limiter.d.ts.map +0 -1
- package/dist/src/seller-concurrency-limiter.js.map +0 -1
- package/dist/src/seller-metadata-cache.d.ts.map +0 -1
- package/dist/src/seller-metadata-cache.js.map +0 -1
- package/dist/src/seller-pool.d.ts.map +0 -1
- package/dist/src/seller-pool.js.map +0 -1
- package/dist/src/seller-route-planner.d.ts.map +0 -1
- package/dist/src/seller-route-planner.js.map +0 -1
- package/dist/src/seller-routing-config.d.ts.map +0 -1
- package/dist/src/seller-routing-config.js.map +0 -1
- package/dist/src/seller-routing-strategy.d.ts.map +0 -1
- package/dist/src/seller-routing-strategy.js.map +0 -1
- package/dist/src/stream-failover.d.ts.map +0 -1
- package/dist/src/stream-failover.js.map +0 -1
- package/dist/src/tb-clawtip-proof.d.ts.map +0 -1
- package/dist/src/tb-clawtip-proof.js.map +0 -1
- package/dist/src/tb-proxyd.d.ts.map +0 -1
- package/dist/src/tb-proxyd.js.map +0 -1
- package/dist/src/terminal-detect.d.ts.map +0 -1
- package/dist/src/terminal-detect.js.map +0 -1
- package/dist/src/terminal-image.d.ts.map +0 -1
- package/dist/src/terminal-image.js.map +0 -1
- package/src/buyer-store.ts +0 -1090
- package/src/clawtip-bootstrap.ts +0 -65
- package/src/cli.ts +0 -2243
- package/src/credit-tracker.ts +0 -295
- package/src/daemon.ts +0 -5475
- package/src/doctor-clawtip-wallet.ts +0 -95
- package/src/doctor-diagnostics.ts +0 -1026
- package/src/index.ts +0 -16
- package/src/init-clawtip-activation.ts +0 -695
- package/src/init-payment-options.ts +0 -373
- package/src/init-setup.ts +0 -165
- package/src/model-index.ts +0 -278
- package/src/package-update.ts +0 -311
- package/src/prewarm-cache.ts +0 -485
- package/src/prewarm-scheduler.ts +0 -675
- package/src/provider-install.ts +0 -1006
- package/src/provider-routing-config.ts +0 -410
- package/src/registry-trust.ts +0 -51
- package/src/route-failover.ts +0 -304
- package/src/seller-catalog.ts +0 -505
- package/src/seller-concurrency-limiter.ts +0 -161
- package/src/seller-metadata-cache.ts +0 -91
- package/src/seller-pool.ts +0 -557
- package/src/seller-route-planner.ts +0 -513
- package/src/seller-routing-config.ts +0 -211
- package/src/seller-routing-strategy.ts +0 -362
- package/src/stream-failover.ts +0 -152
- package/src/tb-clawtip-proof.ts +0 -28
- package/src/tb-proxyd.ts +0 -101
- package/src/terminal-detect.ts +0 -333
- package/src/terminal-image.ts +0 -228
- package/static/ui/assets/index-0MVXD7bH.css +0 -1
- package/static/ui/assets/index-BVbeDEwq.js +0 -271
- package/static/ui/assets/index-BVbeDEwq.js.map +0 -1
- package/tests/cli-routing.test.ts +0 -363
- package/tests/control-plane-ui-endpoints.test.ts +0 -1630
- package/tests/credit-tracker.test.ts +0 -165
- package/tests/daemon-413-fallback.test.ts +0 -92
- package/tests/daemon-classify.test.ts +0 -452
- package/tests/daemon-roles.test.ts +0 -92
- package/tests/daemon-trusted-registry-cache.test.ts +0 -132
- package/tests/e2e.test.ts +0 -366
- package/tests/image-generation-e2e.test.ts +0 -230
- package/tests/model-index.test.ts +0 -198
- package/tests/package-update.test.ts +0 -147
- package/tests/prewarm-cache.test.ts +0 -296
- package/tests/prewarm-scheduler.test.ts +0 -367
- package/tests/provider-routing-config.test.ts +0 -150
- package/tests/registry-trust.test.ts +0 -28
- package/tests/route-failover.test.ts +0 -222
- package/tests/seller-catalog-413.test.ts +0 -120
- package/tests/seller-catalog-utilities.test.ts +0 -124
- package/tests/seller-concurrency-limiter.test.ts +0 -83
- package/tests/seller-metadata-cache.test.ts +0 -89
- package/tests/seller-pool.test.ts +0 -365
- package/tests/seller-route-planner.test.ts +0 -312
- package/tests/seller-routing-config.test.ts +0 -124
- package/tests/seller-routing-strategy.test.ts +0 -167
- package/tests/stream-failover.test.ts +0 -52
- package/tests/thousand-seller.test.ts +0 -151
- package/tests/tokenbuddy.test.ts +0 -4043
- package/tsconfig.json +0 -8
|
@@ -1,222 +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 { 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 busy_capacity fails over without retrying or opening the circuit", () => {
|
|
106
|
-
const { failover, credit, pool } = buildHarness([{ id: "s1" }, { id: "s2" }]);
|
|
107
|
-
credit.recordPurchase("s1", 1_000_000, 1_000_000);
|
|
108
|
-
const decision = failover.decide(
|
|
109
|
-
{ sellerId: "s1", status: 429, errorKind: "busy_capacity", errorMessage: "capacity full", attempt: 0 },
|
|
110
|
-
2
|
|
111
|
-
);
|
|
112
|
-
|
|
113
|
-
expect(decision.action).toBe("failover_next");
|
|
114
|
-
expect(decision.reason).toBe("busy_capacity");
|
|
115
|
-
const entry = pool.snapshot().find((candidate) => candidate.sellerId === "s1");
|
|
116
|
-
expect(entry?.circuit).toBe("closed");
|
|
117
|
-
expect(entry?.consecutiveFailures).toBe(0);
|
|
118
|
-
expect(entry?.capacityBlockedUntil).toBeGreaterThan(Date.now());
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
test("decide on soft_5xx after exhausting retries returns failover_next", () => {
|
|
122
|
-
const { failover, credit } = buildHarness([{ id: "s1" }]);
|
|
123
|
-
credit.recordPurchase("s1", 1_000_000, 1_000_000);
|
|
124
|
-
const decision = failover.decide(
|
|
125
|
-
{ sellerId: "s1", status: 503, errorKind: "soft_5xx", errorMessage: "upstream", attempt: 2 },
|
|
126
|
-
1
|
|
127
|
-
);
|
|
128
|
-
expect(decision.action).toBe("failover_next");
|
|
129
|
-
expect(decision.reason).toBe("soft_failure_exhausted_retries");
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
test("decide on soft_5xx outside the fresh-purchase window still allows the first retry", () => {
|
|
133
|
-
const { failover, credit } = buildHarness([{ id: "s1" }]);
|
|
134
|
-
// Record a purchase, then spend it past the threshold so the window exits.
|
|
135
|
-
credit.recordPurchase("s1", 1_000_000, 1_000_000);
|
|
136
|
-
credit.recordSpend("s1", 100_000);
|
|
137
|
-
const decision = failover.decide(
|
|
138
|
-
{ sellerId: "s1", status: 503, errorKind: "soft_5xx", errorMessage: "upstream", attempt: 0 },
|
|
139
|
-
1
|
|
140
|
-
);
|
|
141
|
-
expect(decision.action).toBe("retry_same_seller");
|
|
142
|
-
expect(decision.freshPurchase).toBe(false);
|
|
143
|
-
expect(decision.reason).toBe("soft_failure_retry");
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
test("decide on deadline uses the soft-error path", () => {
|
|
147
|
-
const { failover, credit } = buildHarness([{ id: "s1" }]);
|
|
148
|
-
credit.recordPurchase("s1", 1_000_000, 1_000_000);
|
|
149
|
-
const decision = failover.decide(
|
|
150
|
-
{ sellerId: "s1", errorKind: "deadline", errorMessage: "buyer timeout", attempt: 0 },
|
|
151
|
-
1
|
|
152
|
-
);
|
|
153
|
-
expect(decision.action).toBe("retry_same_seller");
|
|
154
|
-
expect(decision.freshPurchase).toBe(true);
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
test("decide on purchase_failed fails over without scheduling a soft retry", () => {
|
|
158
|
-
const { failover, credit } = buildHarness([{ id: "s1" }, { id: "s2" }]);
|
|
159
|
-
credit.recordPurchase("s1", 1_000_000, 750_000);
|
|
160
|
-
const decision = failover.decide(
|
|
161
|
-
{ sellerId: "s1", errorKind: "purchase_failed", errorMessage: "purchase create failed", attempt: 0 },
|
|
162
|
-
2
|
|
163
|
-
);
|
|
164
|
-
|
|
165
|
-
expect(decision.action).toBe("failover_next");
|
|
166
|
-
expect(decision.reason).toBe("purchase_failed");
|
|
167
|
-
expect(decision.wastedCreditMicros).toBe(750_000);
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
test("budgetExceeded flag is set when the per-minute purchase budget is exhausted", () => {
|
|
171
|
-
const { failover, credit } = buildHarness([{ id: "s1" }]);
|
|
172
|
-
// Burn through the per-minute budget.
|
|
173
|
-
credit.recordPurchase("s1", 100, 100);
|
|
174
|
-
credit.recordPurchase("s2" in credit.summary().perSeller ? "s2" : "s1", 100, 100);
|
|
175
|
-
credit.recordPurchase("s1", 100, 100);
|
|
176
|
-
credit.recordPurchase("s1", 100, 100);
|
|
177
|
-
const decision = failover.decide(
|
|
178
|
-
{ sellerId: "s1", errorKind: "soft_5xx", attempt: 0 },
|
|
179
|
-
1
|
|
180
|
-
);
|
|
181
|
-
expect(decision.budgetExceeded).toBe(true);
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
test("recordSuccess resets failure counters and reports to the credit tracker", () => {
|
|
185
|
-
const { failover, credit, pool } = buildHarness([{ id: "s1" }]);
|
|
186
|
-
credit.recordPurchase("s1", 1_000_000, 1_000_000);
|
|
187
|
-
failover.decide(
|
|
188
|
-
{ sellerId: "s1", errorKind: "soft_5xx", attempt: 0 },
|
|
189
|
-
1
|
|
190
|
-
);
|
|
191
|
-
failover.decide(
|
|
192
|
-
{ sellerId: "s1", errorKind: "soft_5xx", attempt: 1 },
|
|
193
|
-
1
|
|
194
|
-
);
|
|
195
|
-
failover.recordSuccess("s1", 750_000);
|
|
196
|
-
const entry = pool.snapshot()[0];
|
|
197
|
-
expect(entry.consecutiveFailures).toBe(0);
|
|
198
|
-
expect(credit.getEntry("s1")?.currentBalanceMicros).toBe(750_000);
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
test("shouldAbort returns true when no more candidates are available", () => {
|
|
202
|
-
const { failover } = buildHarness([{ id: "s1" }]);
|
|
203
|
-
expect(failover.shouldAbort(1, false)).toBe(true);
|
|
204
|
-
expect(failover.shouldAbort(1, true)).toBe(false);
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
test("canAutoPurchase mirrors the credit tracker budget", () => {
|
|
208
|
-
const { failover, credit } = buildHarness([{ id: "s1" }]);
|
|
209
|
-
expect(failover.canAutoPurchase()).toBe(true);
|
|
210
|
-
credit.recordPurchase("s1", 100, 100);
|
|
211
|
-
credit.recordPurchase("s1", 100, 100);
|
|
212
|
-
credit.recordPurchase("s1", 100, 100);
|
|
213
|
-
expect(failover.canAutoPurchase()).toBe(false);
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
test("wastedMicrosFor returns the latest known balance for a seller", () => {
|
|
217
|
-
const { failover, credit } = buildHarness([{ id: "s1" }]);
|
|
218
|
-
expect(failover.wastedMicrosFor("s1")).toBe(0);
|
|
219
|
-
credit.recordPurchase("s1", 1_000_000, 500_000);
|
|
220
|
-
expect(failover.wastedMicrosFor("s1")).toBe(500_000);
|
|
221
|
-
});
|
|
222
|
-
});
|
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
import * as crypto from "crypto";
|
|
2
|
-
import { RegistryTooLargeError, fetchSellerRegistry, fetchSellerRegistryWithTrust } from "../src/seller-catalog.js";
|
|
3
|
-
import { DEFAULT_SELLER_REGISTRY_URL } from "../src/registry-trust.js";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* v1.2 §18.9: registry-too-large fallback. The bootstrap returns 413
|
|
7
|
-
* with a structured body when the serialized registry exceeds 1MB; the
|
|
8
|
-
* buyer must surface this as a typed error so the daemon can fall back
|
|
9
|
-
* to the last-known snapshot.
|
|
10
|
-
*/
|
|
11
|
-
describe("RegistryTooLargeError + fetchSellerRegistry 413", () => {
|
|
12
|
-
const originalFetch = globalThis.fetch;
|
|
13
|
-
|
|
14
|
-
afterEach(() => {
|
|
15
|
-
globalThis.fetch = originalFetch;
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
function mockJsonResponse(status: number, body: unknown): Response {
|
|
19
|
-
return new Response(JSON.stringify(body), {
|
|
20
|
-
status,
|
|
21
|
-
headers: { "Content-Type": "application/json" }
|
|
22
|
-
});
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
test("fetchSellerRegistry throws RegistryTooLargeError on HTTP 413 with the bootstrap's payload", async () => {
|
|
26
|
-
globalThis.fetch = (async () =>
|
|
27
|
-
mockJsonResponse(413, {
|
|
28
|
-
error: "registry_too_large",
|
|
29
|
-
sizeBytes: 1_500_000,
|
|
30
|
-
sellerCount: 2_400,
|
|
31
|
-
maxBytes: 1_000_000
|
|
32
|
-
})) as unknown as typeof globalThis.fetch;
|
|
33
|
-
await expect(
|
|
34
|
-
fetchSellerRegistry("https://bootstrap.example/registry/sellers")
|
|
35
|
-
).rejects.toMatchObject({
|
|
36
|
-
name: "RegistryTooLargeError",
|
|
37
|
-
status: 413,
|
|
38
|
-
sizeBytes: 1_500_000,
|
|
39
|
-
sellerCount: 2_400,
|
|
40
|
-
maxBytes: 1_000_000
|
|
41
|
-
});
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
test("fetchSellerRegistry still surfaces generic non-2xx as plain errors", async () => {
|
|
45
|
-
globalThis.fetch = (async () => mockJsonResponse(500, { error: "internal" })) as unknown as typeof globalThis.fetch;
|
|
46
|
-
await expect(
|
|
47
|
-
fetchSellerRegistry("https://bootstrap.example/registry/sellers")
|
|
48
|
-
).rejects.toThrow(/registry returned 500/);
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
test("fetchSellerRegistry rejects the default registry when signature is missing", async () => {
|
|
52
|
-
globalThis.fetch = (async (url: string | URL | Request) => {
|
|
53
|
-
const href = String(url);
|
|
54
|
-
if (href.endsWith("/registry.json")) {
|
|
55
|
-
return mockJsonResponse(200, { version: 1, sellers: [] });
|
|
56
|
-
}
|
|
57
|
-
if (href.endsWith("/registry.sig")) {
|
|
58
|
-
return mockJsonResponse(404, { error: "missing" });
|
|
59
|
-
}
|
|
60
|
-
throw new Error(`unexpected fetch ${href}`);
|
|
61
|
-
}) as unknown as typeof globalThis.fetch;
|
|
62
|
-
|
|
63
|
-
await expect(fetchSellerRegistry(DEFAULT_SELLER_REGISTRY_URL)).rejects.toThrow("registry signature returned 404");
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
test("fetchSellerRegistry can explicitly allow unsigned registry for local development", async () => {
|
|
67
|
-
const previous = process.env.TB_PROXYD_ALLOW_UNSIGNED_REGISTRY;
|
|
68
|
-
process.env.TB_PROXYD_ALLOW_UNSIGNED_REGISTRY = "1";
|
|
69
|
-
globalThis.fetch = (async () => mockJsonResponse(200, { version: 1, sellers: [] })) as unknown as typeof globalThis.fetch;
|
|
70
|
-
try {
|
|
71
|
-
await expect(fetchSellerRegistry(DEFAULT_SELLER_REGISTRY_URL)).resolves.toMatchObject({ version: 1, sellers: [] });
|
|
72
|
-
} finally {
|
|
73
|
-
if (previous === undefined) {
|
|
74
|
-
delete process.env.TB_PROXYD_ALLOW_UNSIGNED_REGISTRY;
|
|
75
|
-
} else {
|
|
76
|
-
process.env.TB_PROXYD_ALLOW_UNSIGNED_REGISTRY = previous;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
test("fetchSellerRegistryWithTrust returns hash and unsigned trust metadata for allowed local development", async () => {
|
|
82
|
-
const previous = process.env.TB_PROXYD_ALLOW_UNSIGNED_REGISTRY;
|
|
83
|
-
process.env.TB_PROXYD_ALLOW_UNSIGNED_REGISTRY = "1";
|
|
84
|
-
const body = JSON.stringify({ version: 1, sellers: [] });
|
|
85
|
-
globalThis.fetch = (async () => new Response(body, {
|
|
86
|
-
status: 200,
|
|
87
|
-
headers: { "Content-Type": "application/json" }
|
|
88
|
-
})) as unknown as typeof globalThis.fetch;
|
|
89
|
-
try {
|
|
90
|
-
const result = await fetchSellerRegistryWithTrust(DEFAULT_SELLER_REGISTRY_URL);
|
|
91
|
-
expect(result.registry).toMatchObject({ version: 1, sellers: [] });
|
|
92
|
-
expect(result.trust).toMatchObject({
|
|
93
|
-
registryUrl: DEFAULT_SELLER_REGISTRY_URL,
|
|
94
|
-
registrySha256: crypto.createHash("sha256").update(body).digest("hex"),
|
|
95
|
-
verified: false
|
|
96
|
-
});
|
|
97
|
-
expect(result.trust.signature).toBeUndefined();
|
|
98
|
-
expect(result.trust.signingKeyId).toBeUndefined();
|
|
99
|
-
} finally {
|
|
100
|
-
if (previous === undefined) {
|
|
101
|
-
delete process.env.TB_PROXYD_ALLOW_UNSIGNED_REGISTRY;
|
|
102
|
-
} else {
|
|
103
|
-
process.env.TB_PROXYD_ALLOW_UNSIGNED_REGISTRY = previous;
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
test("RegistryTooLargeError is a stable Error subclass with a useful message", () => {
|
|
109
|
-
const err = new RegistryTooLargeError({
|
|
110
|
-
status: 413,
|
|
111
|
-
sizeBytes: 2_500_000,
|
|
112
|
-
sellerCount: 1500,
|
|
113
|
-
maxBytes: 1_000_000
|
|
114
|
-
});
|
|
115
|
-
expect(err).toBeInstanceOf(Error);
|
|
116
|
-
expect(err.name).toBe("RegistryTooLargeError");
|
|
117
|
-
expect(err.message).toContain("1500 sellers");
|
|
118
|
-
expect(err.message).toContain("2500000");
|
|
119
|
-
});
|
|
120
|
-
});
|
|
@@ -1,124 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
dedupeCatalogEntries,
|
|
3
|
-
discoverSellerBackedModels,
|
|
4
|
-
filterCatalogByProtocol,
|
|
5
|
-
filterCatalogBySeller,
|
|
6
|
-
manifestModelIds,
|
|
7
|
-
type ModelCatalogEntry,
|
|
8
|
-
type SellerManifest
|
|
9
|
-
} from "../src/seller-catalog.js";
|
|
10
|
-
|
|
11
|
-
function model(overrides: Partial<ModelCatalogEntry> & { id: string; sellerId: string }): ModelCatalogEntry {
|
|
12
|
-
return {
|
|
13
|
-
id: overrides.id,
|
|
14
|
-
sellerId: overrides.sellerId,
|
|
15
|
-
sellerUrl: overrides.sellerUrl ?? `https://${overrides.sellerId}.example.com`,
|
|
16
|
-
supportedProtocols: overrides.supportedProtocols ?? ["chat_completions"],
|
|
17
|
-
paymentMethods: overrides.paymentMethods ?? ["mock"],
|
|
18
|
-
sellerName: overrides.sellerName,
|
|
19
|
-
inputPriceMicrosPer1m: overrides.inputPriceMicrosPer1m,
|
|
20
|
-
outputPriceMicrosPer1m: overrides.outputPriceMicrosPer1m
|
|
21
|
-
};
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
describe("seller catalog utilities", () => {
|
|
25
|
-
test("filters catalog entries by protocol", () => {
|
|
26
|
-
const entries = [
|
|
27
|
-
model({ id: "gpt-5.4", sellerId: "seller-a", supportedProtocols: ["chat_completions"] }),
|
|
28
|
-
model({ id: "claude", sellerId: "seller-b", supportedProtocols: ["messages"] })
|
|
29
|
-
];
|
|
30
|
-
|
|
31
|
-
expect(filterCatalogByProtocol([], "chat_completions")).toEqual([]);
|
|
32
|
-
expect(filterCatalogByProtocol(entries, "chat_completions")).toEqual([entries[0]]);
|
|
33
|
-
expect(filterCatalogByProtocol(entries, "responses")).toEqual([]);
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
test("filters catalog entries by seller when seller id is provided", () => {
|
|
37
|
-
const entries = [
|
|
38
|
-
model({ id: "m1", sellerId: "seller-a" }),
|
|
39
|
-
model({ id: "m2", sellerId: "seller-b" })
|
|
40
|
-
];
|
|
41
|
-
|
|
42
|
-
expect(filterCatalogBySeller([], "seller-a")).toEqual([]);
|
|
43
|
-
expect(filterCatalogBySeller(entries, "seller-a")).toEqual([entries[0]]);
|
|
44
|
-
expect(filterCatalogBySeller(entries, undefined)).toEqual(entries);
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
test("deduplicates by seller id and model id while preserving order", () => {
|
|
48
|
-
const first = model({ id: "same", sellerId: "seller-a" });
|
|
49
|
-
const duplicate = model({ id: "same", sellerId: "seller-a", sellerUrl: "https://duplicate.example.com" });
|
|
50
|
-
const sameModelDifferentSeller = model({ id: "same", sellerId: "seller-b" });
|
|
51
|
-
const other = model({ id: "other", sellerId: "seller-a" });
|
|
52
|
-
|
|
53
|
-
expect(dedupeCatalogEntries([first, duplicate, sameModelDifferentSeller, other])).toEqual([
|
|
54
|
-
first,
|
|
55
|
-
sameModelDifferentSeller,
|
|
56
|
-
other
|
|
57
|
-
]);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
test("returns trimmed, non-empty manifest model ids only", () => {
|
|
61
|
-
const manifest = {
|
|
62
|
-
models: [
|
|
63
|
-
{ id: " gpt-5.4 " },
|
|
64
|
-
{ id: "" },
|
|
65
|
-
{ id: "claude" }
|
|
66
|
-
]
|
|
67
|
-
} as unknown as SellerManifest;
|
|
68
|
-
|
|
69
|
-
expect(manifestModelIds(manifest)).toEqual(["gpt-5.4", "claude"]);
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
test("discovers models from active buyer-visible registry sellers only", async () => {
|
|
73
|
-
const fetchMock = jest.spyOn(globalThis, "fetch").mockImplementation(async (url) => {
|
|
74
|
-
const href = String(url);
|
|
75
|
-
if (href.endsWith("/registry/sellers")) {
|
|
76
|
-
return jsonResponse({
|
|
77
|
-
version: 8,
|
|
78
|
-
sellers: [
|
|
79
|
-
{
|
|
80
|
-
id: "active",
|
|
81
|
-
status: "active",
|
|
82
|
-
url: "https://active.example.com",
|
|
83
|
-
supportedProtocols: ["chat_completions"],
|
|
84
|
-
paymentMethods: ["clawtip"]
|
|
85
|
-
},
|
|
86
|
-
{
|
|
87
|
-
id: "pending",
|
|
88
|
-
status: "pending",
|
|
89
|
-
url: "https://pending.example.com",
|
|
90
|
-
supportedProtocols: ["chat_completions"],
|
|
91
|
-
paymentMethods: ["clawtip"]
|
|
92
|
-
}
|
|
93
|
-
]
|
|
94
|
-
});
|
|
95
|
-
}
|
|
96
|
-
if (href === "https://active.example.com/manifest") {
|
|
97
|
-
return jsonResponse({
|
|
98
|
-
sellerId: "active",
|
|
99
|
-
supportedProtocols: ["chat_completions"],
|
|
100
|
-
paymentMethods: ["clawtip"],
|
|
101
|
-
models: [{ id: "gpt-5.4" }]
|
|
102
|
-
});
|
|
103
|
-
}
|
|
104
|
-
throw new Error(`unexpected fetch ${href}`);
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
try {
|
|
108
|
-
const catalog = await discoverSellerBackedModels("https://bootstrap.example.com/registry/sellers");
|
|
109
|
-
|
|
110
|
-
expect(catalog.sellers.map((seller) => seller.id)).toEqual(["active"]);
|
|
111
|
-
expect(catalog.models.map((model) => model.sellerId)).toEqual(["active"]);
|
|
112
|
-
expect(fetchMock).not.toHaveBeenCalledWith("https://pending.example.com/manifest");
|
|
113
|
-
} finally {
|
|
114
|
-
fetchMock.mockRestore();
|
|
115
|
-
}
|
|
116
|
-
});
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
function jsonResponse(body: unknown): Response {
|
|
120
|
-
return new Response(JSON.stringify(body), {
|
|
121
|
-
status: 200,
|
|
122
|
-
headers: { "Content-Type": "application/json" }
|
|
123
|
-
});
|
|
124
|
-
}
|
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
import { SellerConcurrencyLimiter } from "../src/seller-concurrency-limiter.js";
|
|
2
|
-
|
|
3
|
-
describe("SellerConcurrencyLimiter", () => {
|
|
4
|
-
afterEach(() => {
|
|
5
|
-
jest.useRealTimers();
|
|
6
|
-
});
|
|
7
|
-
|
|
8
|
-
test("returns disabled no-op leases by default", () => {
|
|
9
|
-
const limiter = new SellerConcurrencyLimiter();
|
|
10
|
-
const first = limiter.tryAcquire("seller-a");
|
|
11
|
-
const second = limiter.tryAcquire("seller-a");
|
|
12
|
-
|
|
13
|
-
expect(first).toBeDefined();
|
|
14
|
-
expect(second).toBeDefined();
|
|
15
|
-
expect(limiter.snapshot()).toEqual({
|
|
16
|
-
enabled: false,
|
|
17
|
-
maxInFlightPerSeller: 2,
|
|
18
|
-
leaseTtlMs: 185000,
|
|
19
|
-
active: []
|
|
20
|
-
});
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
test("rejects sellers at the local in-flight limit and releases idempotently", () => {
|
|
24
|
-
const limiter = new SellerConcurrencyLimiter({
|
|
25
|
-
enabled: true,
|
|
26
|
-
maxInFlightPerSeller: 1,
|
|
27
|
-
leaseTtlMs: 10000
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
const lease = limiter.tryAcquire("seller-a");
|
|
31
|
-
expect(lease).toBeDefined();
|
|
32
|
-
expect(limiter.tryAcquire("seller-a")).toBeUndefined();
|
|
33
|
-
expect(limiter.tryAcquire("seller-b")).toBeDefined();
|
|
34
|
-
expect(limiter.snapshot().active).toEqual([
|
|
35
|
-
{ sellerId: "seller-a", activeCount: 1 },
|
|
36
|
-
{ sellerId: "seller-b", activeCount: 1 }
|
|
37
|
-
]);
|
|
38
|
-
|
|
39
|
-
lease?.release();
|
|
40
|
-
lease?.release();
|
|
41
|
-
|
|
42
|
-
expect(limiter.tryAcquire("seller-a")).toBeDefined();
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
test("expires leaked leases so sellers become selectable again", () => {
|
|
46
|
-
jest.useFakeTimers();
|
|
47
|
-
const limiter = new SellerConcurrencyLimiter({
|
|
48
|
-
enabled: true,
|
|
49
|
-
maxInFlightPerSeller: 1,
|
|
50
|
-
leaseTtlMs: 1000
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
expect(limiter.tryAcquire("seller-a")).toBeDefined();
|
|
54
|
-
expect(limiter.tryAcquire("seller-a")).toBeUndefined();
|
|
55
|
-
|
|
56
|
-
jest.advanceTimersByTime(1000);
|
|
57
|
-
|
|
58
|
-
expect(limiter.snapshot().active).toEqual([]);
|
|
59
|
-
expect(limiter.tryAcquire("seller-a")).toBeDefined();
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
test("refresh extends an active lease watchdog", () => {
|
|
63
|
-
jest.useFakeTimers();
|
|
64
|
-
const limiter = new SellerConcurrencyLimiter({
|
|
65
|
-
enabled: true,
|
|
66
|
-
maxInFlightPerSeller: 1,
|
|
67
|
-
leaseTtlMs: 1000
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
const lease = limiter.tryAcquire("seller-a");
|
|
71
|
-
expect(lease).toBeDefined();
|
|
72
|
-
|
|
73
|
-
jest.advanceTimersByTime(900);
|
|
74
|
-
lease?.refresh();
|
|
75
|
-
jest.advanceTimersByTime(900);
|
|
76
|
-
|
|
77
|
-
expect(limiter.snapshot().active).toEqual([{ sellerId: "seller-a", activeCount: 1 }]);
|
|
78
|
-
|
|
79
|
-
jest.advanceTimersByTime(100);
|
|
80
|
-
|
|
81
|
-
expect(limiter.snapshot().active).toEqual([]);
|
|
82
|
-
});
|
|
83
|
-
});
|
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
import * as http from "http";
|
|
2
|
-
import { SellerMetadataCache } from "../src/seller-metadata-cache.js";
|
|
3
|
-
import type { RegistrySeller } from "../src/seller-catalog.js";
|
|
4
|
-
|
|
5
|
-
function seller(id: string, url: string): RegistrySeller {
|
|
6
|
-
return {
|
|
7
|
-
id,
|
|
8
|
-
url,
|
|
9
|
-
supportedProtocols: ["chat_completions"],
|
|
10
|
-
paymentMethods: ["clawtip"],
|
|
11
|
-
models: ["gpt-5.4"]
|
|
12
|
-
};
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function startManifestServer(handler: (req: http.IncomingMessage, res: http.ServerResponse) => void): Promise<{
|
|
16
|
-
url: string;
|
|
17
|
-
close: () => Promise<void>;
|
|
18
|
-
}> {
|
|
19
|
-
const server = http.createServer(handler);
|
|
20
|
-
return new Promise((resolve) => {
|
|
21
|
-
server.listen(0, "127.0.0.1", () => {
|
|
22
|
-
const address = server.address();
|
|
23
|
-
if (!address || typeof address === "string") {
|
|
24
|
-
throw new Error("server did not bind to a TCP port");
|
|
25
|
-
}
|
|
26
|
-
resolve({
|
|
27
|
-
url: `http://127.0.0.1:${address.port}`,
|
|
28
|
-
close: () => new Promise<void>((done) => server.close(() => done()))
|
|
29
|
-
});
|
|
30
|
-
});
|
|
31
|
-
});
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
describe("seller metadata cache", () => {
|
|
35
|
-
test("reads discount ratio and manifest version from seller manifest selection", async () => {
|
|
36
|
-
let requestCount = 0;
|
|
37
|
-
const server = await startManifestServer((req, res) => {
|
|
38
|
-
requestCount += 1;
|
|
39
|
-
expect(req.url).toBe("/manifest");
|
|
40
|
-
res.setHeader("Content-Type", "application/json");
|
|
41
|
-
res.end(JSON.stringify({
|
|
42
|
-
manifestVersion: "manifest.test",
|
|
43
|
-
selection: {
|
|
44
|
-
discountRatio: 0.42
|
|
45
|
-
}
|
|
46
|
-
}));
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
try {
|
|
50
|
-
const cache = new SellerMetadataCache({ ttlMs: 60_000, now: () => 1000 });
|
|
51
|
-
await cache.refreshIfStale([seller("s1", server.url)]);
|
|
52
|
-
await cache.refreshIfStale([seller("s1", server.url)]);
|
|
53
|
-
|
|
54
|
-
expect(requestCount).toBe(1);
|
|
55
|
-
expect(cache.snapshot()).toEqual([
|
|
56
|
-
{
|
|
57
|
-
sellerId: "s1",
|
|
58
|
-
discountRatio: 0.42,
|
|
59
|
-
manifestVersion: "manifest.test",
|
|
60
|
-
lastRefreshAt: 1000,
|
|
61
|
-
source: "manifest_selection"
|
|
62
|
-
}
|
|
63
|
-
]);
|
|
64
|
-
} finally {
|
|
65
|
-
await server.close();
|
|
66
|
-
}
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
test("stores manifest fetch failures without throwing", async () => {
|
|
70
|
-
const server = await startManifestServer((req, res) => {
|
|
71
|
-
res.statusCode = 500;
|
|
72
|
-
res.end("nope");
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
try {
|
|
76
|
-
const cache = new SellerMetadataCache({ ttlMs: 60_000, now: () => 2000 });
|
|
77
|
-
await expect(cache.refreshIfStale([seller("s1", server.url)])).resolves.toBeUndefined();
|
|
78
|
-
|
|
79
|
-
expect(cache.snapshot()[0]).toMatchObject({
|
|
80
|
-
sellerId: "s1",
|
|
81
|
-
lastRefreshAt: 2000,
|
|
82
|
-
source: "manifest_selection",
|
|
83
|
-
errorMessage: "manifest returned 500"
|
|
84
|
-
});
|
|
85
|
-
} finally {
|
|
86
|
-
await server.close();
|
|
87
|
-
}
|
|
88
|
-
});
|
|
89
|
-
});
|