@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.
- package/LICENSE +21 -0
- package/README.md +216 -0
- package/dist/attempt.d.ts +48 -0
- package/dist/attempt.js +59 -0
- package/dist/catalog.d.ts +65 -0
- package/dist/catalog.js +115 -0
- package/dist/chain.d.ts +204 -0
- package/dist/chain.js +261 -0
- package/dist/fair-share.d.ts +120 -0
- package/dist/fair-share.js +127 -0
- package/dist/forms.d.ts +15 -0
- package/dist/forms.js +15 -0
- package/dist/grounding/contract.d.ts +101 -0
- package/dist/grounding/contract.js +138 -0
- package/dist/grounding/facts.d.ts +107 -0
- package/dist/grounding/facts.js +134 -0
- package/dist/grounding/index.d.ts +24 -0
- package/dist/grounding/index.js +24 -0
- package/dist/grounding/verify.d.ts +91 -0
- package/dist/grounding/verify.js +372 -0
- package/dist/health.d.ts +52 -0
- package/dist/health.js +64 -0
- package/dist/index.d.ts +50 -0
- package/dist/index.js +70 -0
- package/dist/limits.d.ts +102 -0
- package/dist/limits.js +136 -0
- package/dist/react.d.ts +8 -0
- package/dist/react.js +8 -0
- package/dist/registry.d.ts +133 -0
- package/dist/registry.js +126 -0
- package/dist/server.d.ts +10 -0
- package/dist/server.js +10 -0
- package/dist-cjs/grounding/contract.js +146 -0
- package/dist-cjs/grounding/facts.js +143 -0
- package/dist-cjs/grounding/index.js +43 -0
- package/dist-cjs/grounding/verify.js +376 -0
- package/dist-cjs/package.json +1 -0
- package/dist-cjs/registry.js +131 -0
- package/package.json +102 -0
- package/src/attempt.ts +82 -0
- package/src/catalog.ts +155 -0
- package/src/chain.ts +318 -0
- package/src/fair-share.ts +183 -0
- package/src/forms.ts +15 -0
- package/src/grounding/contract.ts +176 -0
- package/src/grounding/facts.ts +170 -0
- package/src/grounding/index.ts +50 -0
- package/src/grounding/verify.ts +429 -0
- package/src/health.ts +92 -0
- package/src/index.ts +124 -0
- package/src/limits.ts +137 -0
- package/src/react.ts +8 -0
- package/src/registry.ts +207 -0
- 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>;
|
package/dist/attempt.js
ADDED
|
@@ -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;
|
package/dist/catalog.js
ADDED
|
@@ -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
|
+
}
|