@bitbaum/ai-kit 0.7.0 → 0.9.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 +117 -9
- package/dist/complete.d.ts +26 -0
- package/dist/complete.js +65 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/liveness.d.ts +112 -0
- package/dist/liveness.js +208 -0
- package/package.json +1 -1
- package/src/complete.ts +107 -1
- package/src/index.ts +9 -0
- package/src/liveness.ts +279 -0
package/README.md
CHANGED
|
@@ -58,6 +58,117 @@ the same org-wide daily budget, so when the day runs dry every link in that
|
|
|
58
58
|
via a text tool protocol, not native `tool_calls`. A native-only client would
|
|
59
59
|
have silently lost most of the chain.
|
|
60
60
|
|
|
61
|
+
### Make the call — `complete()` owns the fetch
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
import { complete, freeChain, usableChain, createHealthTracker } from '@bitbaum/ai-kit';
|
|
65
|
+
|
|
66
|
+
export const llmHealth = createHealthTracker();
|
|
67
|
+
const chain = usableChain(freeChain('MYAPP'), process.env);
|
|
68
|
+
|
|
69
|
+
const { text, id } = await complete({
|
|
70
|
+
chain,
|
|
71
|
+
health: llmHealth,
|
|
72
|
+
maxTokens: 500,
|
|
73
|
+
messages: [{ role: 'user', content: 'Summarise this in one line.' }],
|
|
74
|
+
});
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
For four releases this package shipped the *decisions* and told you to keep the
|
|
78
|
+
fetch. The rule read well and it was wrong: measured 2026-09-05, this fleet ran
|
|
79
|
+
**eight** hand-rolled clients, **two** of which told the three kinds of 429
|
|
80
|
+
apart — while `ai-forms`, which ships a working route factory, had more than
|
|
81
|
+
twice this package's adoption. A package that hands you a working call gets
|
|
82
|
+
installed; one that hands you advice about calls does not.
|
|
83
|
+
|
|
84
|
+
`complete()` is the chain walk plus the request, and it carries the parts that
|
|
85
|
+
kept getting left out of the hand-rolled ones:
|
|
86
|
+
|
|
87
|
+
- a **200 with empty content is a failure**, not an answer — reasoning models
|
|
88
|
+
and some vendors return exactly that, and every client that read
|
|
89
|
+
`choices[0].message.content || ''` shipped the empty string to a user;
|
|
90
|
+
- a **daily** 429 marks the whole vendor dead for the walk, instead of trying
|
|
91
|
+
its other models against the same exhausted org-wide budget;
|
|
92
|
+
- a **size** 429 ends the walk rather than demoting to a *smaller* ceiling,
|
|
93
|
+
which is strictly worse;
|
|
94
|
+
- the vendor's response body survives into the error, so an exhausted day is
|
|
95
|
+
distinguishable from a momentary burst in a log.
|
|
96
|
+
|
|
97
|
+
**`maxTokens` has a floor, and it is higher than you think.** The chain leads
|
|
98
|
+
with reasoning models, which spend the budget on hidden thinking before emitting
|
|
99
|
+
a visible token: `groq/openai/gpt-oss-20b` answered *empty* at 16 and correctly
|
|
100
|
+
at 256 for the same one-word question. A mean budget makes a healthy model look
|
|
101
|
+
dead.
|
|
102
|
+
|
|
103
|
+
**Every link gets its own deadline** (`timeoutMs`, default 30s). A vendor that
|
|
104
|
+
accepts the connection and then never answers is the most common partial outage
|
|
105
|
+
there is, and it is the one a fallback chain is least able to survive: without a
|
|
106
|
+
deadline `await fetch` never returns, link two is never reached, and the chain
|
|
107
|
+
that exists to survive an outage becomes the thing holding the request open.
|
|
108
|
+
|
|
109
|
+
Note it is *per link*, and that it is not the same thing as `signal`:
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
// ✗ WRONG — link one spends the whole budget, links two and three inherit an
|
|
113
|
+
// already-aborted signal, and the "fallback" reports every vendor broken.
|
|
114
|
+
complete({ chain, signal: AbortSignal.timeout(10_000), messages });
|
|
115
|
+
|
|
116
|
+
// ✓ RIGHT — each link gets ten seconds; `signal` stays what it should be,
|
|
117
|
+
// the caller going away.
|
|
118
|
+
complete({ chain, timeoutMs: 10_000, signal: request.signal, messages });
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
When the caller's `signal` aborts, the walk stops rather than touring the
|
|
122
|
+
remaining vendors: the request nobody is waiting for should not spend the daily
|
|
123
|
+
budget, nor report "every vendor failed" about vendors that were never asked.
|
|
124
|
+
|
|
125
|
+
`tryChain` stays for a caller with a genuinely unusual request to make.
|
|
126
|
+
|
|
127
|
+
### Does it work RIGHT NOW? — a probe, not a guess
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
// app/api/health/ai/route.ts — Next App Router, Hono, Deno and Bun all take
|
|
131
|
+
// this shape directly.
|
|
132
|
+
import { createAiHealthHandler, freeChain, usableChain } from '@bitbaum/ai-kit';
|
|
133
|
+
import { llmHealth } from '@/lib/llm-health';
|
|
134
|
+
|
|
135
|
+
const handler = createAiHealthHandler({
|
|
136
|
+
chain: usableChain(freeChain('MYAPP'), process.env),
|
|
137
|
+
health: llmHealth,
|
|
138
|
+
secret: process.env.AI_PROBE_SECRET,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
export const GET = handler;
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
```
|
|
145
|
+
GET /api/health/ai free. What happened last time.
|
|
146
|
+
GET /api/health/ai?probe=1 + the secret makes a real call. 200 or 503.
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
**Why a probe and not a passive read.** Absence of failure is not evidence of
|
|
150
|
+
success. A tracker that has recorded nothing looks identical whether the chain
|
|
151
|
+
is perfect or every key is missing — and straight after a deploy that is exactly
|
|
152
|
+
the state it is in. Observed converting the first app: the deploy was green, the
|
|
153
|
+
bundle provably held the new code, both keys were present, `/api/health`
|
|
154
|
+
returned 200, and `llm.status` was `"unknown"`. Every available signal said
|
|
155
|
+
"probably fine" and none said "works". The only paths that would have answered
|
|
156
|
+
were an admin-authenticated form and two cron jobs that **email real users** —
|
|
157
|
+
verifying a deploy must never require spamming somebody.
|
|
158
|
+
|
|
159
|
+
**Why it is gated and cached.** A probe spends real tokens from a daily budget
|
|
160
|
+
shared with the app's actual features, so an ungated one on a health route is a
|
|
161
|
+
self-inflicted outage: a monitor polling every 30s would drain the allowance and
|
|
162
|
+
take the AI features down with it. So a probe runs only on `?probe=1` **and**
|
|
163
|
+
with the secret, a *success* is cached for 10 minutes (returned with `cached`
|
|
164
|
+
and its age, because a nine-minute-old success is a different claim from a fresh
|
|
165
|
+
one), and a **failure is never cached** — the whole point is the truth about
|
|
166
|
+
right now.
|
|
167
|
+
|
|
168
|
+
With no secret configured the route answers **501**, not an open probe: an app
|
|
169
|
+
that forgets to set one gets a route that cannot spend money, rather than one
|
|
170
|
+
that can.
|
|
171
|
+
|
|
61
172
|
### Is it up? — walk the chain, and know when none of it worked
|
|
62
173
|
|
|
63
174
|
A chain nobody walks is a list, not a fallback. This was found sitting unused
|
|
@@ -79,7 +190,7 @@ const { text } = await tryChain(chain, {
|
|
|
79
190
|
});
|
|
80
191
|
```
|
|
81
192
|
|
|
82
|
-
|
|
193
|
+
`attempt` makes the real request; `tryChain` only
|
|
83
194
|
decides which link goes next and throws `ChainExhaustedError` (naming every
|
|
84
195
|
link's failure, not just the last) when none of them work.
|
|
85
196
|
|
|
@@ -180,14 +291,11 @@ React lives on its own subpath and is an **optional** peer, so importing
|
|
|
180
291
|
|
|
181
292
|
## What it deliberately does not ship
|
|
182
293
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
in this fleet and it is the one that broke the rule, by shipping a route factory
|
|
189
|
-
and a React hook. A package that hands you a working route gets installed; one
|
|
190
|
-
that hands you advice about routes does not.
|
|
294
|
+
**~~An HTTP client.~~** It ships one now — see [`complete()`](#make-the-call--complete-owns-the-fetch).
|
|
295
|
+
The old rule ("every app has its own calling conventions, and replacing those is
|
|
296
|
+
a rewrite rather than an adoption") described this fleet's duplication
|
|
297
|
+
accurately and then protected it: the conventions differed because nothing had
|
|
298
|
+
ever offered to own them.
|
|
191
299
|
|
|
192
300
|
**Model values.** Which ids are free, which are billed, and which your account
|
|
193
301
|
may use are properties of *your* deployment. Centralise the rule, assert it
|
package/dist/complete.d.ts
CHANGED
|
@@ -97,7 +97,33 @@ export interface CompleteOptions {
|
|
|
97
97
|
model?: string;
|
|
98
98
|
env?: Env;
|
|
99
99
|
health?: HealthTracker;
|
|
100
|
+
/**
|
|
101
|
+
* The CALLER's cancellation, covering the whole walk. When this aborts, the
|
|
102
|
+
* walk stops — the caller has gone, so trying the next vendor on their behalf
|
|
103
|
+
* is work nobody is waiting for.
|
|
104
|
+
*
|
|
105
|
+
* Do not use this as a timeout. See `timeoutMs`.
|
|
106
|
+
*/
|
|
100
107
|
signal?: AbortSignal;
|
|
108
|
+
/**
|
|
109
|
+
* How long ONE link may take before it is abandoned and the next is tried.
|
|
110
|
+
* Default 30s. Set 0 to wait forever (not advised).
|
|
111
|
+
*
|
|
112
|
+
* Per LINK, and that is the whole point. A vendor that accepts the connection
|
|
113
|
+
* and then never answers is the most common partial outage there is, and it
|
|
114
|
+
* is the one a fallback chain is least able to survive: without a deadline,
|
|
115
|
+
* `await fetch` simply never returns and link two is never reached. A chain
|
|
116
|
+
* that cannot time out is not a fallback for the failure mode it most needs
|
|
117
|
+
* to cover.
|
|
118
|
+
*
|
|
119
|
+
* It is deliberately NOT the caller's `signal`. A caller who passes a 10s
|
|
120
|
+
* budget as `signal` has the first link spend all of it, and links two
|
|
121
|
+
* onward inherit a signal that is already aborted — so the "fallback" fails
|
|
122
|
+
* instantly and reports every vendor broken when only the first was slow.
|
|
123
|
+
* Handing each link its own budget is the only shape in which a deadline and
|
|
124
|
+
* a fallback can both be true.
|
|
125
|
+
*/
|
|
126
|
+
timeoutMs?: number;
|
|
101
127
|
/**
|
|
102
128
|
* Set this GENEROUSLY, or a healthy model looks dead.
|
|
103
129
|
*
|
package/dist/complete.js
CHANGED
|
@@ -79,6 +79,53 @@ export class LinkFailure extends Error {
|
|
|
79
79
|
this.retryAfter = init.retryAfter;
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* Long enough that a slow-but-working reasoning model finishes, short enough
|
|
84
|
+
* that a hung vendor does not hold a request open until something upstream
|
|
85
|
+
* gives up on it.
|
|
86
|
+
*/
|
|
87
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
88
|
+
/**
|
|
89
|
+
* One link's deadline, composed with the caller's cancellation.
|
|
90
|
+
*
|
|
91
|
+
* Hand-rolled rather than `AbortSignal.any`, which landed in Node 20.3 — this
|
|
92
|
+
* package supports Node >= 20, and a helper that works on 20.0 costs eight
|
|
93
|
+
* lines while an engines bump costs every consumer a decision.
|
|
94
|
+
*/
|
|
95
|
+
function linkDeadline(caller, timeoutMs) {
|
|
96
|
+
if (timeoutMs <= 0) {
|
|
97
|
+
return { signal: caller, timedOut: false, timeoutMs, dispose() { } };
|
|
98
|
+
}
|
|
99
|
+
const controller = new AbortController();
|
|
100
|
+
const state = { timedOut: false };
|
|
101
|
+
// Deliberately NOT unref'd. It is tempting — a stray timer holding a process
|
|
102
|
+
// open is a real nuisance — but this one is always cleared in `dispose`, so
|
|
103
|
+
// there is nothing to save, and an unref'd timer stops firing whenever
|
|
104
|
+
// nothing else keeps the loop alive. That turns the deadline into a deadline
|
|
105
|
+
// that sometimes does not happen, which is worse than none at all.
|
|
106
|
+
const timer = setTimeout(() => {
|
|
107
|
+
state.timedOut = true;
|
|
108
|
+
controller.abort(new Error(`link timeout after ${timeoutMs}ms`));
|
|
109
|
+
}, timeoutMs);
|
|
110
|
+
const onCallerAbort = () => controller.abort(caller?.reason);
|
|
111
|
+
if (caller) {
|
|
112
|
+
if (caller.aborted)
|
|
113
|
+
controller.abort(caller.reason);
|
|
114
|
+
else
|
|
115
|
+
caller.addEventListener("abort", onCallerAbort, { once: true });
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
signal: controller.signal,
|
|
119
|
+
get timedOut() {
|
|
120
|
+
return state.timedOut;
|
|
121
|
+
},
|
|
122
|
+
timeoutMs,
|
|
123
|
+
dispose() {
|
|
124
|
+
clearTimeout(timer);
|
|
125
|
+
caller?.removeEventListener("abort", onCallerAbort);
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
82
129
|
/** `provider/model` — the id worth logging, because the model alone does not say whose meter it drew on. */
|
|
83
130
|
export function linkId(link) {
|
|
84
131
|
return `${link.provider.id}/${link.model}`;
|
|
@@ -139,20 +186,31 @@ async function callLink(link, options, key) {
|
|
|
139
186
|
...(options.tools === undefined ? {} : { tools: options.tools }),
|
|
140
187
|
...options.extraBody,
|
|
141
188
|
};
|
|
189
|
+
const deadline = linkDeadline(options.signal, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
142
190
|
let res;
|
|
143
191
|
try {
|
|
144
192
|
res = await doFetch(`${link.provider.baseUrl}/chat/completions`, {
|
|
145
193
|
method: "POST",
|
|
146
194
|
headers: { "content-type": "application/json", authorization: `Bearer ${key}` },
|
|
147
195
|
body: JSON.stringify(body),
|
|
148
|
-
signal:
|
|
196
|
+
signal: deadline.signal,
|
|
149
197
|
});
|
|
150
198
|
}
|
|
151
199
|
catch (error) {
|
|
152
200
|
// A transport failure (DNS, TLS, abort) is not the vendor's answer, so it
|
|
153
201
|
// carries no rate-limit kind — it demotes like any other link failure.
|
|
202
|
+
//
|
|
203
|
+
// Name a timeout as a timeout. "The operation was aborted" in a log is
|
|
204
|
+
// indistinguishable from a caller cancelling, and the two want opposite
|
|
205
|
+
// reactions from whoever reads it.
|
|
206
|
+
if (deadline.timedOut) {
|
|
207
|
+
throw new LinkFailure(link, `${linkId(link)}: no response within ${deadline.timeoutMs}ms — abandoned, trying the next link`);
|
|
208
|
+
}
|
|
154
209
|
throw new LinkFailure(link, `${linkId(link)}: ${error.message}`);
|
|
155
210
|
}
|
|
211
|
+
finally {
|
|
212
|
+
deadline.dispose();
|
|
213
|
+
}
|
|
156
214
|
const text = await res.text();
|
|
157
215
|
if (!res.ok) {
|
|
158
216
|
if (res.status === 429) {
|
|
@@ -225,6 +283,12 @@ export async function complete(options) {
|
|
|
225
283
|
options.onLinkFailure?.(link, failure);
|
|
226
284
|
if (failure.kind === "daily")
|
|
227
285
|
deadProviders.add(link.provider.id);
|
|
286
|
+
// The caller cancelled — the request they were waiting on is gone. Walking
|
|
287
|
+
// the rest of the chain now would spend their daily budget on an answer
|
|
288
|
+
// nobody will read, and would report "every vendor failed" about vendors
|
|
289
|
+
// that were never asked.
|
|
290
|
+
if (options.signal?.aborted)
|
|
291
|
+
break;
|
|
228
292
|
// Stepping down after a size 429 reaches a model with a smaller ceiling —
|
|
229
293
|
// strictly worse. Stop, and let the caller shorten the prompt.
|
|
230
294
|
if (failure.kind === "size")
|
package/dist/index.d.ts
CHANGED
|
@@ -54,5 +54,6 @@ export { type CatalogVerdict, type CheckCatalogOptions, checkCatalog, hasRot, de
|
|
|
54
54
|
export { type ChainAttemptFailure, type TryChainOptions, ChainExhaustedError, tryChain, } from "./attempt.js";
|
|
55
55
|
export { type ChatMessage, type ToolCall, type CompleteOptions, type CompleteResult, LinkFailure, complete, linkId, } from "./complete.js";
|
|
56
56
|
export { type HealthStatus, type Health, type HealthTrackerOptions, type HealthTracker, createHealthTracker, } from "./health.js";
|
|
57
|
+
export { type LivenessResult, type LivenessOptions, type LivenessProbe, type AiHealthHandlerOptions, createLivenessProbe, createAiHealthHandler, } from "./liveness.js";
|
|
57
58
|
export { type RateLimitKind, classifyRateLimit, retryAfterSeconds, humanizeWait, rateLimitMessage, } from "./limits.js";
|
|
58
59
|
export { DAY_SECONDS, DEFAULT_BURST, type ShareInput, type ShareReason, type ShareDecision, fairShare, utcDayElapsed, utcDayKey, } from "./fair-share.js";
|
package/dist/index.js
CHANGED
|
@@ -54,6 +54,7 @@ export { checkCatalog, hasRot, deadProviders, catalogReport, } from "./catalog.j
|
|
|
54
54
|
export { ChainExhaustedError, tryChain, } from "./attempt.js";
|
|
55
55
|
export { LinkFailure, complete, linkId, } from "./complete.js";
|
|
56
56
|
export { createHealthTracker, } from "./health.js";
|
|
57
|
+
export { createLivenessProbe, createAiHealthHandler, } from "./liveness.js";
|
|
57
58
|
export { classifyRateLimit, retryAfterSeconds, humanizeWait, rateLimitMessage, } from "./limits.js";
|
|
58
59
|
export { DAY_SECONDS, DEFAULT_BURST, fairShare, utcDayElapsed, utcDayKey, } from "./fair-share.js";
|
|
59
60
|
// Form filling lives at `ai-kit/forms`, NOT here.
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Can this deployment reach a model RIGHT NOW?
|
|
3
|
+
*
|
|
4
|
+
* ── The gap this closes ──────────────────────────────────────────────────────
|
|
5
|
+
* A health route that reports `createHealthTracker().getHealth()` reports what
|
|
6
|
+
* happened the last time the app happened to call a model. Straight after a
|
|
7
|
+
* deploy that is `"unknown"` — no calls yet — and "unknown" is what it stays
|
|
8
|
+
* until real traffic arrives. So the one question a deploy needs answered ("did
|
|
9
|
+
* I just ship a working AI path?") is exactly the one it cannot answer.
|
|
10
|
+
*
|
|
11
|
+
* Observed 2026-09-05, converting the first app to `complete()`: the deploy was
|
|
12
|
+
* green, the bundle provably contained the new code, both keys were present,
|
|
13
|
+
* `/api/health` returned 200 — and `llm.status` was `"unknown"`. Every signal
|
|
14
|
+
* available said "probably fine". The only paths that would have produced a
|
|
15
|
+
* real answer were an admin-authenticated form and two cron jobs that EMAIL
|
|
16
|
+
* REAL USERS. Verifying a deploy must never require spamming somebody.
|
|
17
|
+
*
|
|
18
|
+
* ── Why it is a probe and not a passive read ─────────────────────────────────
|
|
19
|
+
* Absence of failure is not evidence of success. A tracker that has recorded
|
|
20
|
+
* nothing looks identical whether the chain is perfect or every key is missing.
|
|
21
|
+
* The only thing that distinguishes them is making a call — so this makes one,
|
|
22
|
+
* deliberately, on demand.
|
|
23
|
+
*
|
|
24
|
+
* ── Why it must be gated and cached ──────────────────────────────────────────
|
|
25
|
+
* This spends real tokens from a free daily budget shared across the whole org.
|
|
26
|
+
* An ungated probe on a health route is a self-inflicted outage: a monitor
|
|
27
|
+
* polling every 30s would drain a 100k/day allowance and take the app's actual
|
|
28
|
+
* AI features down with it. So:
|
|
29
|
+
*
|
|
30
|
+
* - a probe runs ONLY when asked for explicitly (`?probe=1`) AND the caller
|
|
31
|
+
* proves it is allowed to (a secret), never on an ordinary health poll;
|
|
32
|
+
* - a successful probe is CACHED for `minIntervalMs` (default 10 minutes),
|
|
33
|
+
* so even an authorised caller in a retry loop cannot burn the budget. The
|
|
34
|
+
* cached answer is returned with `cached: true` and the age, because a
|
|
35
|
+
* nine-minute-old success is a different claim from a fresh one and the
|
|
36
|
+
* reader deserves to know which they got.
|
|
37
|
+
*
|
|
38
|
+
* The prompt is deliberately tiny — a handful of tokens — because the question
|
|
39
|
+
* is "does the pipe carry water", not "is the model any good".
|
|
40
|
+
*/
|
|
41
|
+
import { type CompleteOptions } from "./complete.js";
|
|
42
|
+
import type { HealthTracker } from "./health.js";
|
|
43
|
+
export interface LivenessResult {
|
|
44
|
+
/** Did a model answer? */
|
|
45
|
+
ok: boolean;
|
|
46
|
+
/** `provider/model` that served it, when one did. */
|
|
47
|
+
servedBy?: string;
|
|
48
|
+
/** What the model actually said, trimmed — proof of a real generation, not a 200. */
|
|
49
|
+
answer?: string;
|
|
50
|
+
/** Round-trip milliseconds for a fresh probe. */
|
|
51
|
+
ms?: number;
|
|
52
|
+
/** True when this is a remembered result rather than a call made just now. */
|
|
53
|
+
cached: boolean;
|
|
54
|
+
/** Age of a cached result, in milliseconds. */
|
|
55
|
+
cachedAgeMs?: number;
|
|
56
|
+
/** Every link's failure, when the whole chain was exhausted. */
|
|
57
|
+
failures?: string[];
|
|
58
|
+
/** Why no call was attempted at all (no keys, no links). */
|
|
59
|
+
skipped?: string;
|
|
60
|
+
}
|
|
61
|
+
export interface LivenessOptions extends Omit<CompleteOptions, "messages" | "maxTokens" | "temperature"> {
|
|
62
|
+
/**
|
|
63
|
+
* Don't call again within this window; return the last successful result.
|
|
64
|
+
* Default 10 minutes. Set 0 to disable caching — only for a test.
|
|
65
|
+
*/
|
|
66
|
+
minIntervalMs?: number;
|
|
67
|
+
/** Injected for tests. Defaults to `Date.now`. */
|
|
68
|
+
now?: () => number;
|
|
69
|
+
}
|
|
70
|
+
export interface LivenessProbe {
|
|
71
|
+
/** Make a call (or return a cached success). */
|
|
72
|
+
run(): Promise<LivenessResult>;
|
|
73
|
+
/** Forget any cached success — the next `run` will really call. */
|
|
74
|
+
reset(): void;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Build a probe with its own cache.
|
|
78
|
+
*
|
|
79
|
+
* The cache lives on the instance rather than in a module global so that two
|
|
80
|
+
* apps in one process, or a test, cannot silently share (and satisfy) each
|
|
81
|
+
* other's probe.
|
|
82
|
+
*/
|
|
83
|
+
export declare function createLivenessProbe(options?: LivenessOptions): LivenessProbe;
|
|
84
|
+
export interface AiHealthHandlerOptions extends LivenessOptions {
|
|
85
|
+
/**
|
|
86
|
+
* Shared secret authorising a probe. Compared against the `x-probe-secret`
|
|
87
|
+
* header or a `secret` query parameter.
|
|
88
|
+
*
|
|
89
|
+
* When absent, the handler NEVER probes — it only reports passive health.
|
|
90
|
+
* That default is deliberate: an app that forgets to configure a secret gets
|
|
91
|
+
* a route that cannot spend money, rather than an open endpoint that can.
|
|
92
|
+
*/
|
|
93
|
+
secret?: string;
|
|
94
|
+
/** Passive health to report alongside. Optional. */
|
|
95
|
+
health?: HealthTracker;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* A framework-neutral `Request -> Response` handler for an AI health route.
|
|
99
|
+
*
|
|
100
|
+
* Web-standard on purpose: Next's App Router, Hono, Deno and Bun all accept
|
|
101
|
+
* this shape directly, so adopting it is an export line rather than a port.
|
|
102
|
+
*
|
|
103
|
+
* GET /api/health/ai passive — what happened last time. Free.
|
|
104
|
+
* GET /api/health/ai?probe=1 makes a real call. Requires the secret.
|
|
105
|
+
*
|
|
106
|
+
* A probe without a valid secret is 401 and does NOT fall back to probing.
|
|
107
|
+
*
|
|
108
|
+
* Status codes are chosen so an uptime monitor can watch this URL directly:
|
|
109
|
+
* 200 when the answer is good, 503 when a probe was attempted and the chain
|
|
110
|
+
* could not answer.
|
|
111
|
+
*/
|
|
112
|
+
export declare function createAiHealthHandler(options?: AiHealthHandlerOptions): (request: Request) => Promise<Response>;
|
package/dist/liveness.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Can this deployment reach a model RIGHT NOW?
|
|
3
|
+
*
|
|
4
|
+
* ── The gap this closes ──────────────────────────────────────────────────────
|
|
5
|
+
* A health route that reports `createHealthTracker().getHealth()` reports what
|
|
6
|
+
* happened the last time the app happened to call a model. Straight after a
|
|
7
|
+
* deploy that is `"unknown"` — no calls yet — and "unknown" is what it stays
|
|
8
|
+
* until real traffic arrives. So the one question a deploy needs answered ("did
|
|
9
|
+
* I just ship a working AI path?") is exactly the one it cannot answer.
|
|
10
|
+
*
|
|
11
|
+
* Observed 2026-09-05, converting the first app to `complete()`: the deploy was
|
|
12
|
+
* green, the bundle provably contained the new code, both keys were present,
|
|
13
|
+
* `/api/health` returned 200 — and `llm.status` was `"unknown"`. Every signal
|
|
14
|
+
* available said "probably fine". The only paths that would have produced a
|
|
15
|
+
* real answer were an admin-authenticated form and two cron jobs that EMAIL
|
|
16
|
+
* REAL USERS. Verifying a deploy must never require spamming somebody.
|
|
17
|
+
*
|
|
18
|
+
* ── Why it is a probe and not a passive read ─────────────────────────────────
|
|
19
|
+
* Absence of failure is not evidence of success. A tracker that has recorded
|
|
20
|
+
* nothing looks identical whether the chain is perfect or every key is missing.
|
|
21
|
+
* The only thing that distinguishes them is making a call — so this makes one,
|
|
22
|
+
* deliberately, on demand.
|
|
23
|
+
*
|
|
24
|
+
* ── Why it must be gated and cached ──────────────────────────────────────────
|
|
25
|
+
* This spends real tokens from a free daily budget shared across the whole org.
|
|
26
|
+
* An ungated probe on a health route is a self-inflicted outage: a monitor
|
|
27
|
+
* polling every 30s would drain a 100k/day allowance and take the app's actual
|
|
28
|
+
* AI features down with it. So:
|
|
29
|
+
*
|
|
30
|
+
* - a probe runs ONLY when asked for explicitly (`?probe=1`) AND the caller
|
|
31
|
+
* proves it is allowed to (a secret), never on an ordinary health poll;
|
|
32
|
+
* - a successful probe is CACHED for `minIntervalMs` (default 10 minutes),
|
|
33
|
+
* so even an authorised caller in a retry loop cannot burn the budget. The
|
|
34
|
+
* cached answer is returned with `cached: true` and the age, because a
|
|
35
|
+
* nine-minute-old success is a different claim from a fresh one and the
|
|
36
|
+
* reader deserves to know which they got.
|
|
37
|
+
*
|
|
38
|
+
* The prompt is deliberately tiny — a handful of tokens — because the question
|
|
39
|
+
* is "does the pipe carry water", not "is the model any good".
|
|
40
|
+
*/
|
|
41
|
+
import { complete } from "./complete.js";
|
|
42
|
+
import { ChainExhaustedError } from "./attempt.js";
|
|
43
|
+
const DEFAULT_MIN_INTERVAL_MS = 10 * 60 * 1000;
|
|
44
|
+
/**
|
|
45
|
+
* The budget the probe asks for.
|
|
46
|
+
*
|
|
47
|
+
* NOT small, despite the tiny prompt. The chain leads with REASONING models,
|
|
48
|
+
* which spend this on hidden thinking before emitting a visible token: measured
|
|
49
|
+
* 2026-09-05, groq/openai/gpt-oss-20b answered EMPTY at 16 and correctly at 256
|
|
50
|
+
* for the same one-word question. An empty completion is a failure here (see
|
|
51
|
+
* complete.ts), so a mean budget would make a perfectly healthy deployment
|
|
52
|
+
* report itself dead — the exact false alarm this module exists to prevent.
|
|
53
|
+
*/
|
|
54
|
+
const PROBE_MAX_TOKENS = 256;
|
|
55
|
+
/**
|
|
56
|
+
* Tighter than `complete`'s 30s default, per link.
|
|
57
|
+
*
|
|
58
|
+
* A monitor asking "is the AI up?" gives up long before a chain of 30-second
|
|
59
|
+
* links has finished being patient — and a health route that takes a minute to
|
|
60
|
+
* answer "down" has not answered at all, it has just become a second outage.
|
|
61
|
+
* Ten seconds is far above the ~1s a healthy free-tier link measures.
|
|
62
|
+
*/
|
|
63
|
+
const PROBE_TIMEOUT_MS = 10_000;
|
|
64
|
+
/** A question with one short right answer, cheap to ask and easy to sanity-check. */
|
|
65
|
+
const PROBE_MESSAGES = [
|
|
66
|
+
{ role: "system", content: "Answer with a single word, no punctuation." },
|
|
67
|
+
{ role: "user", content: "What colour is a clear midday sky? Answer in one word." },
|
|
68
|
+
];
|
|
69
|
+
/**
|
|
70
|
+
* Build a probe with its own cache.
|
|
71
|
+
*
|
|
72
|
+
* The cache lives on the instance rather than in a module global so that two
|
|
73
|
+
* apps in one process, or a test, cannot silently share (and satisfy) each
|
|
74
|
+
* other's probe.
|
|
75
|
+
*/
|
|
76
|
+
export function createLivenessProbe(options = {}) {
|
|
77
|
+
const minIntervalMs = options.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS;
|
|
78
|
+
const now = options.now ?? Date.now;
|
|
79
|
+
let lastOk = null;
|
|
80
|
+
return {
|
|
81
|
+
reset() {
|
|
82
|
+
lastOk = null;
|
|
83
|
+
},
|
|
84
|
+
async run() {
|
|
85
|
+
if (lastOk && minIntervalMs > 0) {
|
|
86
|
+
const age = now() - lastOk.at;
|
|
87
|
+
if (age < minIntervalMs) {
|
|
88
|
+
return { ...lastOk.result, cached: true, cachedAgeMs: age };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const started = now();
|
|
92
|
+
try {
|
|
93
|
+
const result = await complete({
|
|
94
|
+
timeoutMs: PROBE_TIMEOUT_MS,
|
|
95
|
+
...options,
|
|
96
|
+
messages: PROBE_MESSAGES,
|
|
97
|
+
maxTokens: PROBE_MAX_TOKENS,
|
|
98
|
+
temperature: 0,
|
|
99
|
+
});
|
|
100
|
+
const answer = result.text.trim();
|
|
101
|
+
const fresh = {
|
|
102
|
+
ok: true,
|
|
103
|
+
servedBy: result.id,
|
|
104
|
+
answer,
|
|
105
|
+
ms: now() - started,
|
|
106
|
+
cached: false,
|
|
107
|
+
};
|
|
108
|
+
lastOk = { at: now(), result: fresh };
|
|
109
|
+
return fresh;
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
// A failure is deliberately NOT cached. Caching it would keep reporting
|
|
113
|
+
// an outage after the vendor recovered, and the whole point is to tell
|
|
114
|
+
// the truth about right now.
|
|
115
|
+
if (error instanceof ChainExhaustedError) {
|
|
116
|
+
return {
|
|
117
|
+
ok: false,
|
|
118
|
+
cached: false,
|
|
119
|
+
ms: now() - started,
|
|
120
|
+
failures: error.failures.map((f) => f.message),
|
|
121
|
+
...(error.failures.length === 0
|
|
122
|
+
? { skipped: "No usable link — every provider is missing its key or has no models." }
|
|
123
|
+
: {}),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
ok: false,
|
|
128
|
+
cached: false,
|
|
129
|
+
ms: now() - started,
|
|
130
|
+
failures: [error instanceof Error ? error.message : String(error)],
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* A framework-neutral `Request -> Response` handler for an AI health route.
|
|
138
|
+
*
|
|
139
|
+
* Web-standard on purpose: Next's App Router, Hono, Deno and Bun all accept
|
|
140
|
+
* this shape directly, so adopting it is an export line rather than a port.
|
|
141
|
+
*
|
|
142
|
+
* GET /api/health/ai passive — what happened last time. Free.
|
|
143
|
+
* GET /api/health/ai?probe=1 makes a real call. Requires the secret.
|
|
144
|
+
*
|
|
145
|
+
* A probe without a valid secret is 401 and does NOT fall back to probing.
|
|
146
|
+
*
|
|
147
|
+
* Status codes are chosen so an uptime monitor can watch this URL directly:
|
|
148
|
+
* 200 when the answer is good, 503 when a probe was attempted and the chain
|
|
149
|
+
* could not answer.
|
|
150
|
+
*/
|
|
151
|
+
export function createAiHealthHandler(options = {}) {
|
|
152
|
+
const { secret, health, ...probeOptions } = options;
|
|
153
|
+
const probe = createLivenessProbe(probeOptions);
|
|
154
|
+
return async function handler(request) {
|
|
155
|
+
const url = new URL(request.url);
|
|
156
|
+
const wantsProbe = url.searchParams.get("probe") === "1";
|
|
157
|
+
const offered = request.headers.get("x-probe-secret") ?? url.searchParams.get("secret") ?? undefined;
|
|
158
|
+
const passive = health ? { health: health.getHealth() } : {};
|
|
159
|
+
if (!wantsProbe) {
|
|
160
|
+
return json(200, { probed: false, ...passive });
|
|
161
|
+
}
|
|
162
|
+
// No secret configured means probing is switched off, which is a different
|
|
163
|
+
// answer from "your secret is wrong" — say so, rather than implying the
|
|
164
|
+
// caller could retry with a better credential.
|
|
165
|
+
if (!secret) {
|
|
166
|
+
return json(501, {
|
|
167
|
+
probed: false,
|
|
168
|
+
error: "Probing is not configured on this deployment (no secret set).",
|
|
169
|
+
...passive,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
if (!offered || !timingSafeEqual(offered, secret)) {
|
|
173
|
+
return json(401, { probed: false, error: "Bad or missing probe secret.", ...passive });
|
|
174
|
+
}
|
|
175
|
+
const result = await probe.run();
|
|
176
|
+
// Record into passive health too, so one probe also answers the next
|
|
177
|
+
// ordinary health poll — otherwise the probe's knowledge dies with it.
|
|
178
|
+
if (health) {
|
|
179
|
+
if (result.ok)
|
|
180
|
+
health.recordSuccess();
|
|
181
|
+
else
|
|
182
|
+
health.recordFailure(new Error(result.failures?.join("; ") ?? "probe failed"));
|
|
183
|
+
}
|
|
184
|
+
return json(result.ok ? 200 : 503, {
|
|
185
|
+
probed: true,
|
|
186
|
+
...result,
|
|
187
|
+
...(health ? { health: health.getHealth() } : {}),
|
|
188
|
+
});
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
function json(status, body) {
|
|
192
|
+
return new Response(JSON.stringify(body), {
|
|
193
|
+
status,
|
|
194
|
+
headers: { "content-type": "application/json", "cache-control": "no-store" },
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Constant-time comparison, so a wrong secret cannot be discovered one
|
|
199
|
+
* character at a time by timing the 401.
|
|
200
|
+
*/
|
|
201
|
+
function timingSafeEqual(a, b) {
|
|
202
|
+
if (a.length !== b.length)
|
|
203
|
+
return false;
|
|
204
|
+
let diff = 0;
|
|
205
|
+
for (let i = 0; i < a.length; i += 1)
|
|
206
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
207
|
+
return diff === 0;
|
|
208
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bitbaum/ai-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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 — 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
|
@@ -102,7 +102,33 @@ export interface CompleteOptions {
|
|
|
102
102
|
model?: string;
|
|
103
103
|
env?: Env;
|
|
104
104
|
health?: HealthTracker;
|
|
105
|
+
/**
|
|
106
|
+
* The CALLER's cancellation, covering the whole walk. When this aborts, the
|
|
107
|
+
* walk stops — the caller has gone, so trying the next vendor on their behalf
|
|
108
|
+
* is work nobody is waiting for.
|
|
109
|
+
*
|
|
110
|
+
* Do not use this as a timeout. See `timeoutMs`.
|
|
111
|
+
*/
|
|
105
112
|
signal?: AbortSignal;
|
|
113
|
+
/**
|
|
114
|
+
* How long ONE link may take before it is abandoned and the next is tried.
|
|
115
|
+
* Default 30s. Set 0 to wait forever (not advised).
|
|
116
|
+
*
|
|
117
|
+
* Per LINK, and that is the whole point. A vendor that accepts the connection
|
|
118
|
+
* and then never answers is the most common partial outage there is, and it
|
|
119
|
+
* is the one a fallback chain is least able to survive: without a deadline,
|
|
120
|
+
* `await fetch` simply never returns and link two is never reached. A chain
|
|
121
|
+
* that cannot time out is not a fallback for the failure mode it most needs
|
|
122
|
+
* to cover.
|
|
123
|
+
*
|
|
124
|
+
* It is deliberately NOT the caller's `signal`. A caller who passes a 10s
|
|
125
|
+
* budget as `signal` has the first link spend all of it, and links two
|
|
126
|
+
* onward inherit a signal that is already aborted — so the "fallback" fails
|
|
127
|
+
* instantly and reports every vendor broken when only the first was slow.
|
|
128
|
+
* Handing each link its own budget is the only shape in which a deadline and
|
|
129
|
+
* a fallback can both be true.
|
|
130
|
+
*/
|
|
131
|
+
timeoutMs?: number;
|
|
106
132
|
/**
|
|
107
133
|
* Set this GENEROUSLY, or a healthy model looks dead.
|
|
108
134
|
*
|
|
@@ -164,6 +190,66 @@ export class LinkFailure extends Error {
|
|
|
164
190
|
}
|
|
165
191
|
}
|
|
166
192
|
|
|
193
|
+
/**
|
|
194
|
+
* Long enough that a slow-but-working reasoning model finishes, short enough
|
|
195
|
+
* that a hung vendor does not hold a request open until something upstream
|
|
196
|
+
* gives up on it.
|
|
197
|
+
*/
|
|
198
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
199
|
+
|
|
200
|
+
interface LinkDeadline {
|
|
201
|
+
signal: AbortSignal | undefined;
|
|
202
|
+
/** True when THIS link's own clock fired, rather than the caller cancelling. */
|
|
203
|
+
readonly timedOut: boolean;
|
|
204
|
+
readonly timeoutMs: number;
|
|
205
|
+
/** Always call. An uncleared timer keeps the event loop alive. */
|
|
206
|
+
dispose(): void;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* One link's deadline, composed with the caller's cancellation.
|
|
211
|
+
*
|
|
212
|
+
* Hand-rolled rather than `AbortSignal.any`, which landed in Node 20.3 — this
|
|
213
|
+
* package supports Node >= 20, and a helper that works on 20.0 costs eight
|
|
214
|
+
* lines while an engines bump costs every consumer a decision.
|
|
215
|
+
*/
|
|
216
|
+
function linkDeadline(caller: AbortSignal | undefined, timeoutMs: number): LinkDeadline {
|
|
217
|
+
if (timeoutMs <= 0) {
|
|
218
|
+
return { signal: caller, timedOut: false, timeoutMs, dispose() {} };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const controller = new AbortController();
|
|
222
|
+
const state = { timedOut: false };
|
|
223
|
+
|
|
224
|
+
// Deliberately NOT unref'd. It is tempting — a stray timer holding a process
|
|
225
|
+
// open is a real nuisance — but this one is always cleared in `dispose`, so
|
|
226
|
+
// there is nothing to save, and an unref'd timer stops firing whenever
|
|
227
|
+
// nothing else keeps the loop alive. That turns the deadline into a deadline
|
|
228
|
+
// that sometimes does not happen, which is worse than none at all.
|
|
229
|
+
const timer = setTimeout(() => {
|
|
230
|
+
state.timedOut = true;
|
|
231
|
+
controller.abort(new Error(`link timeout after ${timeoutMs}ms`));
|
|
232
|
+
}, timeoutMs);
|
|
233
|
+
|
|
234
|
+
const onCallerAbort = () => controller.abort(caller?.reason);
|
|
235
|
+
if (caller) {
|
|
236
|
+
if (caller.aborted) controller.abort(caller.reason);
|
|
237
|
+
else caller.addEventListener("abort", onCallerAbort, { once: true });
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
signal: controller.signal,
|
|
242
|
+
get timedOut() {
|
|
243
|
+
return state.timedOut;
|
|
244
|
+
},
|
|
245
|
+
timeoutMs,
|
|
246
|
+
dispose() {
|
|
247
|
+
clearTimeout(timer);
|
|
248
|
+
caller?.removeEventListener("abort", onCallerAbort);
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
167
253
|
/** `provider/model` — the id worth logging, because the model alone does not say whose meter it drew on. */
|
|
168
254
|
export function linkId(link: Link): string {
|
|
169
255
|
return `${link.provider.id}/${link.model}`;
|
|
@@ -230,18 +316,32 @@ async function callLink(
|
|
|
230
316
|
...options.extraBody,
|
|
231
317
|
};
|
|
232
318
|
|
|
319
|
+
const deadline = linkDeadline(options.signal, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
320
|
+
|
|
233
321
|
let res: Response;
|
|
234
322
|
try {
|
|
235
323
|
res = await doFetch(`${link.provider.baseUrl}/chat/completions`, {
|
|
236
324
|
method: "POST",
|
|
237
325
|
headers: { "content-type": "application/json", authorization: `Bearer ${key}` },
|
|
238
326
|
body: JSON.stringify(body),
|
|
239
|
-
signal:
|
|
327
|
+
signal: deadline.signal,
|
|
240
328
|
});
|
|
241
329
|
} catch (error) {
|
|
242
330
|
// A transport failure (DNS, TLS, abort) is not the vendor's answer, so it
|
|
243
331
|
// carries no rate-limit kind — it demotes like any other link failure.
|
|
332
|
+
//
|
|
333
|
+
// Name a timeout as a timeout. "The operation was aborted" in a log is
|
|
334
|
+
// indistinguishable from a caller cancelling, and the two want opposite
|
|
335
|
+
// reactions from whoever reads it.
|
|
336
|
+
if (deadline.timedOut) {
|
|
337
|
+
throw new LinkFailure(
|
|
338
|
+
link,
|
|
339
|
+
`${linkId(link)}: no response within ${deadline.timeoutMs}ms — abandoned, trying the next link`,
|
|
340
|
+
);
|
|
341
|
+
}
|
|
244
342
|
throw new LinkFailure(link, `${linkId(link)}: ${(error as Error).message}`);
|
|
343
|
+
} finally {
|
|
344
|
+
deadline.dispose();
|
|
245
345
|
}
|
|
246
346
|
|
|
247
347
|
const text = await res.text();
|
|
@@ -328,6 +428,12 @@ export async function complete(options: CompleteOptions): Promise<CompleteResult
|
|
|
328
428
|
|
|
329
429
|
if (failure.kind === "daily") deadProviders.add(link.provider.id);
|
|
330
430
|
|
|
431
|
+
// The caller cancelled — the request they were waiting on is gone. Walking
|
|
432
|
+
// the rest of the chain now would spend their daily budget on an answer
|
|
433
|
+
// nobody will read, and would report "every vendor failed" about vendors
|
|
434
|
+
// that were never asked.
|
|
435
|
+
if (options.signal?.aborted) break;
|
|
436
|
+
|
|
331
437
|
// Stepping down after a size 429 reaches a model with a smaller ceiling —
|
|
332
438
|
// strictly worse. Stop, and let the caller shorten the prompt.
|
|
333
439
|
if (failure.kind === "size") break;
|
package/src/index.ts
CHANGED
|
@@ -100,6 +100,15 @@ export {
|
|
|
100
100
|
createHealthTracker,
|
|
101
101
|
} from "./health.js";
|
|
102
102
|
|
|
103
|
+
export {
|
|
104
|
+
type LivenessResult,
|
|
105
|
+
type LivenessOptions,
|
|
106
|
+
type LivenessProbe,
|
|
107
|
+
type AiHealthHandlerOptions,
|
|
108
|
+
createLivenessProbe,
|
|
109
|
+
createAiHealthHandler,
|
|
110
|
+
} from "./liveness.js";
|
|
111
|
+
|
|
103
112
|
export {
|
|
104
113
|
type RateLimitKind,
|
|
105
114
|
classifyRateLimit,
|
package/src/liveness.ts
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Can this deployment reach a model RIGHT NOW?
|
|
3
|
+
*
|
|
4
|
+
* ── The gap this closes ──────────────────────────────────────────────────────
|
|
5
|
+
* A health route that reports `createHealthTracker().getHealth()` reports what
|
|
6
|
+
* happened the last time the app happened to call a model. Straight after a
|
|
7
|
+
* deploy that is `"unknown"` — no calls yet — and "unknown" is what it stays
|
|
8
|
+
* until real traffic arrives. So the one question a deploy needs answered ("did
|
|
9
|
+
* I just ship a working AI path?") is exactly the one it cannot answer.
|
|
10
|
+
*
|
|
11
|
+
* Observed 2026-09-05, converting the first app to `complete()`: the deploy was
|
|
12
|
+
* green, the bundle provably contained the new code, both keys were present,
|
|
13
|
+
* `/api/health` returned 200 — and `llm.status` was `"unknown"`. Every signal
|
|
14
|
+
* available said "probably fine". The only paths that would have produced a
|
|
15
|
+
* real answer were an admin-authenticated form and two cron jobs that EMAIL
|
|
16
|
+
* REAL USERS. Verifying a deploy must never require spamming somebody.
|
|
17
|
+
*
|
|
18
|
+
* ── Why it is a probe and not a passive read ─────────────────────────────────
|
|
19
|
+
* Absence of failure is not evidence of success. A tracker that has recorded
|
|
20
|
+
* nothing looks identical whether the chain is perfect or every key is missing.
|
|
21
|
+
* The only thing that distinguishes them is making a call — so this makes one,
|
|
22
|
+
* deliberately, on demand.
|
|
23
|
+
*
|
|
24
|
+
* ── Why it must be gated and cached ──────────────────────────────────────────
|
|
25
|
+
* This spends real tokens from a free daily budget shared across the whole org.
|
|
26
|
+
* An ungated probe on a health route is a self-inflicted outage: a monitor
|
|
27
|
+
* polling every 30s would drain a 100k/day allowance and take the app's actual
|
|
28
|
+
* AI features down with it. So:
|
|
29
|
+
*
|
|
30
|
+
* - a probe runs ONLY when asked for explicitly (`?probe=1`) AND the caller
|
|
31
|
+
* proves it is allowed to (a secret), never on an ordinary health poll;
|
|
32
|
+
* - a successful probe is CACHED for `minIntervalMs` (default 10 minutes),
|
|
33
|
+
* so even an authorised caller in a retry loop cannot burn the budget. The
|
|
34
|
+
* cached answer is returned with `cached: true` and the age, because a
|
|
35
|
+
* nine-minute-old success is a different claim from a fresh one and the
|
|
36
|
+
* reader deserves to know which they got.
|
|
37
|
+
*
|
|
38
|
+
* The prompt is deliberately tiny — a handful of tokens — because the question
|
|
39
|
+
* is "does the pipe carry water", not "is the model any good".
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { complete, type CompleteOptions } from "./complete.js";
|
|
43
|
+
import { ChainExhaustedError } from "./attempt.js";
|
|
44
|
+
import type { HealthTracker } from "./health.js";
|
|
45
|
+
|
|
46
|
+
export interface LivenessResult {
|
|
47
|
+
/** Did a model answer? */
|
|
48
|
+
ok: boolean;
|
|
49
|
+
/** `provider/model` that served it, when one did. */
|
|
50
|
+
servedBy?: string;
|
|
51
|
+
/** What the model actually said, trimmed — proof of a real generation, not a 200. */
|
|
52
|
+
answer?: string;
|
|
53
|
+
/** Round-trip milliseconds for a fresh probe. */
|
|
54
|
+
ms?: number;
|
|
55
|
+
/** True when this is a remembered result rather than a call made just now. */
|
|
56
|
+
cached: boolean;
|
|
57
|
+
/** Age of a cached result, in milliseconds. */
|
|
58
|
+
cachedAgeMs?: number;
|
|
59
|
+
/** Every link's failure, when the whole chain was exhausted. */
|
|
60
|
+
failures?: string[];
|
|
61
|
+
/** Why no call was attempted at all (no keys, no links). */
|
|
62
|
+
skipped?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface LivenessOptions extends Omit<
|
|
66
|
+
CompleteOptions,
|
|
67
|
+
"messages" | "maxTokens" | "temperature"
|
|
68
|
+
> {
|
|
69
|
+
/**
|
|
70
|
+
* Don't call again within this window; return the last successful result.
|
|
71
|
+
* Default 10 minutes. Set 0 to disable caching — only for a test.
|
|
72
|
+
*/
|
|
73
|
+
minIntervalMs?: number;
|
|
74
|
+
/** Injected for tests. Defaults to `Date.now`. */
|
|
75
|
+
now?: () => number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const DEFAULT_MIN_INTERVAL_MS = 10 * 60 * 1000;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The budget the probe asks for.
|
|
82
|
+
*
|
|
83
|
+
* NOT small, despite the tiny prompt. The chain leads with REASONING models,
|
|
84
|
+
* which spend this on hidden thinking before emitting a visible token: measured
|
|
85
|
+
* 2026-09-05, groq/openai/gpt-oss-20b answered EMPTY at 16 and correctly at 256
|
|
86
|
+
* for the same one-word question. An empty completion is a failure here (see
|
|
87
|
+
* complete.ts), so a mean budget would make a perfectly healthy deployment
|
|
88
|
+
* report itself dead — the exact false alarm this module exists to prevent.
|
|
89
|
+
*/
|
|
90
|
+
const PROBE_MAX_TOKENS = 256;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Tighter than `complete`'s 30s default, per link.
|
|
94
|
+
*
|
|
95
|
+
* A monitor asking "is the AI up?" gives up long before a chain of 30-second
|
|
96
|
+
* links has finished being patient — and a health route that takes a minute to
|
|
97
|
+
* answer "down" has not answered at all, it has just become a second outage.
|
|
98
|
+
* Ten seconds is far above the ~1s a healthy free-tier link measures.
|
|
99
|
+
*/
|
|
100
|
+
const PROBE_TIMEOUT_MS = 10_000;
|
|
101
|
+
|
|
102
|
+
/** A question with one short right answer, cheap to ask and easy to sanity-check. */
|
|
103
|
+
const PROBE_MESSAGES = [
|
|
104
|
+
{ role: "system" as const, content: "Answer with a single word, no punctuation." },
|
|
105
|
+
{ role: "user" as const, content: "What colour is a clear midday sky? Answer in one word." },
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
export interface LivenessProbe {
|
|
109
|
+
/** Make a call (or return a cached success). */
|
|
110
|
+
run(): Promise<LivenessResult>;
|
|
111
|
+
/** Forget any cached success — the next `run` will really call. */
|
|
112
|
+
reset(): void;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Build a probe with its own cache.
|
|
117
|
+
*
|
|
118
|
+
* The cache lives on the instance rather than in a module global so that two
|
|
119
|
+
* apps in one process, or a test, cannot silently share (and satisfy) each
|
|
120
|
+
* other's probe.
|
|
121
|
+
*/
|
|
122
|
+
export function createLivenessProbe(options: LivenessOptions = {}): LivenessProbe {
|
|
123
|
+
const minIntervalMs = options.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS;
|
|
124
|
+
const now = options.now ?? Date.now;
|
|
125
|
+
|
|
126
|
+
let lastOk: { at: number; result: LivenessResult } | null = null;
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
reset() {
|
|
130
|
+
lastOk = null;
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
async run(): Promise<LivenessResult> {
|
|
134
|
+
if (lastOk && minIntervalMs > 0) {
|
|
135
|
+
const age = now() - lastOk.at;
|
|
136
|
+
if (age < minIntervalMs) {
|
|
137
|
+
return { ...lastOk.result, cached: true, cachedAgeMs: age };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const started = now();
|
|
142
|
+
try {
|
|
143
|
+
const result = await complete({
|
|
144
|
+
timeoutMs: PROBE_TIMEOUT_MS,
|
|
145
|
+
...options,
|
|
146
|
+
messages: PROBE_MESSAGES,
|
|
147
|
+
maxTokens: PROBE_MAX_TOKENS,
|
|
148
|
+
temperature: 0,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const answer = result.text.trim();
|
|
152
|
+
const fresh: LivenessResult = {
|
|
153
|
+
ok: true,
|
|
154
|
+
servedBy: result.id,
|
|
155
|
+
answer,
|
|
156
|
+
ms: now() - started,
|
|
157
|
+
cached: false,
|
|
158
|
+
};
|
|
159
|
+
lastOk = { at: now(), result: fresh };
|
|
160
|
+
return fresh;
|
|
161
|
+
} catch (error) {
|
|
162
|
+
// A failure is deliberately NOT cached. Caching it would keep reporting
|
|
163
|
+
// an outage after the vendor recovered, and the whole point is to tell
|
|
164
|
+
// the truth about right now.
|
|
165
|
+
if (error instanceof ChainExhaustedError) {
|
|
166
|
+
return {
|
|
167
|
+
ok: false,
|
|
168
|
+
cached: false,
|
|
169
|
+
ms: now() - started,
|
|
170
|
+
failures: error.failures.map((f) => f.message),
|
|
171
|
+
...(error.failures.length === 0
|
|
172
|
+
? { skipped: "No usable link — every provider is missing its key or has no models." }
|
|
173
|
+
: {}),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
ok: false,
|
|
178
|
+
cached: false,
|
|
179
|
+
ms: now() - started,
|
|
180
|
+
failures: [error instanceof Error ? error.message : String(error)],
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export interface AiHealthHandlerOptions extends LivenessOptions {
|
|
188
|
+
/**
|
|
189
|
+
* Shared secret authorising a probe. Compared against the `x-probe-secret`
|
|
190
|
+
* header or a `secret` query parameter.
|
|
191
|
+
*
|
|
192
|
+
* When absent, the handler NEVER probes — it only reports passive health.
|
|
193
|
+
* That default is deliberate: an app that forgets to configure a secret gets
|
|
194
|
+
* a route that cannot spend money, rather than an open endpoint that can.
|
|
195
|
+
*/
|
|
196
|
+
secret?: string;
|
|
197
|
+
/** Passive health to report alongside. Optional. */
|
|
198
|
+
health?: HealthTracker;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* A framework-neutral `Request -> Response` handler for an AI health route.
|
|
203
|
+
*
|
|
204
|
+
* Web-standard on purpose: Next's App Router, Hono, Deno and Bun all accept
|
|
205
|
+
* this shape directly, so adopting it is an export line rather than a port.
|
|
206
|
+
*
|
|
207
|
+
* GET /api/health/ai passive — what happened last time. Free.
|
|
208
|
+
* GET /api/health/ai?probe=1 makes a real call. Requires the secret.
|
|
209
|
+
*
|
|
210
|
+
* A probe without a valid secret is 401 and does NOT fall back to probing.
|
|
211
|
+
*
|
|
212
|
+
* Status codes are chosen so an uptime monitor can watch this URL directly:
|
|
213
|
+
* 200 when the answer is good, 503 when a probe was attempted and the chain
|
|
214
|
+
* could not answer.
|
|
215
|
+
*/
|
|
216
|
+
export function createAiHealthHandler(
|
|
217
|
+
options: AiHealthHandlerOptions = {},
|
|
218
|
+
): (request: Request) => Promise<Response> {
|
|
219
|
+
const { secret, health, ...probeOptions } = options;
|
|
220
|
+
const probe = createLivenessProbe(probeOptions);
|
|
221
|
+
|
|
222
|
+
return async function handler(request: Request): Promise<Response> {
|
|
223
|
+
const url = new URL(request.url);
|
|
224
|
+
const wantsProbe = url.searchParams.get("probe") === "1";
|
|
225
|
+
const offered =
|
|
226
|
+
request.headers.get("x-probe-secret") ?? url.searchParams.get("secret") ?? undefined;
|
|
227
|
+
|
|
228
|
+
const passive = health ? { health: health.getHealth() } : {};
|
|
229
|
+
|
|
230
|
+
if (!wantsProbe) {
|
|
231
|
+
return json(200, { probed: false, ...passive });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// No secret configured means probing is switched off, which is a different
|
|
235
|
+
// answer from "your secret is wrong" — say so, rather than implying the
|
|
236
|
+
// caller could retry with a better credential.
|
|
237
|
+
if (!secret) {
|
|
238
|
+
return json(501, {
|
|
239
|
+
probed: false,
|
|
240
|
+
error: "Probing is not configured on this deployment (no secret set).",
|
|
241
|
+
...passive,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
if (!offered || !timingSafeEqual(offered, secret)) {
|
|
245
|
+
return json(401, { probed: false, error: "Bad or missing probe secret.", ...passive });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const result = await probe.run();
|
|
249
|
+
// Record into passive health too, so one probe also answers the next
|
|
250
|
+
// ordinary health poll — otherwise the probe's knowledge dies with it.
|
|
251
|
+
if (health) {
|
|
252
|
+
if (result.ok) health.recordSuccess();
|
|
253
|
+
else health.recordFailure(new Error(result.failures?.join("; ") ?? "probe failed"));
|
|
254
|
+
}
|
|
255
|
+
return json(result.ok ? 200 : 503, {
|
|
256
|
+
probed: true,
|
|
257
|
+
...result,
|
|
258
|
+
...(health ? { health: health.getHealth() } : {}),
|
|
259
|
+
});
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function json(status: number, body: unknown): Response {
|
|
264
|
+
return new Response(JSON.stringify(body), {
|
|
265
|
+
status,
|
|
266
|
+
headers: { "content-type": "application/json", "cache-control": "no-store" },
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Constant-time comparison, so a wrong secret cannot be discovered one
|
|
272
|
+
* character at a time by timing the 401.
|
|
273
|
+
*/
|
|
274
|
+
function timingSafeEqual(a: string, b: string): boolean {
|
|
275
|
+
if (a.length !== b.length) return false;
|
|
276
|
+
let diff = 0;
|
|
277
|
+
for (let i = 0; i < a.length; i += 1) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
278
|
+
return diff === 0;
|
|
279
|
+
}
|