@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 +44 -0
- package/dist/complete.d.ts +18 -0
- package/dist/complete.js +23 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/meter.d.ts +117 -0
- package/dist/meter.js +217 -0
- package/package.json +1 -1
- package/src/complete.ts +39 -0
- package/src/index.ts +11 -0
- package/src/meter.ts +267 -0
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
|
package/dist/complete.d.ts
CHANGED
|
@@ -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
|
//
|
package/dist/meter.d.ts
ADDED
|
@@ -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.
|
|
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
|
+
}
|