@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
@@ -0,0 +1,204 @@
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
+ /** A vendor, its endpoint, and the free models worth trying on it, in order. */
36
+ export type Provider = {
37
+ /** Display/debug name; also the prefix reported back as the model id. */
38
+ id: string;
39
+ baseUrl: string;
40
+ /** Env var holding the API key. Absent key = entry skipped, not an error. */
41
+ keyEnv: string;
42
+ /** Models to try for this provider, in order. */
43
+ models: string[];
44
+ /**
45
+ * Tokens this vendor's FREE tier grants per day, summed into the pool that
46
+ * fair-share rations. An ESTIMATE unless the vendor states it: handing out
47
+ * shares of capacity that turns out not to exist produces the exact wall the
48
+ * rationing exists to prevent, only later in the day and harder to diagnose.
49
+ * So estimate LOW.
50
+ */
51
+ dailyTokens: number;
52
+ /**
53
+ * Env var that REPLACES `models` when set (comma/space separated).
54
+ * Read at CALL time, not at import: the point of this override is routing
55
+ * around a model that rotted, and a value frozen at module load would need a
56
+ * redeploy to take effect — which is exactly the delay it exists to avoid.
57
+ */
58
+ modelsEnv?: string;
59
+ /** Env var overriding `dailyTokens` at call time. */
60
+ dailyTokensEnv?: string;
61
+ /**
62
+ * Does this vendor use ROUTED ids, where `vendor/model` names weights it
63
+ * resells and a `:free` suffix is the difference between free routing and a
64
+ * per-call charge? True for OpenRouter.
65
+ *
66
+ * It matters because the same STRING means different things at different
67
+ * vendors. `openai/gpt-oss-20b` bills at OpenRouter (no `:free`), while at
68
+ * Groq it is simply that vendor's name for a model whose cost depends on the
69
+ * account tier. Deciding cost from the id alone was safe only while
70
+ * non-routed vendors used bare ids like `llama-3.1-8b-instant`; Groq now
71
+ * ships vendor-prefixed ids, so the shape no longer identifies the vendor.
72
+ *
73
+ * Defaults to false: claiming an id is routed when it is not would report a
74
+ * free model as paid, and the reverse — assuming free — is the direction
75
+ * this module exists to refuse.
76
+ */
77
+ routed?: boolean;
78
+ };
79
+ export type Env = Record<string, string | undefined>;
80
+ /** One attempt: a model at a provider. */
81
+ export type Link = {
82
+ provider: Provider;
83
+ model: string;
84
+ };
85
+ /** This provider's models, honouring its env override. */
86
+ export declare function providerModels(provider: Provider, env?: Env): string[];
87
+ /**
88
+ * Build a provider row whose env var names follow one prefix.
89
+ *
90
+ * Saves each app from inventing its own naming and then documenting it: with
91
+ * prefix "LOKI" a provider `groq` reads LOKI_GROQ_MODELS and
92
+ * LOKI_GROQ_DAILY_TOKENS. The key env stays explicit because it is usually the
93
+ * vendor's conventional name (GROQ_API_KEY), shared with other tools.
94
+ */
95
+ export declare function withEnvPrefix(prefix: string, provider: Omit<Provider, "modelsEnv" | "dailyTokensEnv">): Provider;
96
+ /**
97
+ * A default chain of FREE models, every entry probed live on 2026-08-15 with a
98
+ * real tool call. Protocol each answered on:
99
+ *
100
+ * groq/llama-3.3-70b-versatile native
101
+ * groq/llama-3.1-8b-instant text
102
+ * openai/gpt-oss-20b:free native
103
+ * nvidia/nemotron-3-super-120b-a12b:free native
104
+ * nvidia/nemotron-3.5-lightning:free native
105
+ * google/gemma-4-26b-a4b-it:free text
106
+ * nvidia/nemotron-3-nano-30b-a3b:free text
107
+ * cohere/north-mini-code:free text
108
+ * openrouter/free text
109
+ *
110
+ * Deliberately excluded, both verified rather than assumed:
111
+ * google/gemma-4-31b-it:free — "Provider returned error" on probe
112
+ * nvidia/nemotron-nano-12b-v2-vl — returns HTTP 200 with EMPTY content, which
113
+ * a naive client reads as a successful
114
+ * empty answer
115
+ *
116
+ * `openrouter/free` sits last on purpose: it is an auto-router across the free
117
+ * catalogue, so it keeps working when a specific id above it is retired. That
118
+ * makes it the link most likely to survive the next rot, and the least
119
+ * predictable in quality — exactly the right shape for a last resort.
120
+ *
121
+ * NOTE the shelf life. This list is evidence from one day, not a constant; free
122
+ * catalogues rot. Treat it as a starting point and re-probe.
123
+ */
124
+ export declare function freeChain(prefix?: string): Provider[];
125
+ /** What a model id tells us about who pays. */
126
+ export type CostVerdict = "free" | "paid" | "unknown";
127
+ /**
128
+ * Does this model id cost money?
129
+ *
130
+ * Exists because the same mistake was found in THREE separate apps on one day,
131
+ * each a fallback that silently began spending when the free tier ran dry:
132
+ *
133
+ * anthropic/claude-sonnet-5 a premium model as the fallback
134
+ * google/gemini-2.0-flash-001 the paid twin of a `:free` id
135
+ * meta-llama/llama-3.3-70b-instruct reads free; bills at 1e-7/token,
136
+ * and its `:free` sibling has been
137
+ * retired from the catalogue
138
+ *
139
+ * The decidable rule is narrow and stated as such. A routed id (`vendor/model`,
140
+ * the OpenRouter shape) is FREE only with the `:free` suffix, and PAID without
141
+ * it — that suffix is the entire difference between free routing and a per-call
142
+ * charge for the same weights. A bare id (`llama-3.1-8b-instant`) says nothing:
143
+ * whether it costs depends on the account's tier at that vendor, which no string
144
+ * can answer, so it returns "unknown" rather than guessing.
145
+ *
146
+ * Guessing "free" there would be the dangerous direction — it is what let three
147
+ * of these through code review.
148
+ *
149
+ * IMPORTANT: this reads the id as a ROUTED (OpenRouter-shape) id, because that
150
+ * is the only shape where the string decides. It is therefore wrong to apply to
151
+ * an id from a vendor that merely happens to prefix its own models — Groq's
152
+ * `openai/gpt-oss-120b` is not a routed OpenAI id, and this function would call
153
+ * it paid. When you know the provider, use `modelCostAt`; `paidModelsIn` does.
154
+ */
155
+ export declare function modelCost(id: string): CostVerdict;
156
+ /**
157
+ * Cost of a model AT a specific provider — the honest signature, because the
158
+ * same id answers differently at different vendors (see `Provider.routed`).
159
+ *
160
+ * At a non-routed vendor the id carries no cost information at all: what you
161
+ * pay is the account's tier there, which no string can report. That is the
162
+ * same "unknown" a bare id has always returned, now correct for vendor-prefixed
163
+ * ids too.
164
+ */
165
+ export declare function modelCostAt(provider: Provider, model: string): CostVerdict;
166
+ /**
167
+ * Assert every model in a chain is free, for apps that must never bill.
168
+ *
169
+ * Judges each id AT ITS PROVIDER. Flagging Groq's `openai/gpt-oss-120b` as paid
170
+ * because it contains a slash would be a false alarm that pressures someone
171
+ * into "fixing" a working free model — and a guard that cries wolf gets
172
+ * disabled, taking the three real cases it does catch with it.
173
+ *
174
+ * Returns the offending ids rather than throwing: the caller knows whether a
175
+ * paid link is a bug or a deliberate, opted-in upgrade, and a library that
176
+ * throws on the second case forces people to route around it.
177
+ */
178
+ export declare function paidModelsIn(chain: Provider[]): string[];
179
+ /**
180
+ * The day's total budget: every provider we hold a key for.
181
+ *
182
+ * Only KEYED providers count. A vendor whose key is absent contributes nothing
183
+ * however generous its tier, and counting it would ration users against capacity
184
+ * that cannot be reached — the same failure as an optimistic estimate, just with
185
+ * an obvious cause.
186
+ */
187
+ export declare function dayCapacityTokens(chain: Provider[], env?: Env): number;
188
+ /**
189
+ * The chain with unusable entries removed: no API key, or no models configured.
190
+ *
191
+ * A missing key is a normal deployment state — most boxes carry one vendor's
192
+ * key, not every vendor's — so it filters out silently rather than throwing.
193
+ */
194
+ export declare function usableChain(chain: Provider[], env?: Env): Link[];
195
+ /**
196
+ * The chain starting at `model`, or the whole chain when it names no link.
197
+ *
198
+ * Apps commonly carry a "use this model" env var. Honouring it as a STARTING
199
+ * POINT rather than a hard pin keeps that escape hatch while refusing to
200
+ * reintroduce the single point of failure this module exists to remove: an
201
+ * operator pinning a model should still get a fallback when that model's vendor
202
+ * runs dry.
203
+ */
204
+ export declare function chainFrom(model: string | undefined, chain: Link[]): Link[];
package/dist/chain.js ADDED
@@ -0,0 +1,261 @@
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
+ function readEnv(env, name) {
36
+ if (!name)
37
+ return undefined;
38
+ return env[name]?.trim() || undefined;
39
+ }
40
+ /** Split a comma/space separated env override into model ids. */
41
+ function modelsFromEnv(env, name) {
42
+ const raw = readEnv(env, name);
43
+ if (!raw)
44
+ return null;
45
+ const models = raw.split(/[\s,]+/).filter(Boolean);
46
+ return models.length > 0 ? models : null;
47
+ }
48
+ /** This provider's models, honouring its env override. */
49
+ export function providerModels(provider, env = process.env) {
50
+ return modelsFromEnv(env, provider.modelsEnv) ?? provider.models;
51
+ }
52
+ /**
53
+ * Build a provider row whose env var names follow one prefix.
54
+ *
55
+ * Saves each app from inventing its own naming and then documenting it: with
56
+ * prefix "LOKI" a provider `groq` reads LOKI_GROQ_MODELS and
57
+ * LOKI_GROQ_DAILY_TOKENS. The key env stays explicit because it is usually the
58
+ * vendor's conventional name (GROQ_API_KEY), shared with other tools.
59
+ */
60
+ export function withEnvPrefix(prefix, provider) {
61
+ const slug = provider.id.toUpperCase().replace(/[^A-Z0-9]+/g, "_");
62
+ return {
63
+ ...provider,
64
+ modelsEnv: `${prefix}_${slug}_MODELS`,
65
+ dailyTokensEnv: `${prefix}_${slug}_DAILY_TOKENS`,
66
+ };
67
+ }
68
+ /**
69
+ * A default chain of FREE models, every entry probed live on 2026-08-15 with a
70
+ * real tool call. Protocol each answered on:
71
+ *
72
+ * groq/llama-3.3-70b-versatile native
73
+ * groq/llama-3.1-8b-instant text
74
+ * openai/gpt-oss-20b:free native
75
+ * nvidia/nemotron-3-super-120b-a12b:free native
76
+ * nvidia/nemotron-3.5-lightning:free native
77
+ * google/gemma-4-26b-a4b-it:free text
78
+ * nvidia/nemotron-3-nano-30b-a3b:free text
79
+ * cohere/north-mini-code:free text
80
+ * openrouter/free text
81
+ *
82
+ * Deliberately excluded, both verified rather than assumed:
83
+ * google/gemma-4-31b-it:free — "Provider returned error" on probe
84
+ * nvidia/nemotron-nano-12b-v2-vl — returns HTTP 200 with EMPTY content, which
85
+ * a naive client reads as a successful
86
+ * empty answer
87
+ *
88
+ * `openrouter/free` sits last on purpose: it is an auto-router across the free
89
+ * catalogue, so it keeps working when a specific id above it is retired. That
90
+ * makes it the link most likely to survive the next rot, and the least
91
+ * predictable in quality — exactly the right shape for a last resort.
92
+ *
93
+ * NOTE the shelf life. This list is evidence from one day, not a constant; free
94
+ * catalogues rot. Treat it as a starting point and re-probe.
95
+ */
96
+ export function freeChain(prefix = "AI") {
97
+ return [
98
+ withEnvPrefix(prefix, {
99
+ id: "groq",
100
+ baseUrl: "https://api.groq.com/openai/v1",
101
+ keyEnv: "GROQ_API_KEY",
102
+ // Re-probed 2026-08-25 against the live catalog. The previous pins,
103
+ // `llama-3.3-70b-versatile` and `llama-3.1-8b-instant`, were BOTH gone —
104
+ // so this "fallback chain" led with a fully dead vendor and every caller
105
+ // paid two 404s before reaching OpenRouter. FleetCrown, whose direct
106
+ // (non-chain) calls used the same id and had no fallback at all, was
107
+ // silently down for eight days. Both ids below answered with a correct
108
+ // native tool_call when probed, which is the bar this list is held to.
109
+ models: ["openai/gpt-oss-120b", "openai/gpt-oss-20b"],
110
+ // Not a guess: Groq's own TPD refusal names it — "on tokens per day
111
+ // (TPD): Limit 100000". Org-wide, so every feature sharing the key draws
112
+ // from this same pool.
113
+ dailyTokens: 100_000,
114
+ }),
115
+ withEnvPrefix(prefix, {
116
+ id: "openrouter",
117
+ baseUrl: "https://openrouter.ai/api/v1",
118
+ keyEnv: "OPENROUTER_API_KEY",
119
+ // Routed ids: `:free` is the whole difference between free routing and a
120
+ // per-call charge for the same weights. See Provider.routed.
121
+ routed: true,
122
+ // Re-checked 2026-08-25 against the 419-model live catalog. Two entries
123
+ // were retired and are removed here: `openai/gpt-oss-20b:free` — which
124
+ // was FIRST, so the preferred fallback 404'd on every call — and
125
+ // `nvidia/nemotron-3-nano-30b-a3b:free`. The five below were present.
126
+ models: [
127
+ "nvidia/nemotron-3-super-120b-a12b:free",
128
+ "nvidia/nemotron-3.5-lightning:free",
129
+ "google/gemma-4-26b-a4b-it:free",
130
+ "cohere/north-mini-code:free",
131
+ "openrouter/free",
132
+ ],
133
+ // OpenRouter meters its free tier in REQUESTS per day, not tokens, and the
134
+ // cap depends on the account's credit balance — so this is a translation,
135
+ // not a published figure. Set at the low end on purpose.
136
+ dailyTokens: 100_000,
137
+ }),
138
+ ];
139
+ }
140
+ /**
141
+ * Does this model id cost money?
142
+ *
143
+ * Exists because the same mistake was found in THREE separate apps on one day,
144
+ * each a fallback that silently began spending when the free tier ran dry:
145
+ *
146
+ * anthropic/claude-sonnet-5 a premium model as the fallback
147
+ * google/gemini-2.0-flash-001 the paid twin of a `:free` id
148
+ * meta-llama/llama-3.3-70b-instruct reads free; bills at 1e-7/token,
149
+ * and its `:free` sibling has been
150
+ * retired from the catalogue
151
+ *
152
+ * The decidable rule is narrow and stated as such. A routed id (`vendor/model`,
153
+ * the OpenRouter shape) is FREE only with the `:free` suffix, and PAID without
154
+ * it — that suffix is the entire difference between free routing and a per-call
155
+ * charge for the same weights. A bare id (`llama-3.1-8b-instant`) says nothing:
156
+ * whether it costs depends on the account's tier at that vendor, which no string
157
+ * can answer, so it returns "unknown" rather than guessing.
158
+ *
159
+ * Guessing "free" there would be the dangerous direction — it is what let three
160
+ * of these through code review.
161
+ *
162
+ * IMPORTANT: this reads the id as a ROUTED (OpenRouter-shape) id, because that
163
+ * is the only shape where the string decides. It is therefore wrong to apply to
164
+ * an id from a vendor that merely happens to prefix its own models — Groq's
165
+ * `openai/gpt-oss-120b` is not a routed OpenAI id, and this function would call
166
+ * it paid. When you know the provider, use `modelCostAt`; `paidModelsIn` does.
167
+ */
168
+ export function modelCost(id) {
169
+ const model = id.trim();
170
+ if (!model)
171
+ return "unknown";
172
+ // OpenRouter's auto-router across the free catalogue.
173
+ if (model === "openrouter/free")
174
+ return "free";
175
+ if (!model.includes("/"))
176
+ return "unknown";
177
+ return model.endsWith(":free") ? "free" : "paid";
178
+ }
179
+ /**
180
+ * Cost of a model AT a specific provider — the honest signature, because the
181
+ * same id answers differently at different vendors (see `Provider.routed`).
182
+ *
183
+ * At a non-routed vendor the id carries no cost information at all: what you
184
+ * pay is the account's tier there, which no string can report. That is the
185
+ * same "unknown" a bare id has always returned, now correct for vendor-prefixed
186
+ * ids too.
187
+ */
188
+ export function modelCostAt(provider, model) {
189
+ return provider.routed ? modelCost(model) : "unknown";
190
+ }
191
+ /**
192
+ * Assert every model in a chain is free, for apps that must never bill.
193
+ *
194
+ * Judges each id AT ITS PROVIDER. Flagging Groq's `openai/gpt-oss-120b` as paid
195
+ * because it contains a slash would be a false alarm that pressures someone
196
+ * into "fixing" a working free model — and a guard that cries wolf gets
197
+ * disabled, taking the three real cases it does catch with it.
198
+ *
199
+ * Returns the offending ids rather than throwing: the caller knows whether a
200
+ * paid link is a bug or a deliberate, opted-in upgrade, and a library that
201
+ * throws on the second case forces people to route around it.
202
+ */
203
+ export function paidModelsIn(chain) {
204
+ return chain.flatMap((p) => p.models.filter((m) => modelCostAt(p, m) === "paid"));
205
+ }
206
+ /**
207
+ * The day's total budget: every provider we hold a key for.
208
+ *
209
+ * Only KEYED providers count. A vendor whose key is absent contributes nothing
210
+ * however generous its tier, and counting it would ration users against capacity
211
+ * that cannot be reached — the same failure as an optimistic estimate, just with
212
+ * an obvious cause.
213
+ */
214
+ export function dayCapacityTokens(chain, env = process.env) {
215
+ let total = 0;
216
+ for (const provider of chain) {
217
+ if (!readEnv(env, provider.keyEnv))
218
+ continue;
219
+ const override = Number(readEnv(env, provider.dailyTokensEnv));
220
+ total += Number.isFinite(override) && override >= 0 ? override : provider.dailyTokens;
221
+ }
222
+ return total;
223
+ }
224
+ /**
225
+ * The chain with unusable entries removed: no API key, or no models configured.
226
+ *
227
+ * A missing key is a normal deployment state — most boxes carry one vendor's
228
+ * key, not every vendor's — so it filters out silently rather than throwing.
229
+ */
230
+ export function usableChain(chain, env = process.env) {
231
+ const out = [];
232
+ for (const provider of chain) {
233
+ if (!readEnv(env, provider.keyEnv))
234
+ continue;
235
+ for (const model of providerModels(provider, env))
236
+ out.push({ provider, model });
237
+ }
238
+ return out;
239
+ }
240
+ /**
241
+ * The chain starting at `model`, or the whole chain when it names no link.
242
+ *
243
+ * Apps commonly carry a "use this model" env var. Honouring it as a STARTING
244
+ * POINT rather than a hard pin keeps that escape hatch while refusing to
245
+ * reintroduce the single point of failure this module exists to remove: an
246
+ * operator pinning a model should still get a fallback when that model's vendor
247
+ * runs dry.
248
+ */
249
+ export function chainFrom(model, chain) {
250
+ const wanted = model?.trim();
251
+ if (!wanted)
252
+ return chain;
253
+ const at = chain.findIndex((l) => l.model === wanted);
254
+ if (at >= 0)
255
+ return chain.slice(at);
256
+ // A model nobody advertises is still a legitimate request (a private
257
+ // deployment, a just-released id). Try it against the first provider that has
258
+ // a key, then fall through to the ordinary chain rather than dead-ending.
259
+ const host = chain[0];
260
+ return host ? [{ provider: host.provider, model: wanted }, ...chain] : [];
261
+ }
@@ -0,0 +1,120 @@
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
+ /** Seconds in a budget day. Provider quotas reset daily, so the day is the unit. */
52
+ export declare const DAY_SECONDS = 86400;
53
+ /**
54
+ * Fraction of a user's share spendable immediately, before pacing has unlocked
55
+ * anything. Set so the first couple of turns never wait: the failure mode this
56
+ * guards against ("I typed one question at 9am and it refused me") is far worse
57
+ * than the one it risks (a slightly front-loaded day).
58
+ */
59
+ export declare const DEFAULT_BURST = 0.25;
60
+ export type ShareInput = {
61
+ /** Total tokens the free tiers grant for the whole day, summed across providers. */
62
+ dayCapacityTokens: number;
63
+ /**
64
+ * Distinct users drawing on the budget today, INCLUDING the one asking now.
65
+ * The caller must include the requester even on their first turn — otherwise
66
+ * a newcomer is rationed against a divisor that does not count them, and the
67
+ * day is briefly over-committed.
68
+ */
69
+ activeUsers: number;
70
+ /** What this user has already spent today. */
71
+ userSpentTokens: number;
72
+ /** Estimated cost of the turn being requested. */
73
+ costTokens: number;
74
+ /** How far through the budget day we are, 0..1. */
75
+ dayElapsed: number;
76
+ /** Fraction of a share usable immediately. Defaults to DEFAULT_BURST. */
77
+ burst?: number;
78
+ };
79
+ export type ShareReason =
80
+ /** Within the paced allowance. */
81
+ "ok"
82
+ /** Within the day's share, but not yet unlocked — waiting helps. */
83
+ | "paced"
84
+ /** This user's whole share for today is committed — waiting does NOT help. */
85
+ | "share-spent"
86
+ /** There is no budget to divide at all. */
87
+ | "no-capacity";
88
+ export type ShareDecision = {
89
+ allowed: boolean;
90
+ /** Their whole-day share, before pacing. */
91
+ shareTokens: number;
92
+ /** What they may have spent BY NOW. */
93
+ allowanceTokens: number;
94
+ reason: ShareReason;
95
+ /**
96
+ * When it is worth asking again. Present ONLY for "paced", because that is
97
+ * the only refusal a wait actually fixes — telling someone whose share is
98
+ * spent to "try again in 20 minutes" is the same lie as telling a user whose
99
+ * daily quota is gone to "try again shortly".
100
+ */
101
+ retryAfterSeconds?: number;
102
+ };
103
+ /**
104
+ * May this user spend `costTokens` right now?
105
+ *
106
+ * Recomputed per request rather than cached: `activeUsers` is the input most
107
+ * likely to change between two turns, and a stale divisor is exactly how a new
108
+ * user gets locked out of a budget that was supposed to include them.
109
+ */
110
+ export declare function fairShare(input: ShareInput): ShareDecision;
111
+ /**
112
+ * How far through the UTC day `now` is, 0..1.
113
+ *
114
+ * UTC because that is what the providers meter on; deriving it from the
115
+ * operator's local midnight would drift the reset away from the vendor's and
116
+ * hand out budget that is not there.
117
+ */
118
+ export declare function utcDayElapsed(now: Date): number;
119
+ /** The UTC day key (YYYY-MM-DD) a spend belongs to — the accounting bucket. */
120
+ export declare function utcDayKey(now: Date): string;