@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/src/catalog.ts ADDED
@@ -0,0 +1,155 @@
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
+
25
+ import { providerModels, type Env, type Provider } from "./chain.js";
26
+
27
+ export type CatalogVerdict = {
28
+ provider: string;
29
+ /**
30
+ * Ids the vendor currently lists, or NULL when the catalogue could not be
31
+ * read (no key, network failure, non-200, unparseable body).
32
+ *
33
+ * Null is not an empty list. Treating "I could not look" as "nothing is
34
+ * there" reports every model as retired and invents an outage; treating it
35
+ * as "all fine" hides a real one. Callers must handle three states.
36
+ */
37
+ live: string[] | null;
38
+ /** Pinned ids confirmed present. Empty when `live` is null. */
39
+ present: string[];
40
+ /** Pinned ids the vendor no longer lists. Empty when `live` is null. */
41
+ missing: string[];
42
+ /** Pinned ids whose status is unknown because `live` is null. */
43
+ unchecked: string[];
44
+ };
45
+
46
+ export type CheckCatalogOptions = {
47
+ env?: Env;
48
+ /** Injectable for tests; defaults to global fetch. */
49
+ fetchImpl?: typeof fetch;
50
+ timeoutMs?: number;
51
+ };
52
+
53
+ /** Ids listed by one provider, or null when the catalogue could not be read. */
54
+ async function liveIds(
55
+ provider: Provider,
56
+ key: string,
57
+ fetchImpl: typeof fetch,
58
+ timeoutMs: number,
59
+ ): Promise<string[] | null> {
60
+ try {
61
+ const res = await fetchImpl(`${provider.baseUrl.replace(/\/$/, "")}/models`, {
62
+ headers: { Authorization: `Bearer ${key}` },
63
+ signal: AbortSignal.timeout(timeoutMs),
64
+ });
65
+ if (!res.ok) return null;
66
+ const body = (await res.json()) as { data?: Array<{ id?: unknown }> };
67
+ if (!Array.isArray(body?.data)) return null;
68
+ const ids = body.data
69
+ .map((m) => (typeof m?.id === "string" ? m.id : ""))
70
+ .filter((id): id is string => id.length > 0);
71
+ // A catalogue that parses but lists nothing is a malformed answer, not a
72
+ // vendor with no models. Refusing it keeps a bad response from reading as
73
+ // total rot.
74
+ return ids.length > 0 ? ids : null;
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Check every model a chain would try against what its vendor still lists.
82
+ *
83
+ * Honours the same env overrides `usableChain` does, so it checks the ids this
84
+ * deployment would ACTUALLY call — not the library defaults an operator has
85
+ * already routed around.
86
+ */
87
+ export async function checkCatalog(
88
+ chain: Provider[],
89
+ opts: CheckCatalogOptions = {},
90
+ ): Promise<CatalogVerdict[]> {
91
+ const env = opts.env ?? process.env;
92
+ const fetchImpl = opts.fetchImpl ?? fetch;
93
+ const timeoutMs = opts.timeoutMs ?? 20_000;
94
+
95
+ const out: CatalogVerdict[] = [];
96
+ for (const provider of chain) {
97
+ const pinned = providerModels(provider, env);
98
+ const key = env[provider.keyEnv]?.trim();
99
+ const live = key ? await liveIds(provider, key, fetchImpl, timeoutMs) : null;
100
+
101
+ if (!live) {
102
+ out.push({ provider: provider.id, live: null, present: [], missing: [], unchecked: pinned });
103
+ continue;
104
+ }
105
+ const set = new Set(live);
106
+ out.push({
107
+ provider: provider.id,
108
+ live,
109
+ present: pinned.filter((m) => set.has(m)),
110
+ missing: pinned.filter((m) => !set.has(m)),
111
+ unchecked: [],
112
+ });
113
+ }
114
+ return out;
115
+ }
116
+
117
+ /** True when any pinned id is confirmed gone. Unchecked providers do NOT count
118
+ * — an unreadable catalogue is not evidence of rot. */
119
+ export function hasRot(verdicts: CatalogVerdict[]): boolean {
120
+ return verdicts.some((v) => v.missing.length > 0);
121
+ }
122
+
123
+ /** True when a whole vendor's models are gone, i.e. the chain has lost a link
124
+ * entirely. Worth separating: a chain that still has vendors is degraded, a
125
+ * chain that has lost one is back to being a single point of failure. */
126
+ export function deadProviders(verdicts: CatalogVerdict[]): string[] {
127
+ return verdicts
128
+ .filter((v) => v.live !== null && v.present.length === 0 && v.missing.length > 0)
129
+ .map((v) => v.provider);
130
+ }
131
+
132
+ /** Human-readable report. Keeps could-not-look visibly distinct from a pass. */
133
+ export function catalogReport(verdicts: CatalogVerdict[]): string {
134
+ const lines: string[] = [];
135
+ for (const v of verdicts) {
136
+ if (v.live === null) {
137
+ lines.push(
138
+ `? ${v.provider}: catalogue unreadable (no key, or the request failed) — ${v.unchecked.length} id(s) UNCHECKED`,
139
+ );
140
+ for (const m of v.unchecked) lines.push(` ? ${m}`);
141
+ continue;
142
+ }
143
+ for (const m of v.present) lines.push(` ok ${v.provider}/${m}`);
144
+ for (const m of v.missing) lines.push(` GONE ${v.provider}/${m}`);
145
+ }
146
+ const dead = deadProviders(verdicts);
147
+ if (dead.length)
148
+ lines.push(
149
+ `\nEVERY model is gone at: ${dead.join(", ")} — the chain has lost that vendor entirely.`,
150
+ );
151
+ const unchecked = verdicts.reduce((n, v) => n + v.unchecked.length, 0);
152
+ if (unchecked)
153
+ lines.push(`\n${unchecked} id(s) could not be checked. That is not a pass for them.`);
154
+ return lines.join("\n");
155
+ }
package/src/chain.ts ADDED
@@ -0,0 +1,318 @@
1
+ /**
2
+ * The provider CHAIN — a list, never a pin.
3
+ *
4
+ * This exists because of a failure that repeated across several projects before
5
+ * anyone named it: an app picks one free model, ships, and works. Then the model
6
+ * is retired, or the vendor's daily budget runs out, and the app is simply down
7
+ * — with an error that looks like a bug in the app rather than an empty tier.
8
+ * A single pinned free model is not a configuration, it is a scheduled outage.
9
+ *
10
+ * Two properties do the work, and BOTH are needed:
11
+ *
12
+ * ACROSS MODELS — a rotted or momentarily busy model steps aside for the
13
+ * next one.
14
+ * ACROSS VENDORS — the one that actually buys headroom. Stepping down to a
15
+ * smaller model at the SAME vendor draws on the SAME org-wide
16
+ * daily budget, so when the day runs dry every link in that
17
+ * "fallback" is already dead. Only a different vendor has a
18
+ * different meter.
19
+ *
20
+ * Every provider here speaks the OpenAI chat-completions shape, so adding one is
21
+ * a row in a table rather than a new client.
22
+ *
23
+ * ── Before pinning a model, PROBE IT ─────────────────────────────────────────
24
+ * A model that cannot emit a parseable tool call cannot drive a tool loop, and
25
+ * that is not guessable from its name, size, or docs. Of nine free models probed
26
+ * live for the default chain below, FIVE answered only via a text protocol and
27
+ * not via native `tool_calls` — so a native-only client would have silently lost
28
+ * most of the chain. Probe with a real tool call, not a docs page.
29
+ *
30
+ * ── Environment is passed in, never read from a global ───────────────────────
31
+ * Every function here takes `env`, defaulting to `process.env`. That keeps the
32
+ * module testable without mutating global state, and makes the override points
33
+ * explicit rather than discovered by grep.
34
+ */
35
+
36
+ /** A vendor, its endpoint, and the free models worth trying on it, in order. */
37
+ export type Provider = {
38
+ /** Display/debug name; also the prefix reported back as the model id. */
39
+ id: string;
40
+ baseUrl: string;
41
+ /** Env var holding the API key. Absent key = entry skipped, not an error. */
42
+ keyEnv: string;
43
+ /** Models to try for this provider, in order. */
44
+ models: string[];
45
+ /**
46
+ * Tokens this vendor's FREE tier grants per day, summed into the pool that
47
+ * fair-share rations. An ESTIMATE unless the vendor states it: handing out
48
+ * shares of capacity that turns out not to exist produces the exact wall the
49
+ * rationing exists to prevent, only later in the day and harder to diagnose.
50
+ * So estimate LOW.
51
+ */
52
+ dailyTokens: number;
53
+ /**
54
+ * Env var that REPLACES `models` when set (comma/space separated).
55
+ * Read at CALL time, not at import: the point of this override is routing
56
+ * around a model that rotted, and a value frozen at module load would need a
57
+ * redeploy to take effect — which is exactly the delay it exists to avoid.
58
+ */
59
+ modelsEnv?: string;
60
+ /** Env var overriding `dailyTokens` at call time. */
61
+ dailyTokensEnv?: string;
62
+ /**
63
+ * Does this vendor use ROUTED ids, where `vendor/model` names weights it
64
+ * resells and a `:free` suffix is the difference between free routing and a
65
+ * per-call charge? True for OpenRouter.
66
+ *
67
+ * It matters because the same STRING means different things at different
68
+ * vendors. `openai/gpt-oss-20b` bills at OpenRouter (no `:free`), while at
69
+ * Groq it is simply that vendor's name for a model whose cost depends on the
70
+ * account tier. Deciding cost from the id alone was safe only while
71
+ * non-routed vendors used bare ids like `llama-3.1-8b-instant`; Groq now
72
+ * ships vendor-prefixed ids, so the shape no longer identifies the vendor.
73
+ *
74
+ * Defaults to false: claiming an id is routed when it is not would report a
75
+ * free model as paid, and the reverse — assuming free — is the direction
76
+ * this module exists to refuse.
77
+ */
78
+ routed?: boolean;
79
+ };
80
+
81
+ export type Env = Record<string, string | undefined>;
82
+
83
+ /** One attempt: a model at a provider. */
84
+ export type Link = { provider: Provider; model: string };
85
+
86
+ function readEnv(env: Env, name: string | undefined): string | undefined {
87
+ if (!name) return undefined;
88
+ return env[name]?.trim() || undefined;
89
+ }
90
+
91
+ /** Split a comma/space separated env override into model ids. */
92
+ function modelsFromEnv(env: Env, name: string | undefined): string[] | null {
93
+ const raw = readEnv(env, name);
94
+ if (!raw) return null;
95
+ const models = raw.split(/[\s,]+/).filter(Boolean);
96
+ return models.length > 0 ? models : null;
97
+ }
98
+
99
+ /** This provider's models, honouring its env override. */
100
+ export function providerModels(provider: Provider, env: Env = process.env): string[] {
101
+ return modelsFromEnv(env, provider.modelsEnv) ?? provider.models;
102
+ }
103
+
104
+ /**
105
+ * Build a provider row whose env var names follow one prefix.
106
+ *
107
+ * Saves each app from inventing its own naming and then documenting it: with
108
+ * prefix "LOKI" a provider `groq` reads LOKI_GROQ_MODELS and
109
+ * LOKI_GROQ_DAILY_TOKENS. The key env stays explicit because it is usually the
110
+ * vendor's conventional name (GROQ_API_KEY), shared with other tools.
111
+ */
112
+ export function withEnvPrefix(
113
+ prefix: string,
114
+ provider: Omit<Provider, "modelsEnv" | "dailyTokensEnv">,
115
+ ): Provider {
116
+ const slug = provider.id.toUpperCase().replace(/[^A-Z0-9]+/g, "_");
117
+ return {
118
+ ...provider,
119
+ modelsEnv: `${prefix}_${slug}_MODELS`,
120
+ dailyTokensEnv: `${prefix}_${slug}_DAILY_TOKENS`,
121
+ };
122
+ }
123
+
124
+ /**
125
+ * A default chain of FREE models, every entry probed live on 2026-08-15 with a
126
+ * real tool call. Protocol each answered on:
127
+ *
128
+ * groq/llama-3.3-70b-versatile native
129
+ * groq/llama-3.1-8b-instant text
130
+ * openai/gpt-oss-20b:free native
131
+ * nvidia/nemotron-3-super-120b-a12b:free native
132
+ * nvidia/nemotron-3.5-lightning:free native
133
+ * google/gemma-4-26b-a4b-it:free text
134
+ * nvidia/nemotron-3-nano-30b-a3b:free text
135
+ * cohere/north-mini-code:free text
136
+ * openrouter/free text
137
+ *
138
+ * Deliberately excluded, both verified rather than assumed:
139
+ * google/gemma-4-31b-it:free — "Provider returned error" on probe
140
+ * nvidia/nemotron-nano-12b-v2-vl — returns HTTP 200 with EMPTY content, which
141
+ * a naive client reads as a successful
142
+ * empty answer
143
+ *
144
+ * `openrouter/free` sits last on purpose: it is an auto-router across the free
145
+ * catalogue, so it keeps working when a specific id above it is retired. That
146
+ * makes it the link most likely to survive the next rot, and the least
147
+ * predictable in quality — exactly the right shape for a last resort.
148
+ *
149
+ * NOTE the shelf life. This list is evidence from one day, not a constant; free
150
+ * catalogues rot. Treat it as a starting point and re-probe.
151
+ */
152
+ export function freeChain(prefix = "AI"): Provider[] {
153
+ return [
154
+ withEnvPrefix(prefix, {
155
+ id: "groq",
156
+ baseUrl: "https://api.groq.com/openai/v1",
157
+ keyEnv: "GROQ_API_KEY",
158
+ // Re-probed 2026-08-25 against the live catalog. The previous pins,
159
+ // `llama-3.3-70b-versatile` and `llama-3.1-8b-instant`, were BOTH gone —
160
+ // so this "fallback chain" led with a fully dead vendor and every caller
161
+ // paid two 404s before reaching OpenRouter. FleetCrown, whose direct
162
+ // (non-chain) calls used the same id and had no fallback at all, was
163
+ // silently down for eight days. Both ids below answered with a correct
164
+ // native tool_call when probed, which is the bar this list is held to.
165
+ models: ["openai/gpt-oss-120b", "openai/gpt-oss-20b"],
166
+ // Not a guess: Groq's own TPD refusal names it — "on tokens per day
167
+ // (TPD): Limit 100000". Org-wide, so every feature sharing the key draws
168
+ // from this same pool.
169
+ dailyTokens: 100_000,
170
+ }),
171
+ withEnvPrefix(prefix, {
172
+ id: "openrouter",
173
+ baseUrl: "https://openrouter.ai/api/v1",
174
+ keyEnv: "OPENROUTER_API_KEY",
175
+ // Routed ids: `:free` is the whole difference between free routing and a
176
+ // per-call charge for the same weights. See Provider.routed.
177
+ routed: true,
178
+ // Re-checked 2026-08-25 against the 419-model live catalog. Two entries
179
+ // were retired and are removed here: `openai/gpt-oss-20b:free` — which
180
+ // was FIRST, so the preferred fallback 404'd on every call — and
181
+ // `nvidia/nemotron-3-nano-30b-a3b:free`. The five below were present.
182
+ models: [
183
+ "nvidia/nemotron-3-super-120b-a12b:free",
184
+ "nvidia/nemotron-3.5-lightning:free",
185
+ "google/gemma-4-26b-a4b-it:free",
186
+ "cohere/north-mini-code:free",
187
+ "openrouter/free",
188
+ ],
189
+ // OpenRouter meters its free tier in REQUESTS per day, not tokens, and the
190
+ // cap depends on the account's credit balance — so this is a translation,
191
+ // not a published figure. Set at the low end on purpose.
192
+ dailyTokens: 100_000,
193
+ }),
194
+ ];
195
+ }
196
+
197
+ /** What a model id tells us about who pays. */
198
+ export type CostVerdict = "free" | "paid" | "unknown";
199
+
200
+ /**
201
+ * Does this model id cost money?
202
+ *
203
+ * Exists because the same mistake was found in THREE separate apps on one day,
204
+ * each a fallback that silently began spending when the free tier ran dry:
205
+ *
206
+ * anthropic/claude-sonnet-5 a premium model as the fallback
207
+ * google/gemini-2.0-flash-001 the paid twin of a `:free` id
208
+ * meta-llama/llama-3.3-70b-instruct reads free; bills at 1e-7/token,
209
+ * and its `:free` sibling has been
210
+ * retired from the catalogue
211
+ *
212
+ * The decidable rule is narrow and stated as such. A routed id (`vendor/model`,
213
+ * the OpenRouter shape) is FREE only with the `:free` suffix, and PAID without
214
+ * it — that suffix is the entire difference between free routing and a per-call
215
+ * charge for the same weights. A bare id (`llama-3.1-8b-instant`) says nothing:
216
+ * whether it costs depends on the account's tier at that vendor, which no string
217
+ * can answer, so it returns "unknown" rather than guessing.
218
+ *
219
+ * Guessing "free" there would be the dangerous direction — it is what let three
220
+ * of these through code review.
221
+ *
222
+ * IMPORTANT: this reads the id as a ROUTED (OpenRouter-shape) id, because that
223
+ * is the only shape where the string decides. It is therefore wrong to apply to
224
+ * an id from a vendor that merely happens to prefix its own models — Groq's
225
+ * `openai/gpt-oss-120b` is not a routed OpenAI id, and this function would call
226
+ * it paid. When you know the provider, use `modelCostAt`; `paidModelsIn` does.
227
+ */
228
+ export function modelCost(id: string): CostVerdict {
229
+ const model = id.trim();
230
+ if (!model) return "unknown";
231
+ // OpenRouter's auto-router across the free catalogue.
232
+ if (model === "openrouter/free") return "free";
233
+ if (!model.includes("/")) return "unknown";
234
+ return model.endsWith(":free") ? "free" : "paid";
235
+ }
236
+
237
+ /**
238
+ * Cost of a model AT a specific provider — the honest signature, because the
239
+ * same id answers differently at different vendors (see `Provider.routed`).
240
+ *
241
+ * At a non-routed vendor the id carries no cost information at all: what you
242
+ * pay is the account's tier there, which no string can report. That is the
243
+ * same "unknown" a bare id has always returned, now correct for vendor-prefixed
244
+ * ids too.
245
+ */
246
+ export function modelCostAt(provider: Provider, model: string): CostVerdict {
247
+ return provider.routed ? modelCost(model) : "unknown";
248
+ }
249
+
250
+ /**
251
+ * Assert every model in a chain is free, for apps that must never bill.
252
+ *
253
+ * Judges each id AT ITS PROVIDER. Flagging Groq's `openai/gpt-oss-120b` as paid
254
+ * because it contains a slash would be a false alarm that pressures someone
255
+ * into "fixing" a working free model — and a guard that cries wolf gets
256
+ * disabled, taking the three real cases it does catch with it.
257
+ *
258
+ * Returns the offending ids rather than throwing: the caller knows whether a
259
+ * paid link is a bug or a deliberate, opted-in upgrade, and a library that
260
+ * throws on the second case forces people to route around it.
261
+ */
262
+ export function paidModelsIn(chain: Provider[]): string[] {
263
+ return chain.flatMap((p) => p.models.filter((m) => modelCostAt(p, m) === "paid"));
264
+ }
265
+
266
+ /**
267
+ * The day's total budget: every provider we hold a key for.
268
+ *
269
+ * Only KEYED providers count. A vendor whose key is absent contributes nothing
270
+ * however generous its tier, and counting it would ration users against capacity
271
+ * that cannot be reached — the same failure as an optimistic estimate, just with
272
+ * an obvious cause.
273
+ */
274
+ export function dayCapacityTokens(chain: Provider[], env: Env = process.env): number {
275
+ let total = 0;
276
+ for (const provider of chain) {
277
+ if (!readEnv(env, provider.keyEnv)) continue;
278
+ const override = Number(readEnv(env, provider.dailyTokensEnv));
279
+ total += Number.isFinite(override) && override >= 0 ? override : provider.dailyTokens;
280
+ }
281
+ return total;
282
+ }
283
+
284
+ /**
285
+ * The chain with unusable entries removed: no API key, or no models configured.
286
+ *
287
+ * A missing key is a normal deployment state — most boxes carry one vendor's
288
+ * key, not every vendor's — so it filters out silently rather than throwing.
289
+ */
290
+ export function usableChain(chain: Provider[], env: Env = process.env): Link[] {
291
+ const out: Link[] = [];
292
+ for (const provider of chain) {
293
+ if (!readEnv(env, provider.keyEnv)) continue;
294
+ for (const model of providerModels(provider, env)) out.push({ provider, model });
295
+ }
296
+ return out;
297
+ }
298
+
299
+ /**
300
+ * The chain starting at `model`, or the whole chain when it names no link.
301
+ *
302
+ * Apps commonly carry a "use this model" env var. Honouring it as a STARTING
303
+ * POINT rather than a hard pin keeps that escape hatch while refusing to
304
+ * reintroduce the single point of failure this module exists to remove: an
305
+ * operator pinning a model should still get a fallback when that model's vendor
306
+ * runs dry.
307
+ */
308
+ export function chainFrom(model: string | undefined, chain: Link[]): Link[] {
309
+ const wanted = model?.trim();
310
+ if (!wanted) return chain;
311
+ const at = chain.findIndex((l) => l.model === wanted);
312
+ if (at >= 0) return chain.slice(at);
313
+ // A model nobody advertises is still a legitimate request (a private
314
+ // deployment, a just-released id). Try it against the first provider that has
315
+ // a key, then fall through to the ordinary chain rather than dead-ending.
316
+ const host = chain[0];
317
+ return host ? [{ provider: host.provider, model: wanted }, ...chain] : [];
318
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Fair-share rationing of a FIXED daily AI budget across users.
3
+ *
4
+ * Pure by design — no DB, no clock, no provider. Everything it needs arrives as
5
+ * arguments, so the same policy drops into any project without dragging one
6
+ * app's storage or model plumbing along. The caller owns "what has this user
7
+ * spent today"; this file owns "may they spend more".
8
+ *
9
+ * ── The problem it exists to solve ───────────────────────────────────────────
10
+ * Free tiers are metered per DAY and shared by everyone holding the key: one
11
+ * vendor grants ~100k tokens/day across a whole org, and one measured chat turn
12
+ * cost ~16k (one tool call, on a free model). That is about six turns a day per
13
+ * vendor — fewer than anyone assumes. Divided badly, the first
14
+ * enthusiastic user spends the entire day's budget before lunch and everyone
15
+ * who arrives after them meets a wall — including the person trying the product
16
+ * for the first time, who concludes it is broken and never returns.
17
+ *
18
+ * So the goal is not "maximise throughput", it is "every ACTIVE user gets a
19
+ * usable amount, every day". Those are different objectives and they favour
20
+ * different designs.
21
+ *
22
+ * ── Two ideas, and both are load-bearing ─────────────────────────────────────
23
+ *
24
+ * 1. SHARE — capacity / active users. Recomputed per request, so the split
25
+ * tracks reality instead of a number set once in a config file. Crucially
26
+ * "active" means users who actually drew today, not everyone registered:
27
+ * counting dormant accounts would ration a quiet day down to nothing and
28
+ * waste the budget that was meant to be generous. One user on a quiet day
29
+ * correctly gets the whole thing.
30
+ *
31
+ * 2. PACING — a share alone is not enough, because a share is a whole-DAY
32
+ * allowance and the day is consumed in order. Without pacing, three users
33
+ * can each legitimately spend their full share by 09:00 and the fourth to
34
+ * arrive finds the capacity gone even though nobody exceeded their split.
35
+ * So the allowance unlocks gradually: by mid-afternoon you may have spent
36
+ * about half your share, by end of day all of it. That is what actually
37
+ * keeps capacity available for whoever shows up later.
38
+ *
39
+ * `burst` exists so this does not become its own wall: with pure pacing, a user
40
+ * at one minute past midnight would have an allowance of nearly zero. A burst
41
+ * makes the first turns immediate, which is the difference between "paced" and
42
+ * "unusable".
43
+ *
44
+ * ── What it deliberately does NOT do ─────────────────────────────────────────
45
+ * No clawback. A user who spent under an older, larger share when they were the
46
+ * only one active is not punished when a second user appears — their allowance
47
+ * simply stops growing until the day catches up. Taking budget back from
48
+ * someone who already used it is impossible anyway (the tokens are spent) and
49
+ * pretending otherwise would only produce confusing refusals.
50
+ */
51
+
52
+ /** Seconds in a budget day. Provider quotas reset daily, so the day is the unit. */
53
+ export const DAY_SECONDS = 86_400;
54
+
55
+ /**
56
+ * Fraction of a user's share spendable immediately, before pacing has unlocked
57
+ * anything. Set so the first couple of turns never wait: the failure mode this
58
+ * guards against ("I typed one question at 9am and it refused me") is far worse
59
+ * than the one it risks (a slightly front-loaded day).
60
+ */
61
+ export const DEFAULT_BURST = 0.25;
62
+
63
+ export type ShareInput = {
64
+ /** Total tokens the free tiers grant for the whole day, summed across providers. */
65
+ dayCapacityTokens: number;
66
+ /**
67
+ * Distinct users drawing on the budget today, INCLUDING the one asking now.
68
+ * The caller must include the requester even on their first turn — otherwise
69
+ * a newcomer is rationed against a divisor that does not count them, and the
70
+ * day is briefly over-committed.
71
+ */
72
+ activeUsers: number;
73
+ /** What this user has already spent today. */
74
+ userSpentTokens: number;
75
+ /** Estimated cost of the turn being requested. */
76
+ costTokens: number;
77
+ /** How far through the budget day we are, 0..1. */
78
+ dayElapsed: number;
79
+ /** Fraction of a share usable immediately. Defaults to DEFAULT_BURST. */
80
+ burst?: number;
81
+ };
82
+
83
+ export type ShareReason =
84
+ /** Within the paced allowance. */
85
+ | "ok"
86
+ /** Within the day's share, but not yet unlocked — waiting helps. */
87
+ | "paced"
88
+ /** This user's whole share for today is committed — waiting does NOT help. */
89
+ | "share-spent"
90
+ /** There is no budget to divide at all. */
91
+ | "no-capacity";
92
+
93
+ export type ShareDecision = {
94
+ allowed: boolean;
95
+ /** Their whole-day share, before pacing. */
96
+ shareTokens: number;
97
+ /** What they may have spent BY NOW. */
98
+ allowanceTokens: number;
99
+ reason: ShareReason;
100
+ /**
101
+ * When it is worth asking again. Present ONLY for "paced", because that is
102
+ * the only refusal a wait actually fixes — telling someone whose share is
103
+ * spent to "try again in 20 minutes" is the same lie as telling a user whose
104
+ * daily quota is gone to "try again shortly".
105
+ */
106
+ retryAfterSeconds?: number;
107
+ };
108
+
109
+ /** Clamp to [0, 1]; a caller's clock skew must not produce a negative allowance. */
110
+ function clamp01(n: number): number {
111
+ if (!Number.isFinite(n)) return 0;
112
+ return Math.min(1, Math.max(0, n));
113
+ }
114
+
115
+ /**
116
+ * May this user spend `costTokens` right now?
117
+ *
118
+ * Recomputed per request rather than cached: `activeUsers` is the input most
119
+ * likely to change between two turns, and a stale divisor is exactly how a new
120
+ * user gets locked out of a budget that was supposed to include them.
121
+ */
122
+ export function fairShare(input: ShareInput): ShareDecision {
123
+ const capacity = Math.max(0, input.dayCapacityTokens);
124
+ // A user is always at least one user. Guarding here rather than trusting the
125
+ // caller keeps a bad count from becoming a division by zero at the worst
126
+ // possible moment.
127
+ const users = Math.max(1, Math.floor(input.activeUsers) || 1);
128
+ const spent = Math.max(0, input.userSpentTokens);
129
+ const cost = Math.max(0, input.costTokens);
130
+ const burst = clamp01(input.burst ?? DEFAULT_BURST);
131
+ const elapsed = clamp01(input.dayElapsed);
132
+
133
+ const shareTokens = capacity / users;
134
+ const pace = clamp01(elapsed + burst);
135
+ // A share that cannot buy a SINGLE turn is a wall wearing a ration's clothes.
136
+ // With four active users on a 100k day and a ~10k turn, pure pacing refuses
137
+ // the first question of the morning and tells the user to come back in three
138
+ // hours — which, for someone trying the product for the first time, is
139
+ // indistinguishable from broken. So the allowance never sits below the cost
140
+ // of one turn, capped by the share: everyone gets at least one, then pacing
141
+ // governs the rest. Capped by `shareTokens` so this floor can never hand out
142
+ // more than the user's actual split.
143
+ const oneTurn = Math.min(shareTokens, cost);
144
+ const allowanceTokens = Math.min(shareTokens, Math.max(shareTokens * pace, oneTurn));
145
+
146
+ if (capacity <= 0) {
147
+ return { allowed: false, shareTokens: 0, allowanceTokens: 0, reason: "no-capacity" };
148
+ }
149
+
150
+ const wanted = spent + cost;
151
+ if (wanted <= allowanceTokens) {
152
+ return { allowed: true, shareTokens, allowanceTokens, reason: "ok" };
153
+ }
154
+
155
+ // Past the whole-day share: no amount of waiting unlocks more today.
156
+ if (wanted > shareTokens) {
157
+ return { allowed: false, shareTokens, allowanceTokens, reason: "share-spent" };
158
+ }
159
+
160
+ // Within the share but ahead of the pace — the one case a wait fixes. Solve
161
+ // for the elapsed fraction at which the allowance covers `wanted`.
162
+ const neededPace = wanted / shareTokens;
163
+ const neededElapsed = neededPace - burst;
164
+ const retryAfterSeconds = Math.max(1, Math.ceil((neededElapsed - elapsed) * DAY_SECONDS));
165
+ return { allowed: false, shareTokens, allowanceTokens, reason: "paced", retryAfterSeconds };
166
+ }
167
+
168
+ /**
169
+ * How far through the UTC day `now` is, 0..1.
170
+ *
171
+ * UTC because that is what the providers meter on; deriving it from the
172
+ * operator's local midnight would drift the reset away from the vendor's and
173
+ * hand out budget that is not there.
174
+ */
175
+ export function utcDayElapsed(now: Date): number {
176
+ const ms = now.getTime() - Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
177
+ return clamp01(ms / (DAY_SECONDS * 1000));
178
+ }
179
+
180
+ /** The UTC day key (YYYY-MM-DD) a spend belongs to — the accounting bucket. */
181
+ export function utcDayKey(now: Date): string {
182
+ return now.toISOString().slice(0, 10);
183
+ }
package/src/forms.ts ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Form filling, re-exported from `ai-forms`.
3
+ *
4
+ * `ai-forms` is NOT absorbed. It stays its own package: it works, four apps run
5
+ * it, and it is a genuinely general-purpose thing that people outside this fleet
6
+ * can use. Swallowing it would break four repos and delete a good name off the
7
+ * registry to satisfy a filing system.
8
+ *
9
+ * What this subpath buys is that an app adding AI installs ONE thing. AOZ is
10
+ * the argument: it adopted `ai-forms`, then hand-rolled a provider layer and a
11
+ * chat loop, because those were two further decisions nobody made. Filling a
12
+ * form from prose and choosing which model fills it are the same feature to the
13
+ * app, so they should be one install.
14
+ */
15
+ export * from "ai-forms";