@bitbaum/ai-kit 1.0.0 → 1.1.0

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/README.md CHANGED
@@ -361,6 +361,50 @@ locally.
361
361
  anything to do with AI. An app that throttles its login form should not install a
362
362
  model catalogue to do it.
363
363
 
364
+ ## Knowing what is left
365
+
366
+ ```ts
367
+ const result = await complete({
368
+ messages,
369
+ onQuota: (readings) => void recordQuota(readings), // your table, your call
370
+ });
371
+ ```
372
+
373
+ Every vendor answer carries what it was willing to disclose about your remaining
374
+ allowance, and this hands it to you on success **and on refusal**. No extra
375
+ request: the number arrives on the exchange you were making anyway.
376
+
377
+ **Do not poll a vendor's usage endpoint instead.** Measured on one live key,
378
+ within the same second:
379
+
380
+ ```
381
+ GET /api/v1/key → limit_remaining: null, usage_daily: 0
382
+ POST /chat/completions → 429, x-ratelimit-remaining: 0 of 50,
383
+ "Rate limit exceeded: free-models-per-day"
384
+ ```
385
+
386
+ Both are OpenRouter, about the same account, and the account was locked out. The
387
+ usage endpoint tracks money; free models cost nothing; the limit that actually
388
+ binds is a request count it never reports. A dashboard built on the first line
389
+ shows a full tank during a total outage.
390
+
391
+ Three things this will not do, each deliberate:
392
+
393
+ - **It never invents a reading.** A vendor that sends no headers produces none.
394
+ Absent is a third state next to known and empty — render it as *unknown*,
395
+ because drawing it full repeats the bug above and drawing it empty invents an
396
+ outage.
397
+ - **It never guesses a window.** `x-ratelimit-remaining-requests` counts a day at
398
+ Groq and a minute elsewhere, so the window comes from a verified per-provider
399
+ profile or from the header's own name, and is otherwise reported as unknown.
400
+ - **It never stores anything.** Persisting is the app's job, which is why this is
401
+ a callback. The package has no database and should not grow one.
402
+
403
+ `answersRemaining(reading, tokensPerTurn)` converts a token count into the unit a
404
+ person actually thinks in. Nobody has an intuition for a token; "about 40 more
405
+ answers" is actionable, and "7,927 tokens" is a sum the reader has to do and will
406
+ get wrong.
407
+
364
408
  ## Versioning
365
409
 
366
410
  **This package is 1.x, and that is a functional decision rather than a
@@ -58,6 +58,7 @@
58
58
  import { type Env, type Link, type Provider } from "./chain.js";
59
59
  import type { HealthTracker } from "./health.js";
60
60
  import { type RateLimitKind } from "./limits.js";
61
+ import { type QuotaReading } from "./meter.js";
61
62
  /** One message in the OpenAI chat-completions shape every provider here speaks. */
62
63
  export interface ChatMessage {
63
64
  role: "system" | "user" | "assistant" | "tool";
@@ -156,6 +157,23 @@ export interface CompleteOptions {
156
157
  extraHeaders?: Record<string, string>;
157
158
  /** Called on each link's failure before moving on — e.g. to log which id rotted. */
158
159
  onLinkFailure?: (link: Link, error: Error) => void;
160
+ /**
161
+ * Called with every remaining-quota figure the vendor disclosed, on success
162
+ * AND on refusal. This is how an app learns what is left without spending a
163
+ * request to ask.
164
+ *
165
+ * It fires on 429s too, and those are the most valuable readings of all: a
166
+ * refusal is the vendor correcting a local counter that had drifted
167
+ * optimistic. See `meter.ts` for why polling a vendor's usage endpoint
168
+ * instead is the wrong design — one of them reports an untouched allowance
169
+ * while the key is locked out.
170
+ *
171
+ * Keep it cheap and never let it throw: it runs inside the response path, and
172
+ * an exception here would turn a good answer into a link failure. Persisting
173
+ * is the app's job, which is why this is a callback and not a store — the
174
+ * package stays free of a database.
175
+ */
176
+ onQuota?: (readings: QuotaReading[]) => void;
159
177
  /** Injected for tests. Defaults to global `fetch`. */
160
178
  fetchImpl?: typeof fetch;
161
179
  }
package/dist/complete.js CHANGED
@@ -58,6 +58,7 @@
58
58
  import { chainFrom, freeChain, usableChain } from "./chain.js";
59
59
  import { ChainExhaustedError } from "./attempt.js";
60
60
  import { classifyRateLimit, retryAfterSeconds } from "./limits.js";
61
+ import { readQuota, readingFromRefusal } from "./meter.js";
61
62
  /**
62
63
  * A link failed in a way that says something about the WALK, not just this link.
63
64
  *
@@ -220,10 +221,32 @@ async function callLink(link, options, key) {
220
221
  deadline.dispose();
221
222
  }
222
223
  const text = await res.text();
224
+ // Read the tank before interpreting the answer, so a refusal still reports
225
+ // what it disclosed. A caller's hook must never turn a good response into a
226
+ // failure, so it is isolated — an app's logging bug is not a vendor outage.
227
+ const report = (readings) => {
228
+ if (readings.length === 0 || !options.onQuota)
229
+ return;
230
+ try {
231
+ options.onQuota(readings);
232
+ }
233
+ catch {
234
+ /* the caller's sink is the caller's problem */
235
+ }
236
+ };
237
+ report(readQuota(res.headers, link));
223
238
  if (!res.ok) {
224
239
  if (res.status === 429) {
225
240
  const kind = classifyRateLimit(text);
226
241
  const retryAfter = retryAfterSeconds(text);
242
+ // The most reliable reading there is: the vendor itself saying "spent".
243
+ // Recorded even when no header carried a number, because it corrects a
244
+ // local counter that had drifted optimistic. `size` is excluded — that
245
+ // 429 means this one prompt was too big, not that the allowance is gone,
246
+ // and recording it as empty would take a working vendor out of service.
247
+ if (kind !== "size") {
248
+ report([readingFromRefusal(link, retryAfter)]);
249
+ }
227
250
  throw new LinkFailure(link, `${linkId(link)}: 429 ${kind} — ${excerpt(text)}`, {
228
251
  status: 429,
229
252
  kind,
package/dist/index.d.ts CHANGED
@@ -56,4 +56,5 @@ export { type ChatMessage, type ToolCall, type CompleteOptions, type CompleteRes
56
56
  export { type HealthStatus, type Health, type HealthTrackerOptions, type HealthTracker, createHealthTracker, } from "./health.js";
57
57
  export { type LivenessResult, type LivenessOptions, type LivenessProbe, type AiHealthHandlerOptions, createLivenessProbe, createAiHealthHandler, } from "./liveness.js";
58
58
  export { type RateLimitKind, classifyRateLimit, retryAfterSeconds, humanizeWait, rateLimitMessage, } from "./limits.js";
59
+ export { type QuotaScope, type QuotaWindow, type QuotaReading, type HeaderBag, readQuota, readingFromRefusal, parseResetAt, answersRemaining, } from "./meter.js";
59
60
  export { DAY_SECONDS, DEFAULT_BURST, type ShareInput, type ShareReason, type ShareDecision, fairShare, utcDayElapsed, utcDayKey, } from "./fair-share.js";
package/dist/index.js CHANGED
@@ -56,6 +56,7 @@ export { LinkFailure, complete, linkId, } from "./complete.js";
56
56
  export { createHealthTracker, } from "./health.js";
57
57
  export { createLivenessProbe, createAiHealthHandler, } from "./liveness.js";
58
58
  export { classifyRateLimit, retryAfterSeconds, humanizeWait, rateLimitMessage, } from "./limits.js";
59
+ export { readQuota, readingFromRefusal, parseResetAt, answersRemaining, } from "./meter.js";
59
60
  export { DAY_SECONDS, DEFAULT_BURST, fairShare, utcDayElapsed, utcDayKey, } from "./fair-share.js";
60
61
  // Form filling lives at `ai-kit/forms`, NOT here.
61
62
  //
@@ -0,0 +1,117 @@
1
+ /**
2
+ * What is left at the vendor, read from the answer you already got.
3
+ *
4
+ * ── WHY THIS IS NOT A POLLER ─────────────────────────────────────────────────
5
+ *
6
+ * The obvious design is to ask each provider how much quota remains. That
7
+ * design is wrong, and it is wrong in the direction that hurts: it reports a
8
+ * full tank during an outage.
9
+ *
10
+ * Measured on 2026-09-11, on one live key, within the same second:
11
+ *
12
+ * GET /api/v1/key → limit_remaining: null, usage_daily: 0
13
+ * POST /chat/completions → 429, x-ratelimit-remaining: 0 of 50,
14
+ * "Rate limit exceeded: free-models-per-day"
15
+ *
16
+ * Both answers are from OpenRouter about the same account. The account was
17
+ * locked out. The usage endpoint tracks money, free models cost nothing, and
18
+ * the limit that actually binds is a REQUEST count that endpoint never reports.
19
+ * A dashboard built on the first line would have shown an untouched allowance
20
+ * while every call was failing.
21
+ *
22
+ * So the meter reads the headers on calls you were making anyway. No extra
23
+ * request, no extra quota spent to find out how much quota is left, and the
24
+ * number comes from the same exchange that either worked or did not.
25
+ *
26
+ * ── THE THREE STATES ─────────────────────────────────────────────────────────
27
+ *
28
+ * A provider you have not called today reports NOTHING, and that is a third
29
+ * state — not zero, not full. This module never invents a reading: no header,
30
+ * no `QuotaReading`. Callers must render the absence as "unknown", because
31
+ * drawing it as full repeats the bug above and drawing it as empty invents an
32
+ * outage.
33
+ *
34
+ * ── VENDORS DISAGREE ABOUT WHAT THEIR OWN HEADERS MEAN ───────────────────────
35
+ *
36
+ * `x-ratelimit-remaining-requests` is a per-DAY count at Groq and a per-MINUTE
37
+ * count elsewhere. The window is therefore read from a per-provider profile
38
+ * where one is known, from the header name where it says so (`...-day`), and
39
+ * otherwise reported as "unknown" rather than guessed. A confident wrong window
40
+ * turns "you have 900 requests left today" into "…this minute", which is the
41
+ * kind of error nobody catches until the dashboard has been trusted for a week.
42
+ */
43
+ import type { Link } from "./chain.js";
44
+ /** What is being counted. */
45
+ export type QuotaScope = "requests" | "tokens";
46
+ /** The period the count refreshes over. `unknown` is a real answer. */
47
+ export type QuotaWindow = "minute" | "day" | "unknown";
48
+ /** One observation of one vendor counter, at one moment. */
49
+ export interface QuotaReading {
50
+ /** Provider id, e.g. "groq". */
51
+ provider: string;
52
+ /** The model the call named — Groq meters per model, so this matters. */
53
+ model: string;
54
+ scope: QuotaScope;
55
+ window: QuotaWindow;
56
+ /** The ceiling, when the vendor states it. */
57
+ limit: number | null;
58
+ /** What is left. The number this module exists to obtain. */
59
+ remaining: number;
60
+ /** When the counter refills, epoch ms. Null when the vendor did not say. */
61
+ resetAt: number | null;
62
+ /** The header (or body) this came from — so a wrong number is traceable. */
63
+ source: string;
64
+ /** Epoch ms. A reading is evidence about a moment, not a standing fact. */
65
+ observedAt: number;
66
+ }
67
+ /** Anything header-shaped. Keeps this module free of a DOM/undici dependency. */
68
+ export interface HeaderBag {
69
+ get(name: string): string | null;
70
+ }
71
+ /**
72
+ * Parse a reset value into epoch milliseconds.
73
+ *
74
+ * Vendors encode this three incompatible ways and none of them say which:
75
+ *
76
+ * "1789171200000" OpenRouter — epoch MILLISECONDS (a Sept 2026 date)
77
+ * "1m26.4s" Groq — a duration, compound, fractional
78
+ * "547ms" Groq — a duration under a second
79
+ * "60" several — seconds from now
80
+ *
81
+ * The discriminator is magnitude, not format: a value past the year 2001 in
82
+ * milliseconds cannot be a duration anyone would wait. Returns null rather than
83
+ * guessing when nothing parses, because a wrong reset time tells the operator
84
+ * to come back at the wrong hour.
85
+ */
86
+ export declare function parseResetAt(raw: string | null, now?: number): number | null;
87
+ /**
88
+ * Every remaining-count this response disclosed.
89
+ *
90
+ * Returns an empty array when the vendor said nothing, which is the common case
91
+ * — Gemini sends no rate-limit headers at all, and several others only meter
92
+ * server-side. An empty array means "did not say", never "nothing left".
93
+ */
94
+ export declare function readQuota(headers: HeaderBag, link: Link, now?: number): QuotaReading[];
95
+ /**
96
+ * A refusal is the most reliable reading there is.
97
+ *
98
+ * A 429 states the one fact a dashboard most needs and most often has stale:
99
+ * this vendor is spent. It is worth recording even when the response carried no
100
+ * usable headers, because it corrects a local counter that had drifted
101
+ * optimistic — the counter is a model of the vendor, and this is the vendor
102
+ * disagreeing with it.
103
+ */
104
+ export declare function readingFromRefusal(link: Link, retryAfterSec: number | null, scope?: QuotaScope, now?: number): QuotaReading;
105
+ /**
106
+ * Turn a remaining-token count into the unit a person thinks in.
107
+ *
108
+ * Nobody has an intuition for a token. "About 40 more answers" is actionable;
109
+ * "7,927 tokens" is a number the reader has to convert before it means
110
+ * anything, and they will convert it wrongly.
111
+ *
112
+ * `tokensPerTurn` is the caller's measured average, because it is a property of
113
+ * their prompts, not of this package. Returns null when the reading cannot
114
+ * support the translation, so the caller shows the raw figure rather than a
115
+ * fabricated one.
116
+ */
117
+ export declare function answersRemaining(reading: QuotaReading, tokensPerTurn: number): number | null;
package/dist/meter.js ADDED
@@ -0,0 +1,217 @@
1
+ /**
2
+ * What is left at the vendor, read from the answer you already got.
3
+ *
4
+ * ── WHY THIS IS NOT A POLLER ─────────────────────────────────────────────────
5
+ *
6
+ * The obvious design is to ask each provider how much quota remains. That
7
+ * design is wrong, and it is wrong in the direction that hurts: it reports a
8
+ * full tank during an outage.
9
+ *
10
+ * Measured on 2026-09-11, on one live key, within the same second:
11
+ *
12
+ * GET /api/v1/key → limit_remaining: null, usage_daily: 0
13
+ * POST /chat/completions → 429, x-ratelimit-remaining: 0 of 50,
14
+ * "Rate limit exceeded: free-models-per-day"
15
+ *
16
+ * Both answers are from OpenRouter about the same account. The account was
17
+ * locked out. The usage endpoint tracks money, free models cost nothing, and
18
+ * the limit that actually binds is a REQUEST count that endpoint never reports.
19
+ * A dashboard built on the first line would have shown an untouched allowance
20
+ * while every call was failing.
21
+ *
22
+ * So the meter reads the headers on calls you were making anyway. No extra
23
+ * request, no extra quota spent to find out how much quota is left, and the
24
+ * number comes from the same exchange that either worked or did not.
25
+ *
26
+ * ── THE THREE STATES ─────────────────────────────────────────────────────────
27
+ *
28
+ * A provider you have not called today reports NOTHING, and that is a third
29
+ * state — not zero, not full. This module never invents a reading: no header,
30
+ * no `QuotaReading`. Callers must render the absence as "unknown", because
31
+ * drawing it as full repeats the bug above and drawing it as empty invents an
32
+ * outage.
33
+ *
34
+ * ── VENDORS DISAGREE ABOUT WHAT THEIR OWN HEADERS MEAN ───────────────────────
35
+ *
36
+ * `x-ratelimit-remaining-requests` is a per-DAY count at Groq and a per-MINUTE
37
+ * count elsewhere. The window is therefore read from a per-provider profile
38
+ * where one is known, from the header name where it says so (`...-day`), and
39
+ * otherwise reported as "unknown" rather than guessed. A confident wrong window
40
+ * turns "you have 900 requests left today" into "…this minute", which is the
41
+ * kind of error nobody catches until the dashboard has been trusted for a week.
42
+ */
43
+ /**
44
+ * Per-provider correction for headers whose name does not state their window.
45
+ *
46
+ * Only entries verified against the vendor's own documentation or a live
47
+ * response belong here. An unlisted provider yields `unknown`, which is the
48
+ * honest answer and renders as such.
49
+ */
50
+ const PROVIDER_WINDOWS = {
51
+ // Verified live 2026-09-11: `x-ratelimit-remaining-requests` counts the DAY
52
+ // (1,000 per day), while `-tokens` counts the minute (8,000 per minute).
53
+ // Reading both as per-minute understates the day by three orders of
54
+ // magnitude; reading both as daily hides the limit that actually throttles.
55
+ groq: { requests: "day", tokens: "minute" },
56
+ // Verified live 2026-09-11: the unpaid tier is 50 REQUESTS per day, and the
57
+ // bare `x-ratelimit-remaining` on the chat response is that counter.
58
+ openrouter: { requests: "day" },
59
+ // Documented per-minute, with separate `-day` twins this parser reads by name.
60
+ sambanova: { requests: "minute" },
61
+ ovh: { requests: "minute" },
62
+ mistral: { requests: "minute" },
63
+ };
64
+ /**
65
+ * Header names carrying a remaining count, most specific first.
66
+ *
67
+ * Order matters: `...-requests-day` must be tested before `...-requests`, or
68
+ * the day counter is read as the minute counter under the shorter name.
69
+ */
70
+ const REMAINING_HEADERS = [
71
+ { name: "x-ratelimit-remaining-requests-day", scope: "requests", window: "day" },
72
+ { name: "x-ratelimit-remaining-tokens-day", scope: "tokens", window: "day" },
73
+ { name: "x-ratelimit-remaining-requests-minute", scope: "requests", window: "minute" },
74
+ { name: "x-ratelimit-remaining-minute", scope: "requests", window: "minute" },
75
+ { name: "x-ratelimit-remaining-requests", scope: "requests" },
76
+ { name: "x-ratelimit-remaining-tokens", scope: "tokens" },
77
+ // Bare forms. OpenRouter uses `x-ratelimit-remaining`; OVHcloud drops the
78
+ // `x-` prefix entirely. Both count requests.
79
+ { name: "x-ratelimit-remaining", scope: "requests" },
80
+ { name: "ratelimit-remaining", scope: "requests" },
81
+ ];
82
+ /** The limit header paired with a remaining header, by substitution. */
83
+ function limitNameFor(remainingName) {
84
+ return remainingName.replace("remaining", "limit");
85
+ }
86
+ /** The reset header paired with a remaining header, by substitution. */
87
+ function resetNameFor(remainingName) {
88
+ return remainingName.replace("remaining", "reset");
89
+ }
90
+ /**
91
+ * Parse a reset value into epoch milliseconds.
92
+ *
93
+ * Vendors encode this three incompatible ways and none of them say which:
94
+ *
95
+ * "1789171200000" OpenRouter — epoch MILLISECONDS (a Sept 2026 date)
96
+ * "1m26.4s" Groq — a duration, compound, fractional
97
+ * "547ms" Groq — a duration under a second
98
+ * "60" several — seconds from now
99
+ *
100
+ * The discriminator is magnitude, not format: a value past the year 2001 in
101
+ * milliseconds cannot be a duration anyone would wait. Returns null rather than
102
+ * guessing when nothing parses, because a wrong reset time tells the operator
103
+ * to come back at the wrong hour.
104
+ */
105
+ export function parseResetAt(raw, now = Date.now()) {
106
+ if (!raw)
107
+ return null;
108
+ const value = raw.trim();
109
+ if (value === "")
110
+ return null;
111
+ // Bare digits: epoch ms if implausibly large to be a wait, else seconds.
112
+ if (/^\d+$/.test(value)) {
113
+ const n = Number(value);
114
+ // 10^12 ms ≈ 2001. No vendor asks you to wait thirty years.
115
+ return n > 1e12 ? n : now + n * 1000;
116
+ }
117
+ // Duration forms: 1h2m3.4s, 56m26.88s, 547ms, 3.6s.
118
+ const ms = /^(\d+(?:\.\d+)?)ms$/.exec(value);
119
+ if (ms)
120
+ return now + Number(ms[1]);
121
+ const parts = /^(?:(\d+(?:\.\d+)?)h)?(?:(\d+(?:\.\d+)?)m(?!s))?(?:(\d+(?:\.\d+)?)s)?$/.exec(value);
122
+ if (parts && (parts[1] || parts[2] || parts[3])) {
123
+ const hours = Number(parts[1] ?? 0);
124
+ const mins = Number(parts[2] ?? 0);
125
+ const secs = Number(parts[3] ?? 0);
126
+ return now + (hours * 3600 + mins * 60 + secs) * 1000;
127
+ }
128
+ return null;
129
+ }
130
+ /** Resolve the window for a header that did not name one. */
131
+ function windowFor(providerId, scope, stated) {
132
+ if (stated)
133
+ return stated;
134
+ return PROVIDER_WINDOWS[providerId]?.[scope] ?? "unknown";
135
+ }
136
+ /**
137
+ * Every remaining-count this response disclosed.
138
+ *
139
+ * Returns an empty array when the vendor said nothing, which is the common case
140
+ * — Gemini sends no rate-limit headers at all, and several others only meter
141
+ * server-side. An empty array means "did not say", never "nothing left".
142
+ */
143
+ export function readQuota(headers, link, now = Date.now()) {
144
+ const readings = [];
145
+ const seen = new Set();
146
+ for (const entry of REMAINING_HEADERS) {
147
+ const raw = headers.get(entry.name);
148
+ if (raw === null || raw.trim() === "")
149
+ continue;
150
+ const remaining = Number(raw);
151
+ if (!Number.isFinite(remaining))
152
+ continue;
153
+ const window = windowFor(link.provider.id, entry.scope, entry.window);
154
+ // A shorter header name must not overwrite the more specific one it is a
155
+ // prefix of: once requests/day is known, a bare requests header is the same
156
+ // counter reported less precisely.
157
+ const key = `${entry.scope}:${window}`;
158
+ if (seen.has(key))
159
+ continue;
160
+ seen.add(key);
161
+ const limitRaw = headers.get(limitNameFor(entry.name));
162
+ const limit = limitRaw === null ? null : Number(limitRaw);
163
+ readings.push({
164
+ provider: link.provider.id,
165
+ model: link.model,
166
+ scope: entry.scope,
167
+ window,
168
+ limit: limit !== null && Number.isFinite(limit) ? limit : null,
169
+ remaining,
170
+ resetAt: parseResetAt(headers.get(resetNameFor(entry.name)), now),
171
+ source: entry.name,
172
+ observedAt: now,
173
+ });
174
+ }
175
+ return readings;
176
+ }
177
+ /**
178
+ * A refusal is the most reliable reading there is.
179
+ *
180
+ * A 429 states the one fact a dashboard most needs and most often has stale:
181
+ * this vendor is spent. It is worth recording even when the response carried no
182
+ * usable headers, because it corrects a local counter that had drifted
183
+ * optimistic — the counter is a model of the vendor, and this is the vendor
184
+ * disagreeing with it.
185
+ */
186
+ export function readingFromRefusal(link, retryAfterSec, scope = "requests", now = Date.now()) {
187
+ return {
188
+ provider: link.provider.id,
189
+ model: link.model,
190
+ scope,
191
+ window: windowFor(link.provider.id, scope),
192
+ limit: null,
193
+ remaining: 0,
194
+ resetAt: retryAfterSec === null ? null : now + retryAfterSec * 1000,
195
+ source: "429",
196
+ observedAt: now,
197
+ };
198
+ }
199
+ /**
200
+ * Turn a remaining-token count into the unit a person thinks in.
201
+ *
202
+ * Nobody has an intuition for a token. "About 40 more answers" is actionable;
203
+ * "7,927 tokens" is a number the reader has to convert before it means
204
+ * anything, and they will convert it wrongly.
205
+ *
206
+ * `tokensPerTurn` is the caller's measured average, because it is a property of
207
+ * their prompts, not of this package. Returns null when the reading cannot
208
+ * support the translation, so the caller shows the raw figure rather than a
209
+ * fabricated one.
210
+ */
211
+ export function answersRemaining(reading, tokensPerTurn) {
212
+ if (reading.scope === "requests")
213
+ return Math.max(0, Math.floor(reading.remaining));
214
+ if (!Number.isFinite(tokensPerTurn) || tokensPerTurn <= 0)
215
+ return null;
216
+ return Math.max(0, Math.floor(reading.remaining / tokensPerTurn));
217
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitbaum/ai-kit",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "One install for the AI layer of an app: which model to call, what to do when the vendor retires it, how to walk the fallback chain and know when none of it worked, how to read the three kinds of 429, a fair daily budget across users, headless AI form filling \u2014 and now the model registry (one SSOT for every callable id, with the paid/free boundary as a field) and the grounding harness (facts, contract, deterministic fabrication check).",
5
5
  "license": "MIT",
6
6
  "author": "Mao Nakamoto",
package/src/complete.ts CHANGED
@@ -60,6 +60,7 @@ import { type Env, type Link, type Provider, chainFrom, freeChain, usableChain }
60
60
  import { ChainExhaustedError, type ChainAttemptFailure } from "./attempt.js";
61
61
  import type { HealthTracker } from "./health.js";
62
62
  import { classifyRateLimit, retryAfterSeconds, type RateLimitKind } from "./limits.js";
63
+ import { readQuota, readingFromRefusal, type QuotaReading } from "./meter.js";
63
64
 
64
65
  /** One message in the OpenAI chat-completions shape every provider here speaks. */
65
66
  export interface ChatMessage {
@@ -161,6 +162,23 @@ export interface CompleteOptions {
161
162
  extraHeaders?: Record<string, string>;
162
163
  /** Called on each link's failure before moving on — e.g. to log which id rotted. */
163
164
  onLinkFailure?: (link: Link, error: Error) => void;
165
+ /**
166
+ * Called with every remaining-quota figure the vendor disclosed, on success
167
+ * AND on refusal. This is how an app learns what is left without spending a
168
+ * request to ask.
169
+ *
170
+ * It fires on 429s too, and those are the most valuable readings of all: a
171
+ * refusal is the vendor correcting a local counter that had drifted
172
+ * optimistic. See `meter.ts` for why polling a vendor's usage endpoint
173
+ * instead is the wrong design — one of them reports an untouched allowance
174
+ * while the key is locked out.
175
+ *
176
+ * Keep it cheap and never let it throw: it runs inside the response path, and
177
+ * an exception here would turn a good answer into a link failure. Persisting
178
+ * is the app's job, which is why this is a callback and not a store — the
179
+ * package stays free of a database.
180
+ */
181
+ onQuota?: (readings: QuotaReading[]) => void;
164
182
  /** Injected for tests. Defaults to global `fetch`. */
165
183
  fetchImpl?: typeof fetch;
166
184
  }
@@ -367,10 +385,31 @@ async function callLink(
367
385
 
368
386
  const text = await res.text();
369
387
 
388
+ // Read the tank before interpreting the answer, so a refusal still reports
389
+ // what it disclosed. A caller's hook must never turn a good response into a
390
+ // failure, so it is isolated — an app's logging bug is not a vendor outage.
391
+ const report = (readings: QuotaReading[]) => {
392
+ if (readings.length === 0 || !options.onQuota) return;
393
+ try {
394
+ options.onQuota(readings);
395
+ } catch {
396
+ /* the caller's sink is the caller's problem */
397
+ }
398
+ };
399
+ report(readQuota(res.headers, link));
400
+
370
401
  if (!res.ok) {
371
402
  if (res.status === 429) {
372
403
  const kind = classifyRateLimit(text);
373
404
  const retryAfter = retryAfterSeconds(text);
405
+ // The most reliable reading there is: the vendor itself saying "spent".
406
+ // Recorded even when no header carried a number, because it corrects a
407
+ // local counter that had drifted optimistic. `size` is excluded — that
408
+ // 429 means this one prompt was too big, not that the allowance is gone,
409
+ // and recording it as empty would take a working vendor out of service.
410
+ if (kind !== "size") {
411
+ report([readingFromRefusal(link, retryAfter)]);
412
+ }
374
413
  throw new LinkFailure(link, `${linkId(link)}: 429 ${kind} — ${excerpt(text)}`, {
375
414
  status: 429,
376
415
  kind,
package/src/index.ts CHANGED
@@ -117,6 +117,17 @@ export {
117
117
  rateLimitMessage,
118
118
  } from "./limits.js";
119
119
 
120
+ export {
121
+ type QuotaScope,
122
+ type QuotaWindow,
123
+ type QuotaReading,
124
+ type HeaderBag,
125
+ readQuota,
126
+ readingFromRefusal,
127
+ parseResetAt,
128
+ answersRemaining,
129
+ } from "./meter.js";
130
+
120
131
  export {
121
132
  DAY_SECONDS,
122
133
  DEFAULT_BURST,
package/src/meter.ts ADDED
@@ -0,0 +1,267 @@
1
+ /**
2
+ * What is left at the vendor, read from the answer you already got.
3
+ *
4
+ * ── WHY THIS IS NOT A POLLER ─────────────────────────────────────────────────
5
+ *
6
+ * The obvious design is to ask each provider how much quota remains. That
7
+ * design is wrong, and it is wrong in the direction that hurts: it reports a
8
+ * full tank during an outage.
9
+ *
10
+ * Measured on 2026-09-11, on one live key, within the same second:
11
+ *
12
+ * GET /api/v1/key → limit_remaining: null, usage_daily: 0
13
+ * POST /chat/completions → 429, x-ratelimit-remaining: 0 of 50,
14
+ * "Rate limit exceeded: free-models-per-day"
15
+ *
16
+ * Both answers are from OpenRouter about the same account. The account was
17
+ * locked out. The usage endpoint tracks money, free models cost nothing, and
18
+ * the limit that actually binds is a REQUEST count that endpoint never reports.
19
+ * A dashboard built on the first line would have shown an untouched allowance
20
+ * while every call was failing.
21
+ *
22
+ * So the meter reads the headers on calls you were making anyway. No extra
23
+ * request, no extra quota spent to find out how much quota is left, and the
24
+ * number comes from the same exchange that either worked or did not.
25
+ *
26
+ * ── THE THREE STATES ─────────────────────────────────────────────────────────
27
+ *
28
+ * A provider you have not called today reports NOTHING, and that is a third
29
+ * state — not zero, not full. This module never invents a reading: no header,
30
+ * no `QuotaReading`. Callers must render the absence as "unknown", because
31
+ * drawing it as full repeats the bug above and drawing it as empty invents an
32
+ * outage.
33
+ *
34
+ * ── VENDORS DISAGREE ABOUT WHAT THEIR OWN HEADERS MEAN ───────────────────────
35
+ *
36
+ * `x-ratelimit-remaining-requests` is a per-DAY count at Groq and a per-MINUTE
37
+ * count elsewhere. The window is therefore read from a per-provider profile
38
+ * where one is known, from the header name where it says so (`...-day`), and
39
+ * otherwise reported as "unknown" rather than guessed. A confident wrong window
40
+ * turns "you have 900 requests left today" into "…this minute", which is the
41
+ * kind of error nobody catches until the dashboard has been trusted for a week.
42
+ */
43
+
44
+ import type { Link } from "./chain.js";
45
+
46
+ /** What is being counted. */
47
+ export type QuotaScope = "requests" | "tokens";
48
+
49
+ /** The period the count refreshes over. `unknown` is a real answer. */
50
+ export type QuotaWindow = "minute" | "day" | "unknown";
51
+
52
+ /** One observation of one vendor counter, at one moment. */
53
+ export interface QuotaReading {
54
+ /** Provider id, e.g. "groq". */
55
+ provider: string;
56
+ /** The model the call named — Groq meters per model, so this matters. */
57
+ model: string;
58
+ scope: QuotaScope;
59
+ window: QuotaWindow;
60
+ /** The ceiling, when the vendor states it. */
61
+ limit: number | null;
62
+ /** What is left. The number this module exists to obtain. */
63
+ remaining: number;
64
+ /** When the counter refills, epoch ms. Null when the vendor did not say. */
65
+ resetAt: number | null;
66
+ /** The header (or body) this came from — so a wrong number is traceable. */
67
+ source: string;
68
+ /** Epoch ms. A reading is evidence about a moment, not a standing fact. */
69
+ observedAt: number;
70
+ }
71
+
72
+ /** Anything header-shaped. Keeps this module free of a DOM/undici dependency. */
73
+ export interface HeaderBag {
74
+ get(name: string): string | null;
75
+ }
76
+
77
+ /**
78
+ * Per-provider correction for headers whose name does not state their window.
79
+ *
80
+ * Only entries verified against the vendor's own documentation or a live
81
+ * response belong here. An unlisted provider yields `unknown`, which is the
82
+ * honest answer and renders as such.
83
+ */
84
+ const PROVIDER_WINDOWS: Record<string, Partial<Record<QuotaScope, QuotaWindow>>> = {
85
+ // Verified live 2026-09-11: `x-ratelimit-remaining-requests` counts the DAY
86
+ // (1,000 per day), while `-tokens` counts the minute (8,000 per minute).
87
+ // Reading both as per-minute understates the day by three orders of
88
+ // magnitude; reading both as daily hides the limit that actually throttles.
89
+ groq: { requests: "day", tokens: "minute" },
90
+ // Verified live 2026-09-11: the unpaid tier is 50 REQUESTS per day, and the
91
+ // bare `x-ratelimit-remaining` on the chat response is that counter.
92
+ openrouter: { requests: "day" },
93
+ // Documented per-minute, with separate `-day` twins this parser reads by name.
94
+ sambanova: { requests: "minute" },
95
+ ovh: { requests: "minute" },
96
+ mistral: { requests: "minute" },
97
+ };
98
+
99
+ /**
100
+ * Header names carrying a remaining count, most specific first.
101
+ *
102
+ * Order matters: `...-requests-day` must be tested before `...-requests`, or
103
+ * the day counter is read as the minute counter under the shorter name.
104
+ */
105
+ const REMAINING_HEADERS: Array<{ name: string; scope: QuotaScope; window?: QuotaWindow }> = [
106
+ { name: "x-ratelimit-remaining-requests-day", scope: "requests", window: "day" },
107
+ { name: "x-ratelimit-remaining-tokens-day", scope: "tokens", window: "day" },
108
+ { name: "x-ratelimit-remaining-requests-minute", scope: "requests", window: "minute" },
109
+ { name: "x-ratelimit-remaining-minute", scope: "requests", window: "minute" },
110
+ { name: "x-ratelimit-remaining-requests", scope: "requests" },
111
+ { name: "x-ratelimit-remaining-tokens", scope: "tokens" },
112
+ // Bare forms. OpenRouter uses `x-ratelimit-remaining`; OVHcloud drops the
113
+ // `x-` prefix entirely. Both count requests.
114
+ { name: "x-ratelimit-remaining", scope: "requests" },
115
+ { name: "ratelimit-remaining", scope: "requests" },
116
+ ];
117
+
118
+ /** The limit header paired with a remaining header, by substitution. */
119
+ function limitNameFor(remainingName: string): string {
120
+ return remainingName.replace("remaining", "limit");
121
+ }
122
+
123
+ /** The reset header paired with a remaining header, by substitution. */
124
+ function resetNameFor(remainingName: string): string {
125
+ return remainingName.replace("remaining", "reset");
126
+ }
127
+
128
+ /**
129
+ * Parse a reset value into epoch milliseconds.
130
+ *
131
+ * Vendors encode this three incompatible ways and none of them say which:
132
+ *
133
+ * "1789171200000" OpenRouter — epoch MILLISECONDS (a Sept 2026 date)
134
+ * "1m26.4s" Groq — a duration, compound, fractional
135
+ * "547ms" Groq — a duration under a second
136
+ * "60" several — seconds from now
137
+ *
138
+ * The discriminator is magnitude, not format: a value past the year 2001 in
139
+ * milliseconds cannot be a duration anyone would wait. Returns null rather than
140
+ * guessing when nothing parses, because a wrong reset time tells the operator
141
+ * to come back at the wrong hour.
142
+ */
143
+ export function parseResetAt(raw: string | null, now = Date.now()): number | null {
144
+ if (!raw) return null;
145
+ const value = raw.trim();
146
+ if (value === "") return null;
147
+
148
+ // Bare digits: epoch ms if implausibly large to be a wait, else seconds.
149
+ if (/^\d+$/.test(value)) {
150
+ const n = Number(value);
151
+ // 10^12 ms ≈ 2001. No vendor asks you to wait thirty years.
152
+ return n > 1e12 ? n : now + n * 1000;
153
+ }
154
+
155
+ // Duration forms: 1h2m3.4s, 56m26.88s, 547ms, 3.6s.
156
+ const ms = /^(\d+(?:\.\d+)?)ms$/.exec(value);
157
+ if (ms) return now + Number(ms[1]);
158
+
159
+ const parts = /^(?:(\d+(?:\.\d+)?)h)?(?:(\d+(?:\.\d+)?)m(?!s))?(?:(\d+(?:\.\d+)?)s)?$/.exec(
160
+ value,
161
+ );
162
+ if (parts && (parts[1] || parts[2] || parts[3])) {
163
+ const hours = Number(parts[1] ?? 0);
164
+ const mins = Number(parts[2] ?? 0);
165
+ const secs = Number(parts[3] ?? 0);
166
+ return now + (hours * 3600 + mins * 60 + secs) * 1000;
167
+ }
168
+
169
+ return null;
170
+ }
171
+
172
+ /** Resolve the window for a header that did not name one. */
173
+ function windowFor(providerId: string, scope: QuotaScope, stated?: QuotaWindow): QuotaWindow {
174
+ if (stated) return stated;
175
+ return PROVIDER_WINDOWS[providerId]?.[scope] ?? "unknown";
176
+ }
177
+
178
+ /**
179
+ * Every remaining-count this response disclosed.
180
+ *
181
+ * Returns an empty array when the vendor said nothing, which is the common case
182
+ * — Gemini sends no rate-limit headers at all, and several others only meter
183
+ * server-side. An empty array means "did not say", never "nothing left".
184
+ */
185
+ export function readQuota(headers: HeaderBag, link: Link, now = Date.now()): QuotaReading[] {
186
+ const readings: QuotaReading[] = [];
187
+ const seen = new Set<string>();
188
+
189
+ for (const entry of REMAINING_HEADERS) {
190
+ const raw = headers.get(entry.name);
191
+ if (raw === null || raw.trim() === "") continue;
192
+
193
+ const remaining = Number(raw);
194
+ if (!Number.isFinite(remaining)) continue;
195
+
196
+ const window = windowFor(link.provider.id, entry.scope, entry.window);
197
+ // A shorter header name must not overwrite the more specific one it is a
198
+ // prefix of: once requests/day is known, a bare requests header is the same
199
+ // counter reported less precisely.
200
+ const key = `${entry.scope}:${window}`;
201
+ if (seen.has(key)) continue;
202
+ seen.add(key);
203
+
204
+ const limitRaw = headers.get(limitNameFor(entry.name));
205
+ const limit = limitRaw === null ? null : Number(limitRaw);
206
+
207
+ readings.push({
208
+ provider: link.provider.id,
209
+ model: link.model,
210
+ scope: entry.scope,
211
+ window,
212
+ limit: limit !== null && Number.isFinite(limit) ? limit : null,
213
+ remaining,
214
+ resetAt: parseResetAt(headers.get(resetNameFor(entry.name)), now),
215
+ source: entry.name,
216
+ observedAt: now,
217
+ });
218
+ }
219
+
220
+ return readings;
221
+ }
222
+
223
+ /**
224
+ * A refusal is the most reliable reading there is.
225
+ *
226
+ * A 429 states the one fact a dashboard most needs and most often has stale:
227
+ * this vendor is spent. It is worth recording even when the response carried no
228
+ * usable headers, because it corrects a local counter that had drifted
229
+ * optimistic — the counter is a model of the vendor, and this is the vendor
230
+ * disagreeing with it.
231
+ */
232
+ export function readingFromRefusal(
233
+ link: Link,
234
+ retryAfterSec: number | null,
235
+ scope: QuotaScope = "requests",
236
+ now = Date.now(),
237
+ ): QuotaReading {
238
+ return {
239
+ provider: link.provider.id,
240
+ model: link.model,
241
+ scope,
242
+ window: windowFor(link.provider.id, scope),
243
+ limit: null,
244
+ remaining: 0,
245
+ resetAt: retryAfterSec === null ? null : now + retryAfterSec * 1000,
246
+ source: "429",
247
+ observedAt: now,
248
+ };
249
+ }
250
+
251
+ /**
252
+ * Turn a remaining-token count into the unit a person thinks in.
253
+ *
254
+ * Nobody has an intuition for a token. "About 40 more answers" is actionable;
255
+ * "7,927 tokens" is a number the reader has to convert before it means
256
+ * anything, and they will convert it wrongly.
257
+ *
258
+ * `tokensPerTurn` is the caller's measured average, because it is a property of
259
+ * their prompts, not of this package. Returns null when the reading cannot
260
+ * support the translation, so the caller shows the raw figure rather than a
261
+ * fabricated one.
262
+ */
263
+ export function answersRemaining(reading: QuotaReading, tokensPerTurn: number): number | null {
264
+ if (reading.scope === "requests") return Math.max(0, Math.floor(reading.remaining));
265
+ if (!Number.isFinite(tokensPerTurn) || tokensPerTurn <= 0) return null;
266
+ return Math.max(0, Math.floor(reading.remaining / tokensPerTurn));
267
+ }