@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/limits.ts ADDED
@@ -0,0 +1,137 @@
1
+ /**
2
+ * 429 classification — pure, because the kinds of 429 need OPPOSITE responses
3
+ * and telling them apart is the whole trick.
4
+ *
5
+ * The patterns are calibrated on Groq's wording, because Groq is the vendor that
6
+ * states its limits precisely enough to learn from. They are applied to every
7
+ * OpenAI-compatible vendor, which is safe in the direction that matters: an
8
+ * unrecognisable body falls through to "capacity", whose response (retry, then
9
+ * degrade) is the harmless one to guess. A vendor that phrases a daily cap
10
+ * differently is therefore treated as a momentary one — costing a wasted retry,
11
+ * not a wrong answer. Add its wording here when you meet it.
12
+ *
13
+ * A provider returns 429 for three unrelated conditions:
14
+ *
15
+ * CAPACITY — "Rate limit reached ... on tokens per minute (TPM): Limit 12000,
16
+ * Used 11800, Requested 400. Please try again in 3.6s"
17
+ * The per-MINUTE window is momentarily spent. Waiting helps, and a
18
+ * smaller model has its own per-minute budget, so stepping down
19
+ * helps too.
20
+ *
21
+ * SIZE — "Request too large ... Limit 6000, Requested 15041, please
22
+ * reduce your message size"
23
+ * ONE request exceeds the entire per-minute allowance. Waiting can
24
+ * never help: the window never grows big enough. And stepping down
25
+ * makes it strictly WORSE, because the cheaper model has a smaller
26
+ * ceiling (verified 2026-08-14: llama-3.3-70b-versatile = 12000
27
+ * TPM, llama-3.1-8b-instant = 6000). The only cure is a smaller
28
+ * prompt.
29
+ *
30
+ * DAILY — "Rate limit reached ... on tokens per day (TPD): Limit 100000,
31
+ * Used 99331, Requested 4589. Please try again in 56m26.88s"
32
+ * The budget for the WHOLE DAY is gone, and it is shared across the
33
+ * org and across models. Every response that works for capacity is
34
+ * actively harmful here: stepping down draws on the SAME exhausted
35
+ * budget, and the bounded ~25s wait is nowhere near a reset
36
+ * measured in tens of minutes — it just adds dead latency to a turn
37
+ * that was already lost.
38
+ *
39
+ * Treating SIZE as CAPACITY is what broke Loki's tool loop for every question:
40
+ * an oversized prompt 429'd, the handler "helpfully" stepped down to the model
41
+ * with HALF the ceiling, waited 25 pointless seconds, and then gave up — so the
42
+ * loop fell back to a toolless path on every single turn.
43
+ *
44
+ * Treating DAILY as CAPACITY is the same mistake one level deeper, and it is why
45
+ * this file has three kinds rather than two: the distinction is not "which limit
46
+ * was hit" but "what, if anything, the caller can do about it".
47
+ */
48
+
49
+ export type RateLimitKind = "size" | "capacity" | "daily";
50
+
51
+ /**
52
+ * Which kind of 429 this is. Keyed on the body, because the status code alone
53
+ * cannot tell them apart — all three share the status, the `type`, and the
54
+ * `code`, and two of the three even share the phrase "tokens per minute (TPM)".
55
+ * The headers describe the window, not the request, so they cannot decide it
56
+ * either.
57
+ *
58
+ * Daily is tested FIRST: a TPD body also matches the "Rate limit reached"
59
+ * wording that means capacity, so the more specific limit has to win or the
60
+ * cheaper check silently absorbs it.
61
+ *
62
+ * Defaults to "capacity" when the body is unrecognisable: that path retries and
63
+ * degrades, where guessing "size" would shed context that was never the problem
64
+ * and guessing "daily" would give up on a turn that might well have succeeded.
65
+ */
66
+ export function classifyRateLimit(body: string): RateLimitKind {
67
+ // Requests-per-day is the same situation as tokens-per-day: nothing the caller
68
+ // does before the reset can help, so it gets the same treatment.
69
+ if (/per day|\bTPD\b|\bRPD\b/i.test(body)) return "daily";
70
+ return /request too large|reduce your message size|reduce the length/i.test(body)
71
+ ? "size"
72
+ : "capacity";
73
+ }
74
+
75
+ /**
76
+ * The wait the provider itself named, in seconds, or null when it named none.
77
+ *
78
+ * Worth parsing rather than approximating because the honest number is the whole
79
+ * difference between a message a user can act on and one that wastes their time:
80
+ * "try again shortly" invites an immediate retry, and on a daily cap that retry
81
+ * is guaranteed to fail for the next hour. Groq states the real figure — the
82
+ * only reason not to pass it on is not having read it.
83
+ *
84
+ * Handles the two shapes the API emits: "3.6s" and "56m26.88s".
85
+ */
86
+ export function retryAfterSeconds(body: string): number | null {
87
+ const m = /try again in\s+(?:(\d+(?:\.\d+)?)m)?(?:(\d+(?:\.\d+)?)s)?/i.exec(body);
88
+ if (!m || (!m[1] && !m[2])) return null;
89
+ return Math.ceil(Number(m[1] ?? 0) * 60 + Number(m[2] ?? 0));
90
+ }
91
+
92
+ /**
93
+ * That wait as something a person reads: "3s", "2 minutes", "about 1 hour".
94
+ *
95
+ * The minutes/hours boundary is 60, not 90. At 90 the singular branch below is
96
+ * unreachable — every value that got that far divided to at least 1.5 hours,
97
+ * which rounds to 2 — so "about 1 hour" could never be printed and a 90-minute
98
+ * wait was announced as "about 2 hours". Rounding a wait UP past the reset is
99
+ * the same disservice as rounding it down: both leave the reader guessing when
100
+ * to come back.
101
+ */
102
+ export function humanizeWait(seconds: number | null): string | null {
103
+ if (seconds === null || !Number.isFinite(seconds) || seconds <= 0) return null;
104
+ if (seconds < 90) return `${Math.ceil(seconds)}s`;
105
+ const minutes = Math.ceil(seconds / 60);
106
+ if (minutes < 60) return `${minutes} minutes`;
107
+ const hours = Math.round(minutes / 60);
108
+ return `about ${hours} hour${hours === 1 ? "" : "s"}`;
109
+ }
110
+
111
+ /**
112
+ * What to tell the operator when a 429 ends the turn.
113
+ *
114
+ * Lives beside the classifier so every caller says the same thing, and because
115
+ * the message is a direct consequence of the classification: the only useful
116
+ * content in a rate-limit error is whether retrying can work and when. "Try
117
+ * again shortly" on an exhausted DAY is the failure reported as "not working" —
118
+ * technically a rate limit, but it invites exactly the retry that is guaranteed
119
+ * to fail for the next hour.
120
+ *
121
+ * Returns a CLAUSE, not a sentence, because callers embed it in their own
122
+ * framing ("<assistant> is offline — ..."). It therefore neither capitalises nor
123
+ * names the assistant, which would read double.
124
+ */
125
+ export function rateLimitMessage(raw: string): string {
126
+ const wait = humanizeWait(retryAfterSeconds(raw));
127
+ switch (classifyRateLimit(raw)) {
128
+ case "daily":
129
+ return `the daily model quota is used up${wait ? ` (resets in ${wait})` : ""}`;
130
+ case "size":
131
+ // Retrying is not the fix and saying so prevents a pointless loop; the
132
+ // real repair (a smaller prompt) is ours to make, not the operator's.
133
+ return "the question needed more context than the model allows in one request";
134
+ case "capacity":
135
+ return `the model provider is rate-limited${wait ? ` (retry in ${wait})` : " — try again shortly"}`;
136
+ }
137
+ }
package/src/react.ts ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The React form hook, re-exported from `ai-forms/react`.
3
+ *
4
+ * Kept on its own subpath so importing `ai-kit` on a server never pulls React
5
+ * in. `react` is an OPTIONAL peer for exactly this reason: an app using only
6
+ * the provider chain should not be asked to install a UI library.
7
+ */
8
+ export * from "ai-forms/react";
@@ -0,0 +1,207 @@
1
+ /**
2
+ * The model REGISTRY — one SSOT for every model id an app may call.
3
+ *
4
+ * This module exists because the fleet paid for its absence twice, in two
5
+ * different currencies:
6
+ *
7
+ * OUTAGE — on 2026-08-18 Groq removed `llama-3.3-70b-versatile` and one app
8
+ * kept asking for it for eight days. A rot checker already existed, but it
9
+ * probed only the chains it knew about; the id that died was pinned
10
+ * elsewhere. A checker that does not enumerate its subjects cannot report
11
+ * the one it never knew about. The registry IS the enumeration: a model id
12
+ * is callable only if it appears here, and the catalog check walks exactly
13
+ * this list.
14
+ *
15
+ * MONEY — three apps silently billed real money on fallback, because the
16
+ * only thing separating the free variant from the paid one was a `:free`
17
+ * suffix on the id string. A billing boundary that lives in a naming
18
+ * convention is one typo away from a paid call. Here it is a FIELD, and the
19
+ * validator refuses an entry whose flag contradicts its own cost or suffix —
20
+ * so the contradiction is a build failure, not an invoice.
21
+ *
22
+ * What deliberately does NOT live here: which model to PREFER (that is the
23
+ * chain's job), UI presentation (labels, badges — app concern), and anything
24
+ * that knows where data lives. Same boundary as the rest of this package:
25
+ * meaning in core, adapters in the app.
26
+ *
27
+ * ── Vendor vs author ─────────────────────────────────────────────────────────
28
+ * A registry row is a CALLABLE id at a VENDOR — the place a request goes —
29
+ * because that is the unit that rots, meters, and bills. The AUTHOR (who
30
+ * trained it) is metadata. The two were conflated in one app's registry
31
+ * ("provider: Anthropic" on a row served by OpenRouter), which made "who do we
32
+ * pay" unanswerable by query. Here they are separate fields.
33
+ */
34
+
35
+ /**
36
+ * How a model answered a live tool-call probe. "unprobed" is a real value, not
37
+ * a default to ignore: of nine free models probed for the default chain, FIVE
38
+ * answered only via a text protocol — not guessable from name, size, or docs.
39
+ * A loop that needs tools should refuse "none" and treat "unprobed" as a
40
+ * to-do, never as "probably native".
41
+ */
42
+ export type ToolProtocol = "native" | "text" | "none" | "unprobed";
43
+
44
+ export type ModelTier = "free" | "economy" | "standard" | "premium";
45
+
46
+ export type ModelCapability =
47
+ "text" | "vision" | "function_calling" | "json_mode" | "streaming" | "transcribe";
48
+
49
+ export type ModelEntry = {
50
+ /** The id sent on the wire — exactly as the vendor expects it. */
51
+ id: string;
52
+ /** Where the call goes (groq, openrouter, together, ollama, …). */
53
+ vendor: string;
54
+ /** Who trained it (Anthropic, Meta, Moonshot, …) — metadata, never routing. */
55
+ author?: string;
56
+ /** Display name for pickers. Optional: an engine-only entry needs none. */
57
+ name?: string;
58
+ /**
59
+ * THE billing boundary. Required, no default: making the author write
60
+ * `paid: false` is the whole point — a forgotten field must fail the build,
61
+ * not silently ride a naming convention.
62
+ */
63
+ paid: boolean;
64
+ /** USD per 1M tokens. Free entries may omit (treated as 0). */
65
+ inputCostPer1M?: number;
66
+ outputCostPer1M?: number;
67
+ contextWindow?: number;
68
+ maxOutputTokens?: number;
69
+ tier?: ModelTier;
70
+ capabilities?: ModelCapability[];
71
+ /** Verdict of a live tool-call probe. Absent = "unprobed". */
72
+ toolProtocol?: ToolProtocol;
73
+ /**
74
+ * Whether the model accepts a non-default `temperature`. Absent = true.
75
+ * Current Anthropic frontier models reject non-default sampling params, so
76
+ * callers must omit the param for entries that say false.
77
+ */
78
+ supportsTemperature?: boolean;
79
+ /**
80
+ * What breaks when this id stops existing — the text a rot report shows.
81
+ * Borrowed from the eight-day outage: the fastest diagnosis is the registry
82
+ * row saying which feature just died.
83
+ */
84
+ usedFor?: string;
85
+ /** Which endpoint shape this id is called on. Default "chat". */
86
+ kind?: "chat" | "transcribe";
87
+ };
88
+
89
+ export type Registry = {
90
+ entries: readonly ModelEntry[];
91
+ /** Lookup by wire id (optionally scoped to a vendor when ids collide). */
92
+ find(id: string, vendor?: string): ModelEntry | undefined;
93
+ /**
94
+ * The entry, or a THROW naming what depends on it. "A model id is callable
95
+ * only if it appears here" is only true if the miss is loud.
96
+ */
97
+ require(id: string, vendor?: string): ModelEntry;
98
+ /** Every wire id at one vendor — the enumeration a catalog check walks. */
99
+ idsForVendor(vendor: string): string[];
100
+ vendors(): string[];
101
+ /** Entries the free tier may serve. The platform-key guard filters on THIS. */
102
+ freeEntries(): ModelEntry[];
103
+ /** Entries only reachable through someone's money (credits or BYOK). */
104
+ paidEntries(): ModelEntry[];
105
+ };
106
+
107
+ /** A `:free`-suffixed id claiming to be paid, or a "free" entry with a price —
108
+ * each one is the 2026 billing incident waiting to recur. */
109
+ function validateEntry(e: ModelEntry): string | null {
110
+ if (!e.id.trim()) return "entry has an empty id";
111
+ if (!e.vendor.trim()) return `"${e.id}": empty vendor`;
112
+ const cost = (e.inputCostPer1M ?? 0) + (e.outputCostPer1M ?? 0);
113
+ if (!e.paid && cost > 0) {
114
+ return `"${e.id}": declared free but carries a cost (${cost}/1M) — the flag or the price is lying`;
115
+ }
116
+ if (e.paid && e.id.endsWith(":free")) {
117
+ return `"${e.id}": declared paid but the id says :free — the flag or the id is lying`;
118
+ }
119
+ return null;
120
+ }
121
+
122
+ /**
123
+ * Build a registry from entries. Throws on the first contradiction — a
124
+ * registry that loads is a registry whose billing boundary can be trusted.
125
+ */
126
+ export function defineRegistry(entries: ModelEntry[]): Registry {
127
+ const seen = new Set<string>();
128
+ for (const e of entries) {
129
+ const problem = validateEntry(e);
130
+ if (problem) throw new Error(`ai-kit registry: ${problem}`);
131
+ const key = `${e.vendor}:${e.id}`;
132
+ if (seen.has(key)) {
133
+ throw new Error(
134
+ `ai-kit registry: duplicate entry ${key} — two rows for one callable id is two sources of truth`,
135
+ );
136
+ }
137
+ seen.add(key);
138
+ }
139
+ const frozen: readonly ModelEntry[] = Object.freeze(entries.map((e) => ({ ...e })));
140
+
141
+ const find = (id: string, vendor?: string): ModelEntry | undefined =>
142
+ frozen.find((e) => e.id === id && (vendor === undefined || e.vendor === vendor));
143
+
144
+ return {
145
+ entries: frozen,
146
+ find,
147
+ require(id: string, vendor?: string): ModelEntry {
148
+ const hit = find(id, vendor);
149
+ if (!hit) {
150
+ const scope = vendor ? ` at ${vendor}` : "";
151
+ throw new Error(
152
+ `ai-kit registry: "${id}"${scope} is not registered — a model id is callable only if it appears in the registry (add it with its paid flag, or stop calling it)`,
153
+ );
154
+ }
155
+ return hit;
156
+ },
157
+ idsForVendor: (vendor: string) => frozen.filter((e) => e.vendor === vendor).map((e) => e.id),
158
+ vendors: () => [...new Set(frozen.map((e) => e.vendor))],
159
+ freeEntries: () => frozen.filter((e) => !e.paid),
160
+ paidEntries: () => frozen.filter((e) => e.paid),
161
+ };
162
+ }
163
+
164
+ /**
165
+ * The platform-key guard: the ids from `requested` that a platform-funded
166
+ * call may serve. Registered-and-free passes; paid is dropped; an UNKNOWN id
167
+ * is dropped too — an id nobody registered has an unknown price, and "unknown"
168
+ * spends someone's money only when a person decides it does.
169
+ *
170
+ * Returns the dropped ids alongside, because a silently narrowed chain reads
171
+ * as "covered everything" when it didn't.
172
+ */
173
+ export function freeOnly(
174
+ registry: Registry,
175
+ requested: string[],
176
+ ): { allowed: string[]; dropped: { id: string; why: "paid" | "unregistered" }[] } {
177
+ const allowed: string[] = [];
178
+ const dropped: { id: string; why: "paid" | "unregistered" }[] = [];
179
+ for (const id of requested) {
180
+ const entry = registry.find(id);
181
+ if (!entry) dropped.push({ id, why: "unregistered" });
182
+ else if (entry.paid) dropped.push({ id, why: "paid" });
183
+ else allowed.push(id);
184
+ }
185
+ return { allowed, dropped };
186
+ }
187
+
188
+ /**
189
+ * A tool-driving chain may only contain models that can drive a tool loop.
190
+ * "unprobed" entries are reported, not silently trusted — the probe table is
191
+ * one `npm run probe:models` away, and a chain built on guesses loses turns
192
+ * exactly on the models most likely to serve free traffic.
193
+ */
194
+ export function toolCapable(
195
+ registry: Registry,
196
+ requested: string[],
197
+ ): { usable: string[]; refused: { id: string; protocol: ToolProtocol }[] } {
198
+ const usable: string[] = [];
199
+ const refused: { id: string; protocol: ToolProtocol }[] = [];
200
+ for (const id of requested) {
201
+ const entry = registry.find(id);
202
+ const protocol: ToolProtocol = entry?.toolProtocol ?? "unprobed";
203
+ if (protocol === "native" || protocol === "text") usable.push(id);
204
+ else refused.push({ id, protocol });
205
+ }
206
+ return { usable, refused };
207
+ }
package/src/server.ts ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The form-assist route factory, re-exported from `ai-forms/server`.
3
+ *
4
+ * This is the piece that explains the fleet's adoption numbers. `ai-forms` is
5
+ * the most-adopted shared package here, and it is also the only one that ships
6
+ * real machinery rather than decisions alone — AOZ imports this factory and the
7
+ * React hook, and nothing else. A package that hands you a working route gets
8
+ * installed; one that hands you advice about routes does not.
9
+ */
10
+ export * from "ai-forms/server";