@bitbaum/ai-kit 0.6.2

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 (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +216 -0
  3. package/dist/attempt.d.ts +48 -0
  4. package/dist/attempt.js +59 -0
  5. package/dist/catalog.d.ts +65 -0
  6. package/dist/catalog.js +115 -0
  7. package/dist/chain.d.ts +204 -0
  8. package/dist/chain.js +261 -0
  9. package/dist/fair-share.d.ts +120 -0
  10. package/dist/fair-share.js +127 -0
  11. package/dist/forms.d.ts +15 -0
  12. package/dist/forms.js +15 -0
  13. package/dist/grounding/contract.d.ts +101 -0
  14. package/dist/grounding/contract.js +138 -0
  15. package/dist/grounding/facts.d.ts +107 -0
  16. package/dist/grounding/facts.js +134 -0
  17. package/dist/grounding/index.d.ts +24 -0
  18. package/dist/grounding/index.js +24 -0
  19. package/dist/grounding/verify.d.ts +91 -0
  20. package/dist/grounding/verify.js +372 -0
  21. package/dist/health.d.ts +52 -0
  22. package/dist/health.js +64 -0
  23. package/dist/index.d.ts +50 -0
  24. package/dist/index.js +70 -0
  25. package/dist/limits.d.ts +102 -0
  26. package/dist/limits.js +136 -0
  27. package/dist/react.d.ts +8 -0
  28. package/dist/react.js +8 -0
  29. package/dist/registry.d.ts +133 -0
  30. package/dist/registry.js +126 -0
  31. package/dist/server.d.ts +10 -0
  32. package/dist/server.js +10 -0
  33. package/dist-cjs/grounding/contract.js +146 -0
  34. package/dist-cjs/grounding/facts.js +143 -0
  35. package/dist-cjs/grounding/index.js +43 -0
  36. package/dist-cjs/grounding/verify.js +376 -0
  37. package/dist-cjs/package.json +1 -0
  38. package/dist-cjs/registry.js +131 -0
  39. package/package.json +102 -0
  40. package/src/attempt.ts +82 -0
  41. package/src/catalog.ts +155 -0
  42. package/src/chain.ts +318 -0
  43. package/src/fair-share.ts +183 -0
  44. package/src/forms.ts +15 -0
  45. package/src/grounding/contract.ts +176 -0
  46. package/src/grounding/facts.ts +170 -0
  47. package/src/grounding/index.ts +50 -0
  48. package/src/grounding/verify.ts +429 -0
  49. package/src/health.ts +92 -0
  50. package/src/index.ts +124 -0
  51. package/src/limits.ts +137 -0
  52. package/src/react.ts +8 -0
  53. package/src/registry.ts +207 -0
  54. package/src/server.ts +10 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mao Nakamoto
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,216 @@
1
+ # ai-kit
2
+
3
+ **The AI layer of an app, in one install.** Which model to call, what to do when
4
+ the vendor deletes it, how to walk the fallback and know when none of it worked,
5
+ what to do when you're going too fast, how to share a free tier fairly between
6
+ users, and how to fill a form from plain language.
7
+
8
+ ```bash
9
+ npm install ai-kit
10
+ ```
11
+
12
+ ---
13
+
14
+ ## Why this is one package and not four
15
+
16
+ Adding an AI feature looks like one decision and is actually four. Get any of
17
+ them wrong and the app fails **identically** from the outside: the assistant is
18
+ broken, and the error usually blames the wrong thing.
19
+
20
+ On 2026-08-26 that stopped being hypothetical. Groq retired its entire
21
+ `llama-3.x` family. Every app in this fleet that had picked a model by hand went
22
+ down at the same moment — five repos, three of them serving live traffic — and
23
+ the one app that had adopted the fallback chain was unaffected. One of the broken
24
+ ones reported *"AI assistant not configured, please set GROQ_API_KEY"* on a
25
+ deployment whose key was perfectly valid, so the first hour of the investigation
26
+ went into checking a credential that was never the problem.
27
+
28
+ That app had already adopted the form-filling half. It hand-rolled the other
29
+ half, because that was a second decision and nobody made it.
30
+
31
+ So the four decisions ship together now. Adding AI is one install.
32
+
33
+ > **Renamed from `ai-ration` in v0.3.0.** The old name described one of its five
34
+ > modules and hid the other four, and the person deciding whether to install it
35
+ > could not tell what it did. An unreadable name is a cost paid at every install
36
+ > decision — and this package had a single adopter while five repos that skipped
37
+ > it were taken down together by exactly the failure it prevents.
38
+
39
+ ---
40
+
41
+ ## What's in it
42
+
43
+ ### Which model — a list, never a pin
44
+
45
+ ```ts
46
+ import { freeChain, usableChain, chainFrom } from '@bitbaum/ai-kit';
47
+
48
+ const providers = freeChain('MYAPP'); // groq → openrouter
49
+ const links = usableChain(providers, process.env); // drops vendors with no key
50
+ const chain = chainFrom(process.env.MYAPP_MODEL, links);
51
+ ```
52
+
53
+ Falling back to a **smaller model at the same vendor buys nothing**: it draws on
54
+ the same org-wide daily budget, so when the day runs dry every link in that
55
+ "fallback" is already dead. Only a different vendor has a different meter.
56
+
57
+ **Probe before you pin.** Of nine free models probed live, **five** answered only
58
+ via a text tool protocol, not native `tool_calls`. A native-only client would
59
+ have silently lost most of the chain.
60
+
61
+ ### Is it up? — walk the chain, and know when none of it worked
62
+
63
+ A chain nobody walks is a list, not a fallback. This was found sitting unused
64
+ next to a single-shot caller in an app this package's `freeChain` had already
65
+ saved from a retired model — the list existed, and nothing tried link two.
66
+
67
+ ```ts
68
+ import { tryChain, createHealthTracker } from '@bitbaum/ai-kit';
69
+
70
+ const llmHealth = createHealthTracker(); // one per process; see below
71
+
72
+ const { text } = await tryChain(chain, {
73
+ health: llmHealth,
74
+ attempt: async ({ provider, model }) => {
75
+ // POST `${provider.baseUrl}/chat/completions` with `model` — your own
76
+ // fetch, your own retries. Throw to demote to the next link.
77
+ return callVendor(provider, model);
78
+ },
79
+ });
80
+ ```
81
+
82
+ No HTTP client here either — `attempt` makes the real request; `tryChain` only
83
+ decides which link goes next and throws `ChainExhaustedError` (naming every
84
+ link's failure, not just the last) when none of them work.
85
+
86
+ `createHealthTracker()` is a factory, not a global: a single-process app gets
87
+ the old "shared state everywhere" behaviour for free by making exactly one and
88
+ exporting it —
89
+
90
+ ```ts
91
+ // lib/llm-health.ts
92
+ export const llmHealth = createHealthTracker();
93
+ ```
94
+
95
+ — and a health route reports `llmHealth.getHealth()` instead of only ever
96
+ checking the database. That gap is not hypothetical: an app's `/health` reported
97
+ "healthy" while its only configured key was returning 401 and every chat route
98
+ was answering a friendly, silent, hardcoded apology. HTTP 200 is not evidence.
99
+
100
+ ### Still there? — catch a retirement before a user does
101
+
102
+ ```ts
103
+ import { freeChain, checkCatalog, hasRot, catalogReport } from '@bitbaum/ai-kit';
104
+
105
+ const verdicts = await checkCatalog(freeChain('MYAPP'));
106
+ if (hasRot(verdicts)) console.warn(catalogReport(verdicts));
107
+ ```
108
+
109
+ One `GET /models` per vendor. **Zero tokens**, which is what makes it
110
+ schedulable — and "somebody is supposed to remember" is precisely what failed.
111
+
112
+ Three states, not two: a catalogue that could not be read reports **unchecked**,
113
+ never *gone*. Treating "I could not look" as "nothing is there" marks every model
114
+ retired and invents an outage someone then acts on.
115
+
116
+ > This fleet runs it daily across every repo from
117
+ > [`fleet/scripts/ci/model-pin-audit.mjs`](https://github.com/bitbaum/fleet).
118
+
119
+ ### Too fast? — the three kinds of 429
120
+
121
+ ```ts
122
+ import { classifyRateLimit, rateLimitMessage } from '@bitbaum/ai-kit';
123
+
124
+ classifyRateLimit(body); // 'capacity' | 'size' | 'daily'
125
+ ```
126
+
127
+ They share a status code, a `type` and a `code`. Only the body tells them apart,
128
+ and they need **opposite** responses: retry shortly, shrink the request, or give
129
+ up on this vendor until tomorrow.
130
+
131
+ `retryAfterSeconds` is present only for the refusal a wait actually fixes.
132
+ Telling someone whose daily quota is gone to try again in 20 minutes is a lie.
133
+
134
+ ### Who gets it — fair shares of a free tier
135
+
136
+ ```ts
137
+ import { fairShare, utcDayElapsed } from '@bitbaum/ai-kit';
138
+ ```
139
+
140
+ A free tier grants roughly 100k tokens **per day for an entire org**, and one
141
+ measured tool-calling turn cost ~16k — about six turns a day. Divided badly, the
142
+ first enthusiastic user spends it before lunch and everyone after them meets a
143
+ wall, including the person trying the product for the first time, who concludes
144
+ it is broken and never comes back.
145
+
146
+ Shares are `capacity / active users`, recomputed per request, where *active*
147
+ means users who actually drew today — one user on a quiet day correctly gets
148
+ everything. The allowance unlocks gradually through the day, with a **one-turn
149
+ floor** so nobody's first question of the morning is refused.
150
+
151
+ Whatever you pass as `costTokens` must err **high**: under-estimating admits
152
+ turns the pool cannot cover, draining the day while the gate still believes there
153
+ is room.
154
+
155
+ ### Filling forms — from prose, then by talking to it
156
+
157
+ ```ts
158
+ import { runFormAssist } from '@bitbaum/ai-kit/forms';
159
+ import { useAiForm } from '@bitbaum/ai-kit/react';
160
+ import { createFormAssistHandler } from '@bitbaum/ai-kit/server';
161
+ ```
162
+
163
+ Re-exported from [`ai-forms`](https://github.com/bitbaum/ai-forms), which
164
+ stays its own package — it works, four apps run it, and it is useful well outside
165
+ this fleet. Swallowing it would have broken those four for the sake of a filing
166
+ system.
167
+
168
+ **Note the subpath.** Form filling is at `ai-kit/forms`, not at the root. For one
169
+ release it was both, and the first app to adopt the merged package paid for it:
170
+ `ai-forms` is ESM-only, so importing the *chain* from the root dragged the forms
171
+ package in behind it and the app's Jest run — which executes CJS — died inside a
172
+ module it never asked for. One install is still the whole promise; the exports
173
+ map is what keeps it, while letting a server that only wants a provider chain
174
+ stop paying for a form library.
175
+
176
+ React lives on its own subpath and is an **optional** peer, so importing `ai-kit`
177
+ on a server never pulls in a UI library.
178
+
179
+ ---
180
+
181
+ ## What it deliberately does not ship
182
+
183
+ **An HTTP client.** Every app has its own calling conventions, retries and
184
+ logging, and replacing those is a rewrite rather than an adoption. This supplies
185
+ the decisions; you keep the fetch.
186
+
187
+ That rule is under review, and honestly. `ai-forms` is the most-adopted package
188
+ in this fleet and it is the one that broke the rule, by shipping a route factory
189
+ and a React hook. A package that hands you a working route gets installed; one
190
+ that hands you advice about routes does not.
191
+
192
+ **Model values.** Which ids are free, which are billed, and which your account
193
+ may use are properties of *your* deployment. Centralise the rule, assert it
194
+ locally.
195
+
196
+ ---
197
+
198
+ ## Related
199
+
200
+ | Package | For |
201
+ |---|---|
202
+ | [`ai-forms`](https://github.com/bitbaum/ai-forms) | Form filling on its own, without the model layer |
203
+ | [`threadkit`](https://github.com/bitbaum/threadkit) | Messages between people, and who may see them |
204
+ | [`limitkit`](https://github.com/bitbaum/limitkit) | Stopping someone doing something too often |
205
+
206
+ `threadkit` and `limitkit` are **not** merged in here, on purpose: neither has
207
+ anything to do with AI. An app that throttles its login form should not install a
208
+ model catalogue to do it.
209
+
210
+ ## Development
211
+
212
+ ```bash
213
+ npm run verify # lint + typecheck + build + test
214
+ ```
215
+
216
+ MIT.
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Walk a chain, never own the fetch.
3
+ *
4
+ * `usableChain`/`chainFrom` (chain.ts) already answer WHICH links exist and in
5
+ * what order. What was still missing — in every app that hand-rolled it, and
6
+ * inconsistently — is the loop that tries link one, and on failure tries link
7
+ * two, rather than picking the first link and calling it once. A chain nobody
8
+ * walks is a list, not a fallback: it was found sitting unused next to a
9
+ * single-shot caller in the same app that this package's `freeChain` already
10
+ * protected from a retired model but not from a dead key, because ordering the
11
+ * links and walking them were still two different jobs and only one had a
12
+ * home.
13
+ *
14
+ * This still ships no HTTP client. `attempt` is supplied by the caller and
15
+ * does the actual request; this only decides which link goes next, and
16
+ * records the outcome if a `HealthTracker` is given.
17
+ */
18
+ import type { Link } from "./chain.js";
19
+ import type { HealthTracker } from "./health.js";
20
+ export interface ChainAttemptFailure {
21
+ link: Link;
22
+ message: string;
23
+ }
24
+ /** Every link in the chain was tried and failed (or the chain was empty). */
25
+ export declare class ChainExhaustedError extends Error {
26
+ readonly failures: ChainAttemptFailure[];
27
+ constructor(failures: ChainAttemptFailure[]);
28
+ }
29
+ export interface TryChainOptions<T> {
30
+ /** Makes the actual call for one link. Throw to demote to the next link. */
31
+ attempt: (link: Link) => Promise<T>;
32
+ /** Records one success or one failure for the WHOLE walk, not per link. */
33
+ health?: HealthTracker;
34
+ /** Called on each link's failure, before moving to the next — e.g. to log it. */
35
+ onLinkFailure?: (link: Link, error: unknown) => void;
36
+ }
37
+ /**
38
+ * Try each link in order; return the first success.
39
+ *
40
+ * Health is recorded once per call — a success on link two is still a success
41
+ * for the app, and a health check that flagged it "degraded" because the FIRST
42
+ * link failed would be reporting its own fallback working as a problem.
43
+ *
44
+ * Throws `ChainExhaustedError` (carrying every link's failure) when none
45
+ * succeed, so a caller can log exactly what was tried rather than only the
46
+ * last error — the failure that matters is often not the last one.
47
+ */
48
+ export declare function tryChain<T>(chain: Link[], options: TryChainOptions<T>): Promise<T>;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Walk a chain, never own the fetch.
3
+ *
4
+ * `usableChain`/`chainFrom` (chain.ts) already answer WHICH links exist and in
5
+ * what order. What was still missing — in every app that hand-rolled it, and
6
+ * inconsistently — is the loop that tries link one, and on failure tries link
7
+ * two, rather than picking the first link and calling it once. A chain nobody
8
+ * walks is a list, not a fallback: it was found sitting unused next to a
9
+ * single-shot caller in the same app that this package's `freeChain` already
10
+ * protected from a retired model but not from a dead key, because ordering the
11
+ * links and walking them were still two different jobs and only one had a
12
+ * home.
13
+ *
14
+ * This still ships no HTTP client. `attempt` is supplied by the caller and
15
+ * does the actual request; this only decides which link goes next, and
16
+ * records the outcome if a `HealthTracker` is given.
17
+ */
18
+ /** Every link in the chain was tried and failed (or the chain was empty). */
19
+ export class ChainExhaustedError extends Error {
20
+ failures;
21
+ constructor(failures) {
22
+ super(failures.length === 0
23
+ ? "No usable link in the chain — every provider is missing its key, or has no models configured."
24
+ : `All ${failures.length} link(s) failed — ${failures
25
+ .map((f) => `${f.link.provider.id}/${f.link.model}: ${f.message}`)
26
+ .join("; ")}`);
27
+ this.name = "ChainExhaustedError";
28
+ this.failures = failures;
29
+ }
30
+ }
31
+ /**
32
+ * Try each link in order; return the first success.
33
+ *
34
+ * Health is recorded once per call — a success on link two is still a success
35
+ * for the app, and a health check that flagged it "degraded" because the FIRST
36
+ * link failed would be reporting its own fallback working as a problem.
37
+ *
38
+ * Throws `ChainExhaustedError` (carrying every link's failure) when none
39
+ * succeed, so a caller can log exactly what was tried rather than only the
40
+ * last error — the failure that matters is often not the last one.
41
+ */
42
+ export async function tryChain(chain, options) {
43
+ const failures = [];
44
+ for (const link of chain) {
45
+ try {
46
+ const result = await options.attempt(link);
47
+ options.health?.recordSuccess();
48
+ return result;
49
+ }
50
+ catch (error) {
51
+ const message = error instanceof Error ? error.message : String(error);
52
+ failures.push({ link, message });
53
+ options.onLinkFailure?.(link, error);
54
+ }
55
+ }
56
+ const exhausted = new ChainExhaustedError(failures);
57
+ options.health?.recordFailure(exhausted);
58
+ throw exhausted;
59
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * catalog — has the vendor retired a model this chain still asks for?
3
+ *
4
+ * The chain exists because a single pinned free model is a scheduled outage.
5
+ * That reasoning has a hole: the chain itself is a list of pinned ids, so it
6
+ * rots too, and a chain whose first vendor is entirely dead is a slower version
7
+ * of the failure it was built to prevent.
8
+ *
9
+ * Not hypothetical. On 2026-08-25 `freeChain()` was checked against the live
10
+ * catalogues and FOUR of its nine ids were gone — both Groq models (the whole
11
+ * first vendor) and two OpenRouter ids, one of them the preferred fallback. The
12
+ * consumer that also used the Groq id for direct, unchained calls had been
13
+ * silently failing for eight days.
14
+ *
15
+ * Why this lives in the package rather than in each app: the check is the same
16
+ * everywhere, and the app that wrote its own first wrote it slightly
17
+ * differently. One implementation, shared by name and by value.
18
+ *
19
+ * Cheap on purpose — one GET /models per provider and ZERO tokens. That is what
20
+ * makes it schedulable, which is the whole difference between a check that runs
21
+ * nightly and a command someone is supposed to remember. A tool-call probe
22
+ * costs real tokens and cannot run on a timer; existence can.
23
+ */
24
+ import { type Env, type Provider } from "./chain.js";
25
+ export type CatalogVerdict = {
26
+ provider: string;
27
+ /**
28
+ * Ids the vendor currently lists, or NULL when the catalogue could not be
29
+ * read (no key, network failure, non-200, unparseable body).
30
+ *
31
+ * Null is not an empty list. Treating "I could not look" as "nothing is
32
+ * there" reports every model as retired and invents an outage; treating it
33
+ * as "all fine" hides a real one. Callers must handle three states.
34
+ */
35
+ live: string[] | null;
36
+ /** Pinned ids confirmed present. Empty when `live` is null. */
37
+ present: string[];
38
+ /** Pinned ids the vendor no longer lists. Empty when `live` is null. */
39
+ missing: string[];
40
+ /** Pinned ids whose status is unknown because `live` is null. */
41
+ unchecked: string[];
42
+ };
43
+ export type CheckCatalogOptions = {
44
+ env?: Env;
45
+ /** Injectable for tests; defaults to global fetch. */
46
+ fetchImpl?: typeof fetch;
47
+ timeoutMs?: number;
48
+ };
49
+ /**
50
+ * Check every model a chain would try against what its vendor still lists.
51
+ *
52
+ * Honours the same env overrides `usableChain` does, so it checks the ids this
53
+ * deployment would ACTUALLY call — not the library defaults an operator has
54
+ * already routed around.
55
+ */
56
+ export declare function checkCatalog(chain: Provider[], opts?: CheckCatalogOptions): Promise<CatalogVerdict[]>;
57
+ /** True when any pinned id is confirmed gone. Unchecked providers do NOT count
58
+ * — an unreadable catalogue is not evidence of rot. */
59
+ export declare function hasRot(verdicts: CatalogVerdict[]): boolean;
60
+ /** True when a whole vendor's models are gone, i.e. the chain has lost a link
61
+ * entirely. Worth separating: a chain that still has vendors is degraded, a
62
+ * chain that has lost one is back to being a single point of failure. */
63
+ export declare function deadProviders(verdicts: CatalogVerdict[]): string[];
64
+ /** Human-readable report. Keeps could-not-look visibly distinct from a pass. */
65
+ export declare function catalogReport(verdicts: CatalogVerdict[]): string;
@@ -0,0 +1,115 @@
1
+ /**
2
+ * catalog — has the vendor retired a model this chain still asks for?
3
+ *
4
+ * The chain exists because a single pinned free model is a scheduled outage.
5
+ * That reasoning has a hole: the chain itself is a list of pinned ids, so it
6
+ * rots too, and a chain whose first vendor is entirely dead is a slower version
7
+ * of the failure it was built to prevent.
8
+ *
9
+ * Not hypothetical. On 2026-08-25 `freeChain()` was checked against the live
10
+ * catalogues and FOUR of its nine ids were gone — both Groq models (the whole
11
+ * first vendor) and two OpenRouter ids, one of them the preferred fallback. The
12
+ * consumer that also used the Groq id for direct, unchained calls had been
13
+ * silently failing for eight days.
14
+ *
15
+ * Why this lives in the package rather than in each app: the check is the same
16
+ * everywhere, and the app that wrote its own first wrote it slightly
17
+ * differently. One implementation, shared by name and by value.
18
+ *
19
+ * Cheap on purpose — one GET /models per provider and ZERO tokens. That is what
20
+ * makes it schedulable, which is the whole difference between a check that runs
21
+ * nightly and a command someone is supposed to remember. A tool-call probe
22
+ * costs real tokens and cannot run on a timer; existence can.
23
+ */
24
+ import { providerModels } from "./chain.js";
25
+ /** Ids listed by one provider, or null when the catalogue could not be read. */
26
+ async function liveIds(provider, key, fetchImpl, timeoutMs) {
27
+ try {
28
+ const res = await fetchImpl(`${provider.baseUrl.replace(/\/$/, "")}/models`, {
29
+ headers: { Authorization: `Bearer ${key}` },
30
+ signal: AbortSignal.timeout(timeoutMs),
31
+ });
32
+ if (!res.ok)
33
+ return null;
34
+ const body = (await res.json());
35
+ if (!Array.isArray(body?.data))
36
+ return null;
37
+ const ids = body.data
38
+ .map((m) => (typeof m?.id === "string" ? m.id : ""))
39
+ .filter((id) => id.length > 0);
40
+ // A catalogue that parses but lists nothing is a malformed answer, not a
41
+ // vendor with no models. Refusing it keeps a bad response from reading as
42
+ // total rot.
43
+ return ids.length > 0 ? ids : null;
44
+ }
45
+ catch {
46
+ return null;
47
+ }
48
+ }
49
+ /**
50
+ * Check every model a chain would try against what its vendor still lists.
51
+ *
52
+ * Honours the same env overrides `usableChain` does, so it checks the ids this
53
+ * deployment would ACTUALLY call — not the library defaults an operator has
54
+ * already routed around.
55
+ */
56
+ export async function checkCatalog(chain, opts = {}) {
57
+ const env = opts.env ?? process.env;
58
+ const fetchImpl = opts.fetchImpl ?? fetch;
59
+ const timeoutMs = opts.timeoutMs ?? 20_000;
60
+ const out = [];
61
+ for (const provider of chain) {
62
+ const pinned = providerModels(provider, env);
63
+ const key = env[provider.keyEnv]?.trim();
64
+ const live = key ? await liveIds(provider, key, fetchImpl, timeoutMs) : null;
65
+ if (!live) {
66
+ out.push({ provider: provider.id, live: null, present: [], missing: [], unchecked: pinned });
67
+ continue;
68
+ }
69
+ const set = new Set(live);
70
+ out.push({
71
+ provider: provider.id,
72
+ live,
73
+ present: pinned.filter((m) => set.has(m)),
74
+ missing: pinned.filter((m) => !set.has(m)),
75
+ unchecked: [],
76
+ });
77
+ }
78
+ return out;
79
+ }
80
+ /** True when any pinned id is confirmed gone. Unchecked providers do NOT count
81
+ * — an unreadable catalogue is not evidence of rot. */
82
+ export function hasRot(verdicts) {
83
+ return verdicts.some((v) => v.missing.length > 0);
84
+ }
85
+ /** True when a whole vendor's models are gone, i.e. the chain has lost a link
86
+ * entirely. Worth separating: a chain that still has vendors is degraded, a
87
+ * chain that has lost one is back to being a single point of failure. */
88
+ export function deadProviders(verdicts) {
89
+ return verdicts
90
+ .filter((v) => v.live !== null && v.present.length === 0 && v.missing.length > 0)
91
+ .map((v) => v.provider);
92
+ }
93
+ /** Human-readable report. Keeps could-not-look visibly distinct from a pass. */
94
+ export function catalogReport(verdicts) {
95
+ const lines = [];
96
+ for (const v of verdicts) {
97
+ if (v.live === null) {
98
+ lines.push(`? ${v.provider}: catalogue unreadable (no key, or the request failed) — ${v.unchecked.length} id(s) UNCHECKED`);
99
+ for (const m of v.unchecked)
100
+ lines.push(` ? ${m}`);
101
+ continue;
102
+ }
103
+ for (const m of v.present)
104
+ lines.push(` ok ${v.provider}/${m}`);
105
+ for (const m of v.missing)
106
+ lines.push(` GONE ${v.provider}/${m}`);
107
+ }
108
+ const dead = deadProviders(verdicts);
109
+ if (dead.length)
110
+ lines.push(`\nEVERY model is gone at: ${dead.join(", ")} — the chain has lost that vendor entirely.`);
111
+ const unchecked = verdicts.reduce((n, v) => n + v.unchecked.length, 0);
112
+ if (unchecked)
113
+ lines.push(`\n${unchecked} id(s) could not be checked. That is not a pass for them.`);
114
+ return lines.join("\n");
115
+ }