@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/dist/chain.d.ts
ADDED
|
@@ -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;
|