@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/index.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ai-kit — one install for the AI layer of an app.
|
|
3
|
+
*
|
|
4
|
+
* WHAT IT IS FOR
|
|
5
|
+
* --------------
|
|
6
|
+
* An app that wants an AI feature needs several unrelated-looking decisions to
|
|
7
|
+
* go right, and getting any one wrong looks identical from the outside: the
|
|
8
|
+
* assistant is broken. This package holds all of them, so adding AI is one
|
|
9
|
+
* decision instead of many.
|
|
10
|
+
*
|
|
11
|
+
* which model — a fallback list ACROSS VENDORS, because a single pinned free
|
|
12
|
+
* model is a scheduled outage, and a smaller model at the same
|
|
13
|
+
* vendor draws on the same exhausted daily budget.
|
|
14
|
+
* still there? — has the vendor retired an id we still ask for? The list is
|
|
15
|
+
* itself a list of pins, so it rots too. Zero tokens, so it can
|
|
16
|
+
* run on a schedule instead of being remembered.
|
|
17
|
+
* walk it — a chain nobody walks is a list, not a fallback. `tryChain`
|
|
18
|
+
* tries each link and stops at the first success; a
|
|
19
|
+
* `HealthTracker` records whether the WHOLE chain came back
|
|
20
|
+
* empty, so a health route can say so before a user does.
|
|
21
|
+
* too fast? — tell the three kinds of 429 apart. They share a status code
|
|
22
|
+
* and need opposite responses; only the body distinguishes them.
|
|
23
|
+
* who gets it — divide a fixed daily pool across active users, so the person
|
|
24
|
+
* who arrives at 4pm still gets a turn.
|
|
25
|
+
* filling forms— fill a form from prose and keep talking to it, re-exported
|
|
26
|
+
* from `ai-forms` (see ./forms.ts for why it stays separate).
|
|
27
|
+
*
|
|
28
|
+
* WHY IT WAS RENAMED FROM ai-ration
|
|
29
|
+
* ---------------------------------
|
|
30
|
+
* Because the owner of this fleet read the name and could not tell what it did.
|
|
31
|
+
* That is not a cosmetic complaint: an unreadable name is an adoption cost paid
|
|
32
|
+
* on every single install decision, and this package had ONE adopter while the
|
|
33
|
+
* five repos that skipped it were all taken down together on 2026-08-26 by a
|
|
34
|
+
* retired model id — the exact failure the `chain` and `catalog` modules exist
|
|
35
|
+
* to prevent. "Ration" described one of five modules and buried the other four.
|
|
36
|
+
*
|
|
37
|
+
* STILL NOT INCLUDED: an HTTP client. Every app has its own calling conventions,
|
|
38
|
+
* retries and logging, and replacing those is a rewrite rather than an adoption.
|
|
39
|
+
* This supplies the decisions; the caller keeps the fetch — `tryChain` is an
|
|
40
|
+
* orchestrator, not a client: the caller's own `attempt` function makes the
|
|
41
|
+
* actual request. That rule is under review — `ai-forms`, the most-adopted
|
|
42
|
+
* package in this fleet, is the one that broke it by shipping a route factory
|
|
43
|
+
* and a hook.
|
|
44
|
+
*/
|
|
45
|
+
export { providerModels, withEnvPrefix, freeChain, modelCost, modelCostAt, paidModelsIn, dayCapacityTokens, usableChain, chainFrom, } from "./chain.js";
|
|
46
|
+
export { checkCatalog, hasRot, deadProviders, catalogReport, } from "./catalog.js";
|
|
47
|
+
export { ChainExhaustedError, tryChain, } from "./attempt.js";
|
|
48
|
+
export { createHealthTracker, } from "./health.js";
|
|
49
|
+
export { classifyRateLimit, retryAfterSeconds, humanizeWait, rateLimitMessage, } from "./limits.js";
|
|
50
|
+
export { DAY_SECONDS, DEFAULT_BURST, fairShare, utcDayElapsed, utcDayKey, } from "./fair-share.js";
|
|
51
|
+
// Form filling lives at `ai-kit/forms`, NOT here.
|
|
52
|
+
//
|
|
53
|
+
// It was re-exported from this root for one release, so that "adding AI" was a
|
|
54
|
+
// single import as well as a single install. The first app to adopt the merged
|
|
55
|
+
// package showed what that costs: `ai-forms` is ESM-only, so pulling it in from
|
|
56
|
+
// this root made every consumer of the CHAIN load the forms package too — and
|
|
57
|
+
// the app's Jest run, which executes CJS, died on `Unexpected token 'export'`
|
|
58
|
+
// inside a module it never asked for. The fix would have been a
|
|
59
|
+
// `transformIgnorePatterns` entry in that app, and in the next one, and in
|
|
60
|
+
// every app thereafter: one class of breakage, paid per repo, forever.
|
|
61
|
+
//
|
|
62
|
+
// One install is still the promise, and the exports map already keeps it:
|
|
63
|
+
//
|
|
64
|
+
// import { freeChain } from "@bitbaum/ai-kit"; // the chain
|
|
65
|
+
// import { defineFields } from "@bitbaum/ai-kit/forms"; // form filling
|
|
66
|
+
// import { useAssist } from "@bitbaum/ai-kit/react"; // the React hook
|
|
67
|
+
//
|
|
68
|
+
// Same dependency, same version, nothing extra to install — a consumer just
|
|
69
|
+
// stops paying for the half it does not use. That is what subpath exports are
|
|
70
|
+
// for, and collapsing them into the root threw the benefit away.
|
package/dist/limits.d.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
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
|
+
export type RateLimitKind = "size" | "capacity" | "daily";
|
|
49
|
+
/**
|
|
50
|
+
* Which kind of 429 this is. Keyed on the body, because the status code alone
|
|
51
|
+
* cannot tell them apart — all three share the status, the `type`, and the
|
|
52
|
+
* `code`, and two of the three even share the phrase "tokens per minute (TPM)".
|
|
53
|
+
* The headers describe the window, not the request, so they cannot decide it
|
|
54
|
+
* either.
|
|
55
|
+
*
|
|
56
|
+
* Daily is tested FIRST: a TPD body also matches the "Rate limit reached"
|
|
57
|
+
* wording that means capacity, so the more specific limit has to win or the
|
|
58
|
+
* cheaper check silently absorbs it.
|
|
59
|
+
*
|
|
60
|
+
* Defaults to "capacity" when the body is unrecognisable: that path retries and
|
|
61
|
+
* degrades, where guessing "size" would shed context that was never the problem
|
|
62
|
+
* and guessing "daily" would give up on a turn that might well have succeeded.
|
|
63
|
+
*/
|
|
64
|
+
export declare function classifyRateLimit(body: string): RateLimitKind;
|
|
65
|
+
/**
|
|
66
|
+
* The wait the provider itself named, in seconds, or null when it named none.
|
|
67
|
+
*
|
|
68
|
+
* Worth parsing rather than approximating because the honest number is the whole
|
|
69
|
+
* difference between a message a user can act on and one that wastes their time:
|
|
70
|
+
* "try again shortly" invites an immediate retry, and on a daily cap that retry
|
|
71
|
+
* is guaranteed to fail for the next hour. Groq states the real figure — the
|
|
72
|
+
* only reason not to pass it on is not having read it.
|
|
73
|
+
*
|
|
74
|
+
* Handles the two shapes the API emits: "3.6s" and "56m26.88s".
|
|
75
|
+
*/
|
|
76
|
+
export declare function retryAfterSeconds(body: string): number | null;
|
|
77
|
+
/**
|
|
78
|
+
* That wait as something a person reads: "3s", "2 minutes", "about 1 hour".
|
|
79
|
+
*
|
|
80
|
+
* The minutes/hours boundary is 60, not 90. At 90 the singular branch below is
|
|
81
|
+
* unreachable — every value that got that far divided to at least 1.5 hours,
|
|
82
|
+
* which rounds to 2 — so "about 1 hour" could never be printed and a 90-minute
|
|
83
|
+
* wait was announced as "about 2 hours". Rounding a wait UP past the reset is
|
|
84
|
+
* the same disservice as rounding it down: both leave the reader guessing when
|
|
85
|
+
* to come back.
|
|
86
|
+
*/
|
|
87
|
+
export declare function humanizeWait(seconds: number | null): string | null;
|
|
88
|
+
/**
|
|
89
|
+
* What to tell the operator when a 429 ends the turn.
|
|
90
|
+
*
|
|
91
|
+
* Lives beside the classifier so every caller says the same thing, and because
|
|
92
|
+
* the message is a direct consequence of the classification: the only useful
|
|
93
|
+
* content in a rate-limit error is whether retrying can work and when. "Try
|
|
94
|
+
* again shortly" on an exhausted DAY is the failure reported as "not working" —
|
|
95
|
+
* technically a rate limit, but it invites exactly the retry that is guaranteed
|
|
96
|
+
* to fail for the next hour.
|
|
97
|
+
*
|
|
98
|
+
* Returns a CLAUSE, not a sentence, because callers embed it in their own
|
|
99
|
+
* framing ("<assistant> is offline — ..."). It therefore neither capitalises nor
|
|
100
|
+
* names the assistant, which would read double.
|
|
101
|
+
*/
|
|
102
|
+
export declare function rateLimitMessage(raw: string): string;
|
package/dist/limits.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
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
|
+
* Which kind of 429 this is. Keyed on the body, because the status code alone
|
|
50
|
+
* cannot tell them apart — all three share the status, the `type`, and the
|
|
51
|
+
* `code`, and two of the three even share the phrase "tokens per minute (TPM)".
|
|
52
|
+
* The headers describe the window, not the request, so they cannot decide it
|
|
53
|
+
* either.
|
|
54
|
+
*
|
|
55
|
+
* Daily is tested FIRST: a TPD body also matches the "Rate limit reached"
|
|
56
|
+
* wording that means capacity, so the more specific limit has to win or the
|
|
57
|
+
* cheaper check silently absorbs it.
|
|
58
|
+
*
|
|
59
|
+
* Defaults to "capacity" when the body is unrecognisable: that path retries and
|
|
60
|
+
* degrades, where guessing "size" would shed context that was never the problem
|
|
61
|
+
* and guessing "daily" would give up on a turn that might well have succeeded.
|
|
62
|
+
*/
|
|
63
|
+
export function classifyRateLimit(body) {
|
|
64
|
+
// Requests-per-day is the same situation as tokens-per-day: nothing the caller
|
|
65
|
+
// does before the reset can help, so it gets the same treatment.
|
|
66
|
+
if (/per day|\bTPD\b|\bRPD\b/i.test(body))
|
|
67
|
+
return "daily";
|
|
68
|
+
return /request too large|reduce your message size|reduce the length/i.test(body)
|
|
69
|
+
? "size"
|
|
70
|
+
: "capacity";
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The wait the provider itself named, in seconds, or null when it named none.
|
|
74
|
+
*
|
|
75
|
+
* Worth parsing rather than approximating because the honest number is the whole
|
|
76
|
+
* difference between a message a user can act on and one that wastes their time:
|
|
77
|
+
* "try again shortly" invites an immediate retry, and on a daily cap that retry
|
|
78
|
+
* is guaranteed to fail for the next hour. Groq states the real figure — the
|
|
79
|
+
* only reason not to pass it on is not having read it.
|
|
80
|
+
*
|
|
81
|
+
* Handles the two shapes the API emits: "3.6s" and "56m26.88s".
|
|
82
|
+
*/
|
|
83
|
+
export function retryAfterSeconds(body) {
|
|
84
|
+
const m = /try again in\s+(?:(\d+(?:\.\d+)?)m)?(?:(\d+(?:\.\d+)?)s)?/i.exec(body);
|
|
85
|
+
if (!m || (!m[1] && !m[2]))
|
|
86
|
+
return null;
|
|
87
|
+
return Math.ceil(Number(m[1] ?? 0) * 60 + Number(m[2] ?? 0));
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* That wait as something a person reads: "3s", "2 minutes", "about 1 hour".
|
|
91
|
+
*
|
|
92
|
+
* The minutes/hours boundary is 60, not 90. At 90 the singular branch below is
|
|
93
|
+
* unreachable — every value that got that far divided to at least 1.5 hours,
|
|
94
|
+
* which rounds to 2 — so "about 1 hour" could never be printed and a 90-minute
|
|
95
|
+
* wait was announced as "about 2 hours". Rounding a wait UP past the reset is
|
|
96
|
+
* the same disservice as rounding it down: both leave the reader guessing when
|
|
97
|
+
* to come back.
|
|
98
|
+
*/
|
|
99
|
+
export function humanizeWait(seconds) {
|
|
100
|
+
if (seconds === null || !Number.isFinite(seconds) || seconds <= 0)
|
|
101
|
+
return null;
|
|
102
|
+
if (seconds < 90)
|
|
103
|
+
return `${Math.ceil(seconds)}s`;
|
|
104
|
+
const minutes = Math.ceil(seconds / 60);
|
|
105
|
+
if (minutes < 60)
|
|
106
|
+
return `${minutes} minutes`;
|
|
107
|
+
const hours = Math.round(minutes / 60);
|
|
108
|
+
return `about ${hours} hour${hours === 1 ? "" : "s"}`;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* What to tell the operator when a 429 ends the turn.
|
|
112
|
+
*
|
|
113
|
+
* Lives beside the classifier so every caller says the same thing, and because
|
|
114
|
+
* the message is a direct consequence of the classification: the only useful
|
|
115
|
+
* content in a rate-limit error is whether retrying can work and when. "Try
|
|
116
|
+
* again shortly" on an exhausted DAY is the failure reported as "not working" —
|
|
117
|
+
* technically a rate limit, but it invites exactly the retry that is guaranteed
|
|
118
|
+
* to fail for the next hour.
|
|
119
|
+
*
|
|
120
|
+
* Returns a CLAUSE, not a sentence, because callers embed it in their own
|
|
121
|
+
* framing ("<assistant> is offline — ..."). It therefore neither capitalises nor
|
|
122
|
+
* names the assistant, which would read double.
|
|
123
|
+
*/
|
|
124
|
+
export function rateLimitMessage(raw) {
|
|
125
|
+
const wait = humanizeWait(retryAfterSeconds(raw));
|
|
126
|
+
switch (classifyRateLimit(raw)) {
|
|
127
|
+
case "daily":
|
|
128
|
+
return `the daily model quota is used up${wait ? ` (resets in ${wait})` : ""}`;
|
|
129
|
+
case "size":
|
|
130
|
+
// Retrying is not the fix and saying so prevents a pointless loop; the
|
|
131
|
+
// real repair (a smaller prompt) is ours to make, not the operator's.
|
|
132
|
+
return "the question needed more context than the model allows in one request";
|
|
133
|
+
case "capacity":
|
|
134
|
+
return `the model provider is rate-limited${wait ? ` (retry in ${wait})` : " — try again shortly"}`;
|
|
135
|
+
}
|
|
136
|
+
}
|
package/dist/react.d.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";
|
package/dist/react.js
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,133 @@
|
|
|
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
|
+
* How a model answered a live tool-call probe. "unprobed" is a real value, not
|
|
36
|
+
* a default to ignore: of nine free models probed for the default chain, FIVE
|
|
37
|
+
* answered only via a text protocol — not guessable from name, size, or docs.
|
|
38
|
+
* A loop that needs tools should refuse "none" and treat "unprobed" as a
|
|
39
|
+
* to-do, never as "probably native".
|
|
40
|
+
*/
|
|
41
|
+
export type ToolProtocol = "native" | "text" | "none" | "unprobed";
|
|
42
|
+
export type ModelTier = "free" | "economy" | "standard" | "premium";
|
|
43
|
+
export type ModelCapability = "text" | "vision" | "function_calling" | "json_mode" | "streaming" | "transcribe";
|
|
44
|
+
export type ModelEntry = {
|
|
45
|
+
/** The id sent on the wire — exactly as the vendor expects it. */
|
|
46
|
+
id: string;
|
|
47
|
+
/** Where the call goes (groq, openrouter, together, ollama, …). */
|
|
48
|
+
vendor: string;
|
|
49
|
+
/** Who trained it (Anthropic, Meta, Moonshot, …) — metadata, never routing. */
|
|
50
|
+
author?: string;
|
|
51
|
+
/** Display name for pickers. Optional: an engine-only entry needs none. */
|
|
52
|
+
name?: string;
|
|
53
|
+
/**
|
|
54
|
+
* THE billing boundary. Required, no default: making the author write
|
|
55
|
+
* `paid: false` is the whole point — a forgotten field must fail the build,
|
|
56
|
+
* not silently ride a naming convention.
|
|
57
|
+
*/
|
|
58
|
+
paid: boolean;
|
|
59
|
+
/** USD per 1M tokens. Free entries may omit (treated as 0). */
|
|
60
|
+
inputCostPer1M?: number;
|
|
61
|
+
outputCostPer1M?: number;
|
|
62
|
+
contextWindow?: number;
|
|
63
|
+
maxOutputTokens?: number;
|
|
64
|
+
tier?: ModelTier;
|
|
65
|
+
capabilities?: ModelCapability[];
|
|
66
|
+
/** Verdict of a live tool-call probe. Absent = "unprobed". */
|
|
67
|
+
toolProtocol?: ToolProtocol;
|
|
68
|
+
/**
|
|
69
|
+
* Whether the model accepts a non-default `temperature`. Absent = true.
|
|
70
|
+
* Current Anthropic frontier models reject non-default sampling params, so
|
|
71
|
+
* callers must omit the param for entries that say false.
|
|
72
|
+
*/
|
|
73
|
+
supportsTemperature?: boolean;
|
|
74
|
+
/**
|
|
75
|
+
* What breaks when this id stops existing — the text a rot report shows.
|
|
76
|
+
* Borrowed from the eight-day outage: the fastest diagnosis is the registry
|
|
77
|
+
* row saying which feature just died.
|
|
78
|
+
*/
|
|
79
|
+
usedFor?: string;
|
|
80
|
+
/** Which endpoint shape this id is called on. Default "chat". */
|
|
81
|
+
kind?: "chat" | "transcribe";
|
|
82
|
+
};
|
|
83
|
+
export type Registry = {
|
|
84
|
+
entries: readonly ModelEntry[];
|
|
85
|
+
/** Lookup by wire id (optionally scoped to a vendor when ids collide). */
|
|
86
|
+
find(id: string, vendor?: string): ModelEntry | undefined;
|
|
87
|
+
/**
|
|
88
|
+
* The entry, or a THROW naming what depends on it. "A model id is callable
|
|
89
|
+
* only if it appears here" is only true if the miss is loud.
|
|
90
|
+
*/
|
|
91
|
+
require(id: string, vendor?: string): ModelEntry;
|
|
92
|
+
/** Every wire id at one vendor — the enumeration a catalog check walks. */
|
|
93
|
+
idsForVendor(vendor: string): string[];
|
|
94
|
+
vendors(): string[];
|
|
95
|
+
/** Entries the free tier may serve. The platform-key guard filters on THIS. */
|
|
96
|
+
freeEntries(): ModelEntry[];
|
|
97
|
+
/** Entries only reachable through someone's money (credits or BYOK). */
|
|
98
|
+
paidEntries(): ModelEntry[];
|
|
99
|
+
};
|
|
100
|
+
/**
|
|
101
|
+
* Build a registry from entries. Throws on the first contradiction — a
|
|
102
|
+
* registry that loads is a registry whose billing boundary can be trusted.
|
|
103
|
+
*/
|
|
104
|
+
export declare function defineRegistry(entries: ModelEntry[]): Registry;
|
|
105
|
+
/**
|
|
106
|
+
* The platform-key guard: the ids from `requested` that a platform-funded
|
|
107
|
+
* call may serve. Registered-and-free passes; paid is dropped; an UNKNOWN id
|
|
108
|
+
* is dropped too — an id nobody registered has an unknown price, and "unknown"
|
|
109
|
+
* spends someone's money only when a person decides it does.
|
|
110
|
+
*
|
|
111
|
+
* Returns the dropped ids alongside, because a silently narrowed chain reads
|
|
112
|
+
* as "covered everything" when it didn't.
|
|
113
|
+
*/
|
|
114
|
+
export declare function freeOnly(registry: Registry, requested: string[]): {
|
|
115
|
+
allowed: string[];
|
|
116
|
+
dropped: {
|
|
117
|
+
id: string;
|
|
118
|
+
why: "paid" | "unregistered";
|
|
119
|
+
}[];
|
|
120
|
+
};
|
|
121
|
+
/**
|
|
122
|
+
* A tool-driving chain may only contain models that can drive a tool loop.
|
|
123
|
+
* "unprobed" entries are reported, not silently trusted — the probe table is
|
|
124
|
+
* one `npm run probe:models` away, and a chain built on guesses loses turns
|
|
125
|
+
* exactly on the models most likely to serve free traffic.
|
|
126
|
+
*/
|
|
127
|
+
export declare function toolCapable(registry: Registry, requested: string[]): {
|
|
128
|
+
usable: string[];
|
|
129
|
+
refused: {
|
|
130
|
+
id: string;
|
|
131
|
+
protocol: ToolProtocol;
|
|
132
|
+
}[];
|
|
133
|
+
};
|
package/dist/registry.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
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
|
+
/** A `:free`-suffixed id claiming to be paid, or a "free" entry with a price —
|
|
35
|
+
* each one is the 2026 billing incident waiting to recur. */
|
|
36
|
+
function validateEntry(e) {
|
|
37
|
+
if (!e.id.trim())
|
|
38
|
+
return "entry has an empty id";
|
|
39
|
+
if (!e.vendor.trim())
|
|
40
|
+
return `"${e.id}": empty vendor`;
|
|
41
|
+
const cost = (e.inputCostPer1M ?? 0) + (e.outputCostPer1M ?? 0);
|
|
42
|
+
if (!e.paid && cost > 0) {
|
|
43
|
+
return `"${e.id}": declared free but carries a cost (${cost}/1M) — the flag or the price is lying`;
|
|
44
|
+
}
|
|
45
|
+
if (e.paid && e.id.endsWith(":free")) {
|
|
46
|
+
return `"${e.id}": declared paid but the id says :free — the flag or the id is lying`;
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Build a registry from entries. Throws on the first contradiction — a
|
|
52
|
+
* registry that loads is a registry whose billing boundary can be trusted.
|
|
53
|
+
*/
|
|
54
|
+
export function defineRegistry(entries) {
|
|
55
|
+
const seen = new Set();
|
|
56
|
+
for (const e of entries) {
|
|
57
|
+
const problem = validateEntry(e);
|
|
58
|
+
if (problem)
|
|
59
|
+
throw new Error(`ai-kit registry: ${problem}`);
|
|
60
|
+
const key = `${e.vendor}:${e.id}`;
|
|
61
|
+
if (seen.has(key)) {
|
|
62
|
+
throw new Error(`ai-kit registry: duplicate entry ${key} — two rows for one callable id is two sources of truth`);
|
|
63
|
+
}
|
|
64
|
+
seen.add(key);
|
|
65
|
+
}
|
|
66
|
+
const frozen = Object.freeze(entries.map((e) => ({ ...e })));
|
|
67
|
+
const find = (id, vendor) => frozen.find((e) => e.id === id && (vendor === undefined || e.vendor === vendor));
|
|
68
|
+
return {
|
|
69
|
+
entries: frozen,
|
|
70
|
+
find,
|
|
71
|
+
require(id, vendor) {
|
|
72
|
+
const hit = find(id, vendor);
|
|
73
|
+
if (!hit) {
|
|
74
|
+
const scope = vendor ? ` at ${vendor}` : "";
|
|
75
|
+
throw new Error(`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)`);
|
|
76
|
+
}
|
|
77
|
+
return hit;
|
|
78
|
+
},
|
|
79
|
+
idsForVendor: (vendor) => frozen.filter((e) => e.vendor === vendor).map((e) => e.id),
|
|
80
|
+
vendors: () => [...new Set(frozen.map((e) => e.vendor))],
|
|
81
|
+
freeEntries: () => frozen.filter((e) => !e.paid),
|
|
82
|
+
paidEntries: () => frozen.filter((e) => e.paid),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The platform-key guard: the ids from `requested` that a platform-funded
|
|
87
|
+
* call may serve. Registered-and-free passes; paid is dropped; an UNKNOWN id
|
|
88
|
+
* is dropped too — an id nobody registered has an unknown price, and "unknown"
|
|
89
|
+
* spends someone's money only when a person decides it does.
|
|
90
|
+
*
|
|
91
|
+
* Returns the dropped ids alongside, because a silently narrowed chain reads
|
|
92
|
+
* as "covered everything" when it didn't.
|
|
93
|
+
*/
|
|
94
|
+
export function freeOnly(registry, requested) {
|
|
95
|
+
const allowed = [];
|
|
96
|
+
const dropped = [];
|
|
97
|
+
for (const id of requested) {
|
|
98
|
+
const entry = registry.find(id);
|
|
99
|
+
if (!entry)
|
|
100
|
+
dropped.push({ id, why: "unregistered" });
|
|
101
|
+
else if (entry.paid)
|
|
102
|
+
dropped.push({ id, why: "paid" });
|
|
103
|
+
else
|
|
104
|
+
allowed.push(id);
|
|
105
|
+
}
|
|
106
|
+
return { allowed, dropped };
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* A tool-driving chain may only contain models that can drive a tool loop.
|
|
110
|
+
* "unprobed" entries are reported, not silently trusted — the probe table is
|
|
111
|
+
* one `npm run probe:models` away, and a chain built on guesses loses turns
|
|
112
|
+
* exactly on the models most likely to serve free traffic.
|
|
113
|
+
*/
|
|
114
|
+
export function toolCapable(registry, requested) {
|
|
115
|
+
const usable = [];
|
|
116
|
+
const refused = [];
|
|
117
|
+
for (const id of requested) {
|
|
118
|
+
const entry = registry.find(id);
|
|
119
|
+
const protocol = entry?.toolProtocol ?? "unprobed";
|
|
120
|
+
if (protocol === "native" || protocol === "text")
|
|
121
|
+
usable.push(id);
|
|
122
|
+
else
|
|
123
|
+
refused.push({ id, protocol });
|
|
124
|
+
}
|
|
125
|
+
return { usable, refused };
|
|
126
|
+
}
|
package/dist/server.d.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";
|
package/dist/server.js
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";
|