@tokenbuddy/tokenbuddy 1.0.9 → 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.
- package/dist/src/buyer-store.d.ts +13 -0
- package/dist/src/buyer-store.d.ts.map +1 -1
- package/dist/src/buyer-store.js +21 -2
- package/dist/src/buyer-store.js.map +1 -1
- package/dist/src/cli.d.ts.map +1 -1
- package/dist/src/cli.js +54 -0
- package/dist/src/cli.js.map +1 -1
- package/dist/src/credit-tracker.d.ts +118 -0
- package/dist/src/credit-tracker.d.ts.map +1 -0
- package/dist/src/credit-tracker.js +220 -0
- package/dist/src/credit-tracker.js.map +1 -0
- package/dist/src/daemon.d.ts +49 -4
- package/dist/src/daemon.d.ts.map +1 -1
- package/dist/src/daemon.js +541 -405
- package/dist/src/daemon.js.map +1 -1
- package/dist/src/model-index.d.ts +86 -0
- package/dist/src/model-index.d.ts.map +1 -0
- package/dist/src/model-index.js +214 -0
- package/dist/src/model-index.js.map +1 -0
- package/dist/src/prewarm-cache.d.ts +149 -0
- package/dist/src/prewarm-cache.d.ts.map +1 -0
- package/dist/src/prewarm-cache.js +288 -0
- package/dist/src/prewarm-cache.js.map +1 -0
- package/dist/src/prewarm-scheduler.d.ts +150 -0
- package/dist/src/prewarm-scheduler.d.ts.map +1 -0
- package/dist/src/prewarm-scheduler.js +484 -0
- package/dist/src/prewarm-scheduler.js.map +1 -0
- package/dist/src/provider-install.d.ts.map +1 -1
- package/dist/src/provider-install.js +9 -1
- package/dist/src/provider-install.js.map +1 -1
- package/dist/src/route-failover.d.ts +96 -0
- package/dist/src/route-failover.d.ts.map +1 -0
- package/dist/src/route-failover.js +177 -0
- package/dist/src/route-failover.js.map +1 -0
- package/dist/src/seller-catalog.d.ts +26 -0
- package/dist/src/seller-catalog.d.ts.map +1 -1
- package/dist/src/seller-catalog.js +40 -0
- package/dist/src/seller-catalog.js.map +1 -1
- package/dist/src/seller-pool.d.ts +127 -0
- package/dist/src/seller-pool.d.ts.map +1 -0
- package/dist/src/seller-pool.js +243 -0
- package/dist/src/seller-pool.js.map +1 -0
- package/dist/src/stream-failover.d.ts +78 -0
- package/dist/src/stream-failover.d.ts.map +1 -0
- package/dist/src/stream-failover.js +93 -0
- package/dist/src/stream-failover.js.map +1 -0
- package/package.json +1 -1
- package/src/buyer-store.ts +32 -2
- package/src/cli.ts +61 -0
- package/src/credit-tracker.test.ts +165 -0
- package/src/credit-tracker.ts +269 -0
- package/src/daemon.ts +569 -445
- package/src/model-index.test.ts +184 -0
- package/src/model-index.ts +266 -0
- package/src/prewarm-cache.test.ts +281 -0
- package/src/prewarm-cache.ts +373 -0
- package/src/prewarm-scheduler.test.ts +367 -0
- package/src/prewarm-scheduler.ts +581 -0
- package/src/provider-install.ts +9 -1
- package/src/route-failover.test.ts +193 -0
- package/src/route-failover.ts +233 -0
- package/src/seller-catalog-413.test.ts +61 -0
- package/src/seller-catalog.ts +47 -0
- package/src/seller-pool.test.ts +231 -0
- package/src/seller-pool.ts +333 -0
- package/src/stream-failover.test.ts +52 -0
- package/src/stream-failover.ts +129 -0
- package/src/thousand-seller.test.ts +151 -0
- package/tests/daemon-413-fallback.test.ts +92 -0
- package/tests/e2e.test.ts +3 -2
- 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
|
+
}
|