@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,184 @@
1
+ import { ModelIndex } from "../src/model-index.js";
2
+ import type { RegistrySeller } from "../src/seller-catalog.js";
3
+
4
+ function makeSeller(overrides: Partial<RegistrySeller> & { id: string; models?: string[] }): RegistrySeller {
5
+ return {
6
+ id: overrides.id,
7
+ name: overrides.name ?? overrides.id,
8
+ url: overrides.url ?? `https://${overrides.id}.example.com`,
9
+ supportedProtocols: overrides.supportedProtocols ?? ["chat_completions"],
10
+ paymentMethods: overrides.paymentMethods ?? ["clawtip"],
11
+ models: overrides.models
12
+ };
13
+ }
14
+
15
+ describe("ModelIndex", () => {
16
+ test("rebuilds an empty snapshot without throwing", () => {
17
+ const index = new ModelIndex();
18
+ index.rebuild([], { registryVersion: 1 });
19
+
20
+ expect(index.sellersFor("anything")).toEqual([]);
21
+ expect(index.knownModelIds()).toEqual([]);
22
+ const stats = index.stats();
23
+ expect(stats.sellerCount).toBe(0);
24
+ expect(stats.modelCount).toBe(0);
25
+ expect(stats.missingModelsCount).toBe(0);
26
+ expect(stats.registryVersion).toBe(1);
27
+ });
28
+
29
+ test("rebuild populates byModel and bySeller from a registry snapshot", () => {
30
+ const index = new ModelIndex();
31
+ const sellers: RegistrySeller[] = [
32
+ makeSeller({ id: "s1", models: ["claude-sonnet-4-5", "gpt-4o"] }),
33
+ makeSeller({ id: "s2", models: ["gpt-4o", "deepseek-v4-pro"] })
34
+ ];
35
+
36
+ index.rebuild(sellers, { registryVersion: 4, defaultSellerId: "s1" });
37
+
38
+ expect(index.sellersFor("claude-sonnet-4-5").map((s) => s.id)).toEqual(["s1"]);
39
+ expect(index.sellersFor("gpt-4o").map((s) => s.id)).toEqual(["s1", "s2"]);
40
+ expect(index.sellersFor("deepseek-v4-pro").map((s) => s.id)).toEqual(["s2"]);
41
+ expect(index.getSeller("s1")?.url).toBe("https://s1.example.com");
42
+ expect(index.getSeller("missing")).toBeUndefined();
43
+ expect(index.stats().defaultSellerId).toBe("s1");
44
+ expect(index.stats().registryVersion).toBe(4);
45
+ });
46
+
47
+ test("model id matching is case-insensitive and trims whitespace", () => {
48
+ const index = new ModelIndex();
49
+ index.rebuild([makeSeller({ id: "s1", models: ["claude-sonnet-4-5"] })]);
50
+
51
+ expect(index.sellersFor("Claude-Sonnet-4-5").map((s) => s.id)).toEqual(["s1"]);
52
+ expect(index.sellersFor(" claude-sonnet-4-5 ").map((s) => s.id)).toEqual(["s1"]);
53
+ expect(index.sellersFor("CLAUDE-SONNET-4-5").map((s) => s.id)).toEqual(["s1"]);
54
+ });
55
+
56
+ test("filters by protocol and payment method", () => {
57
+ const index = new ModelIndex();
58
+ const sellers: RegistrySeller[] = [
59
+ makeSeller({
60
+ id: "s-chat",
61
+ models: ["gpt-4o"],
62
+ supportedProtocols: ["chat_completions"],
63
+ paymentMethods: ["clawtip"]
64
+ }),
65
+ makeSeller({
66
+ id: "s-msg",
67
+ models: ["gpt-4o"],
68
+ supportedProtocols: ["messages"],
69
+ paymentMethods: ["mock"]
70
+ })
71
+ ];
72
+ index.rebuild(sellers);
73
+
74
+ const chatClawtip = index.sellersFor("gpt-4o", { protocol: "chat_completions", paymentMethod: "clawtip" });
75
+ expect(chatClawtip.map((s) => s.id)).toEqual(["s-chat"]);
76
+
77
+ const messages = index.sellersFor("gpt-4o", { protocol: "messages" });
78
+ expect(messages.map((s) => s.id)).toEqual(["s-msg"]);
79
+
80
+ const messagesClawtip = index.sellersFor("gpt-4o", { protocol: "messages", paymentMethod: "clawtip" });
81
+ expect(messagesClawtip).toEqual([]);
82
+
83
+ const noFilter = index.sellersFor("gpt-4o");
84
+ expect(noFilter.map((s) => s.id).sort()).toEqual(["s-chat", "s-msg"]);
85
+ });
86
+
87
+ test("sellers missing models field are excluded from lookups but still addressable by id", () => {
88
+ const index = new ModelIndex();
89
+ const sellers: RegistrySeller[] = [
90
+ makeSeller({ id: "ok", models: ["gpt-4o"] }),
91
+ makeSeller({ id: "broken" }) // no models
92
+ ];
93
+ index.rebuild(sellers);
94
+
95
+ expect(index.sellersFor("gpt-4o").map((s) => s.id)).toEqual(["ok"]);
96
+ expect(index.getSeller("broken")?.id).toBe("broken");
97
+ expect(index.stats().missingModelsCount).toBe(1);
98
+ expect(index.stats().sellerCount).toBe(2);
99
+ });
100
+
101
+ test("resolve returns matched flag and snapshot", () => {
102
+ const index = new ModelIndex();
103
+ index.rebuild([makeSeller({ id: "s1", models: ["gpt-4o"] })]);
104
+
105
+ const hit = index.resolve("gpt-4o");
106
+ expect(hit.matched).toBe(true);
107
+ expect(hit.sellers).toHaveLength(1);
108
+
109
+ const miss = index.resolve("unknown-model");
110
+ expect(miss.matched).toBe(false);
111
+ expect(miss.sellers).toEqual([]);
112
+ });
113
+
114
+ test("rebuild replaces the previous snapshot atomically", () => {
115
+ const index = new ModelIndex();
116
+ index.rebuild([makeSeller({ id: "s1", models: ["m1"] })], { registryVersion: 1 });
117
+ expect(index.sellersFor("m1")).toHaveLength(1);
118
+
119
+ index.rebuild([makeSeller({ id: "s2", models: ["m2"] })], { registryVersion: 2 });
120
+ expect(index.sellersFor("m1")).toEqual([]);
121
+ expect(index.sellersFor("m2")).toHaveLength(1);
122
+ expect(index.stats().registryVersion).toBe(2);
123
+ });
124
+
125
+ test("prune drops sellers that have not been seen within staleAfterMs", () => {
126
+ const index = new ModelIndex();
127
+ index.rebuild([
128
+ makeSeller({ id: "s1", models: ["gpt-4o", "m1"] }),
129
+ makeSeller({ id: "s2", models: ["m2"] })
130
+ ]);
131
+
132
+ const now = 1_700_000_000_000;
133
+ const lastSeenAt = new Map<string, number>([
134
+ ["s1", now - 1000], // fresh
135
+ ["s2", now - 10 * 24 * 60 * 60 * 1000] // 10 days old
136
+ ]);
137
+
138
+ const removed = index.prune(lastSeenAt, 7 * 24 * 60 * 60 * 1000, now);
139
+ expect(removed).toBe(1);
140
+ expect(index.getSeller("s1")).toBeDefined();
141
+ expect(index.getSeller("s2")).toBeUndefined();
142
+ // Models that belonged only to the pruned seller must be removed.
143
+ expect(index.sellersFor("m2")).toEqual([]);
144
+ // Models shared with the remaining seller must still resolve.
145
+ expect(index.sellersFor("gpt-4o")).toHaveLength(1);
146
+ });
147
+
148
+ test("prune is a no-op when staleAfterMs is non-positive", () => {
149
+ const index = new ModelIndex();
150
+ index.rebuild([makeSeller({ id: "s1", models: ["m1"] })]);
151
+ expect(index.prune(new Map([["s1", 0]]), 0)).toBe(0);
152
+ expect(index.sellersFor("m1")).toHaveLength(1);
153
+ });
154
+
155
+ test("ignores malformed model entries when populating the index", () => {
156
+ const index = new ModelIndex();
157
+ const sellers: RegistrySeller[] = [
158
+ {
159
+ id: "s1",
160
+ name: "s1",
161
+ url: "https://s1.example.com",
162
+ supportedProtocols: ["chat_completions"],
163
+ paymentMethods: ["clawtip"],
164
+ // Mixed valid / invalid entries; the index must skip the bad ones
165
+ // without throwing.
166
+ models: ["m1", "", " ", "m2"] as string[]
167
+ }
168
+ ];
169
+ index.rebuild(sellers);
170
+
171
+ expect(index.sellersFor("m1")).toHaveLength(1);
172
+ expect(index.sellersFor("m2")).toHaveLength(1);
173
+ expect(index.knownModelIds().sort()).toEqual(["m1", "m2"]);
174
+ });
175
+
176
+ test("knownModelIds returns a snapshot that does not mutate internal state", () => {
177
+ const index = new ModelIndex();
178
+ index.rebuild([makeSeller({ id: "s1", models: ["m1"] })]);
179
+
180
+ const ids = index.knownModelIds();
181
+ ids.push("hacked");
182
+ expect(index.knownModelIds()).toEqual(["m1"]);
183
+ });
184
+ });
@@ -0,0 +1,266 @@
1
+ import { createModuleLogger } from "@tokenbuddy/logging";
2
+ import type { RegistrySeller } from "./seller-catalog.js";
3
+
4
+ const logger = createModuleLogger("tb-proxyd:model-index");
5
+
6
+ /**
7
+ * Normalize a model id for index lookup. Trims whitespace and lowercases
8
+ * the value so that `claude-sonnet-4-5` and `Claude-Sonnet-4-5 ` resolve to
9
+ * the same entry. v1.2 model matching is case-insensitive.
10
+ */
11
+ function normalizeModelId(modelId: string): string {
12
+ return modelId.trim().toLowerCase();
13
+ }
14
+
15
+ /**
16
+ * Result of resolving a model id against the index.
17
+ *
18
+ * - `empty` is returned when the index has been built but no seller serves
19
+ * the requested model. The caller should treat this as "no compatible
20
+ * seller" and surface the empty result to the user.
21
+ * - `sellers` is a snapshot array of the matching registry entries. Order
22
+ * follows the registry's `defaultSeller` preference then declaration order.
23
+ */
24
+ export interface ModelIndexResolution {
25
+ modelId: string;
26
+ matched: boolean;
27
+ sellers: RegistrySeller[];
28
+ missingModelsFlag: number;
29
+ }
30
+
31
+ interface ModelIndexInternals {
32
+ byModel: Map<string, RegistrySeller[]>;
33
+ bySeller: Map<string, RegistrySeller>;
34
+ registryVersion: number;
35
+ registryFetchedAt: number;
36
+ defaultSellerId?: string;
37
+ missingModelsCount: number;
38
+ totalSellers: number;
39
+ }
40
+
41
+ /**
42
+ * In-memory index mapping `modelId -> sellers[]` from a fetched registry
43
+ * snapshot. The index is rebuilt atomically on every `rebuild()` call so
44
+ * callers always observe a consistent snapshot (Node is single-threaded, but
45
+ * the rebuild path copies data before swapping the internal maps).
46
+ */
47
+ export class ModelIndex {
48
+ private internals: ModelIndexInternals = emptyInternals();
49
+
50
+ /**
51
+ * Atomically replace the index contents with a new registry snapshot.
52
+ * Sellers that do not declare `models` are kept in the `bySeller` reverse
53
+ * map so they can still be addressed by id, but they are excluded from
54
+ * `sellersFor()` results. The count of such sellers is exposed via
55
+ * `missingModelsCount` and via the `models_refresh.seller_missing_models`
56
+ * log event so operators can fix upstream registry payloads.
57
+ */
58
+ rebuild(sellers: RegistrySeller[], opts: { registryVersion?: number; defaultSellerId?: string } = {}): void {
59
+ const byModel = new Map<string, RegistrySeller[]>();
60
+ const bySeller = new Map<string, RegistrySeller>();
61
+ let missingModels = 0;
62
+
63
+ for (const seller of sellers) {
64
+ if (!seller || !seller.id) {
65
+ continue;
66
+ }
67
+ // v1.2 registry schema 用 "anthropic_messages" 作为协议名(OpenAI / ClawTip
68
+ // / 外部 client 都用这个),但 buyer 内部 `endpointProtocol` 对 /v1/messages
69
+ // 返 "messages"(更短、更易读)。在 modelIndex 重建时做 alias 映射,让两边
70
+ // 都能匹配到同一 seller。alias 只在内存里生效,不回写 registry。
71
+ if (
72
+ Array.isArray(seller.supportedProtocols) &&
73
+ seller.supportedProtocols.includes("anthropic_messages") &&
74
+ !seller.supportedProtocols.includes("messages")
75
+ ) {
76
+ seller.supportedProtocols = [...seller.supportedProtocols, "messages"];
77
+ }
78
+ bySeller.set(seller.id, seller);
79
+ if (!Array.isArray(seller.models) || seller.models.length === 0) {
80
+ missingModels += 1;
81
+ logger.warn("models_refresh.seller_missing_models", "registry seller entry missing models array; excluded from model index", {
82
+ sellerId: seller.id,
83
+ sellerUrl: seller.url
84
+ });
85
+ continue;
86
+ }
87
+ for (const raw of seller.models) {
88
+ if (typeof raw !== "string" || raw.trim() === "") {
89
+ continue;
90
+ }
91
+ const key = normalizeModelId(raw);
92
+ const bucket = byModel.get(key);
93
+ if (bucket) {
94
+ bucket.push(seller);
95
+ } else {
96
+ byModel.set(key, [seller]);
97
+ }
98
+ }
99
+ }
100
+
101
+ this.internals = {
102
+ byModel,
103
+ bySeller,
104
+ registryVersion: opts.registryVersion ?? 0,
105
+ registryFetchedAt: Date.now(),
106
+ defaultSellerId: opts.defaultSellerId,
107
+ missingModelsCount: missingModels,
108
+ totalSellers: bySeller.size
109
+ };
110
+
111
+ logger.info("models_refresh.rebuilt", "model index rebuilt from registry snapshot", {
112
+ registryVersion: this.internals.registryVersion,
113
+ sellerCount: this.internals.totalSellers,
114
+ modelCount: byModel.size,
115
+ missingModels: missingModels,
116
+ defaultSellerId: opts.defaultSellerId ?? null
117
+ });
118
+ }
119
+
120
+ /**
121
+ * Resolve sellers for a given model id, optionally filtered by protocol
122
+ * and payment method. Returns a snapshot; mutating the result does not
123
+ * affect the index.
124
+ */
125
+ sellersFor(modelId: string, filter?: { protocol?: string; paymentMethod?: string }): RegistrySeller[] {
126
+ const key = normalizeModelId(modelId);
127
+ const bucket = this.internals.byModel.get(key);
128
+ if (!bucket) {
129
+ return [];
130
+ }
131
+ if (!filter) {
132
+ return bucket.slice();
133
+ }
134
+ return bucket.filter((seller) => matchesFilter(seller, filter));
135
+ }
136
+
137
+ /**
138
+ * Resolve a single model id with diagnostic metadata. Used by route-failover
139
+ * to log structured "no compatible seller" events without losing the
140
+ * missing-models count.
141
+ */
142
+ resolve(modelId: string, filter?: { protocol?: string; paymentMethod?: string }): ModelIndexResolution {
143
+ return {
144
+ modelId,
145
+ matched: this.internals.byModel.has(normalizeModelId(modelId)),
146
+ sellers: this.sellersFor(modelId, filter),
147
+ missingModelsFlag: this.internals.missingModelsCount
148
+ };
149
+ }
150
+
151
+ /**
152
+ * Returns the registry seller entry by id, or `undefined` if the seller is
153
+ * not present in the latest snapshot. Used by the route-failover to look up
154
+ * a seller for token bookkeeping even when the requested model is unknown.
155
+ */
156
+ getSeller(sellerId: string): RegistrySeller | undefined {
157
+ return this.internals.bySeller.get(sellerId);
158
+ }
159
+
160
+ /**
161
+ * List every model id known to the index. Intended for diagnostics
162
+ * (`tb doctor`) and CLI completion; not used on the hot path.
163
+ */
164
+ knownModelIds(): string[] {
165
+ return Array.from(this.internals.byModel.keys());
166
+ }
167
+
168
+ /**
169
+ * Drop stale seller entries from the reverse map when the registry has not
170
+ * surfaced them within `staleAfterMs`. Model-keyed buckets are derived from
171
+ * the same `bySeller` set so dropping sellers implicitly drops their
172
+ * model associations. Returns the number of removed entries.
173
+ */
174
+ prune(lastSeenAt: Map<string, number>, staleAfterMs: number, now: number = Date.now()): number {
175
+ if (staleAfterMs <= 0) {
176
+ return 0;
177
+ }
178
+ const cutoff = now - staleAfterMs;
179
+ const toRemove: string[] = [];
180
+ for (const [sellerId, seen] of lastSeenAt.entries()) {
181
+ if (seen < cutoff && this.internals.bySeller.has(sellerId)) {
182
+ toRemove.push(sellerId);
183
+ }
184
+ }
185
+ if (toRemove.length === 0) {
186
+ return 0;
187
+ }
188
+
189
+ const byModel = new Map<string, RegistrySeller[]>();
190
+ for (const [key, sellers] of this.internals.byModel.entries()) {
191
+ const filtered = sellers.filter((seller) => !toRemove.includes(seller.id));
192
+ if (filtered.length > 0) {
193
+ byModel.set(key, filtered);
194
+ }
195
+ }
196
+ const bySeller = new Map<string, RegistrySeller>();
197
+ for (const [sellerId, seller] of this.internals.bySeller.entries()) {
198
+ if (!toRemove.includes(sellerId)) {
199
+ bySeller.set(sellerId, seller);
200
+ }
201
+ }
202
+
203
+ this.internals = {
204
+ ...this.internals,
205
+ byModel,
206
+ bySeller,
207
+ registryFetchedAt: now
208
+ };
209
+
210
+ logger.info("models_refresh.pruned", "stale sellers pruned from model index", {
211
+ removedCount: toRemove.length,
212
+ remainingSellers: bySeller.size,
213
+ remainingModels: byModel.size
214
+ });
215
+ return toRemove.length;
216
+ }
217
+
218
+ /**
219
+ * Snapshot of the internal counters for diagnostics. Cheap to call; does
220
+ * not copy the maps.
221
+ */
222
+ stats(): {
223
+ sellerCount: number;
224
+ modelCount: number;
225
+ missingModelsCount: number;
226
+ registryVersion: number;
227
+ registryFetchedAt: number;
228
+ defaultSellerId?: string;
229
+ } {
230
+ return {
231
+ sellerCount: this.internals.totalSellers,
232
+ modelCount: this.internals.byModel.size,
233
+ missingModelsCount: this.internals.missingModelsCount,
234
+ registryVersion: this.internals.registryVersion,
235
+ registryFetchedAt: this.internals.registryFetchedAt,
236
+ defaultSellerId: this.internals.defaultSellerId
237
+ };
238
+ }
239
+ }
240
+
241
+ function emptyInternals(): ModelIndexInternals {
242
+ return {
243
+ byModel: new Map(),
244
+ bySeller: new Map(),
245
+ registryVersion: 0,
246
+ registryFetchedAt: 0,
247
+ missingModelsCount: 0,
248
+ totalSellers: 0
249
+ };
250
+ }
251
+
252
+ function matchesFilter(seller: RegistrySeller, filter: { protocol?: string; paymentMethod?: string }): boolean {
253
+ if (filter.protocol) {
254
+ const protocols = seller.supportedProtocols ?? [];
255
+ if (!protocols.includes(filter.protocol)) {
256
+ return false;
257
+ }
258
+ }
259
+ if (filter.paymentMethod) {
260
+ const methods = seller.paymentMethods ?? [];
261
+ if (!methods.includes(filter.paymentMethod)) {
262
+ return false;
263
+ }
264
+ }
265
+ return true;
266
+ }