@bitbaum/ai-kit 0.6.2 → 0.8.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.
@@ -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>;
@@ -0,0 +1,198 @@
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
+ /** A question with one short right answer, cheap to ask and easy to sanity-check. */
56
+ const PROBE_MESSAGES = [
57
+ { role: "system", content: "Answer with a single word, no punctuation." },
58
+ { role: "user", content: "What colour is a clear midday sky? Answer in one word." },
59
+ ];
60
+ /**
61
+ * Build a probe with its own cache.
62
+ *
63
+ * The cache lives on the instance rather than in a module global so that two
64
+ * apps in one process, or a test, cannot silently share (and satisfy) each
65
+ * other's probe.
66
+ */
67
+ export function createLivenessProbe(options = {}) {
68
+ const minIntervalMs = options.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS;
69
+ const now = options.now ?? Date.now;
70
+ let lastOk = null;
71
+ return {
72
+ reset() {
73
+ lastOk = null;
74
+ },
75
+ async run() {
76
+ if (lastOk && minIntervalMs > 0) {
77
+ const age = now() - lastOk.at;
78
+ if (age < minIntervalMs) {
79
+ return { ...lastOk.result, cached: true, cachedAgeMs: age };
80
+ }
81
+ }
82
+ const started = now();
83
+ try {
84
+ const result = await complete({
85
+ ...options,
86
+ messages: PROBE_MESSAGES,
87
+ maxTokens: PROBE_MAX_TOKENS,
88
+ temperature: 0,
89
+ });
90
+ const answer = result.text.trim();
91
+ const fresh = {
92
+ ok: true,
93
+ servedBy: result.id,
94
+ answer,
95
+ ms: now() - started,
96
+ cached: false,
97
+ };
98
+ lastOk = { at: now(), result: fresh };
99
+ return fresh;
100
+ }
101
+ catch (error) {
102
+ // A failure is deliberately NOT cached. Caching it would keep reporting
103
+ // an outage after the vendor recovered, and the whole point is to tell
104
+ // the truth about right now.
105
+ if (error instanceof ChainExhaustedError) {
106
+ return {
107
+ ok: false,
108
+ cached: false,
109
+ ms: now() - started,
110
+ failures: error.failures.map((f) => f.message),
111
+ ...(error.failures.length === 0
112
+ ? { skipped: "No usable link — every provider is missing its key or has no models." }
113
+ : {}),
114
+ };
115
+ }
116
+ return {
117
+ ok: false,
118
+ cached: false,
119
+ ms: now() - started,
120
+ failures: [error instanceof Error ? error.message : String(error)],
121
+ };
122
+ }
123
+ },
124
+ };
125
+ }
126
+ /**
127
+ * A framework-neutral `Request -> Response` handler for an AI health route.
128
+ *
129
+ * Web-standard on purpose: Next's App Router, Hono, Deno and Bun all accept
130
+ * this shape directly, so adopting it is an export line rather than a port.
131
+ *
132
+ * GET /api/health/ai passive — what happened last time. Free.
133
+ * GET /api/health/ai?probe=1 makes a real call. Requires the secret.
134
+ *
135
+ * A probe without a valid secret is 401 and does NOT fall back to probing.
136
+ *
137
+ * Status codes are chosen so an uptime monitor can watch this URL directly:
138
+ * 200 when the answer is good, 503 when a probe was attempted and the chain
139
+ * could not answer.
140
+ */
141
+ export function createAiHealthHandler(options = {}) {
142
+ const { secret, health, ...probeOptions } = options;
143
+ const probe = createLivenessProbe(probeOptions);
144
+ return async function handler(request) {
145
+ const url = new URL(request.url);
146
+ const wantsProbe = url.searchParams.get("probe") === "1";
147
+ const offered = request.headers.get("x-probe-secret") ?? url.searchParams.get("secret") ?? undefined;
148
+ const passive = health ? { health: health.getHealth() } : {};
149
+ if (!wantsProbe) {
150
+ return json(200, { probed: false, ...passive });
151
+ }
152
+ // No secret configured means probing is switched off, which is a different
153
+ // answer from "your secret is wrong" — say so, rather than implying the
154
+ // caller could retry with a better credential.
155
+ if (!secret) {
156
+ return json(501, {
157
+ probed: false,
158
+ error: "Probing is not configured on this deployment (no secret set).",
159
+ ...passive,
160
+ });
161
+ }
162
+ if (!offered || !timingSafeEqual(offered, secret)) {
163
+ return json(401, { probed: false, error: "Bad or missing probe secret.", ...passive });
164
+ }
165
+ const result = await probe.run();
166
+ // Record into passive health too, so one probe also answers the next
167
+ // ordinary health poll — otherwise the probe's knowledge dies with it.
168
+ if (health) {
169
+ if (result.ok)
170
+ health.recordSuccess();
171
+ else
172
+ health.recordFailure(new Error(result.failures?.join("; ") ?? "probe failed"));
173
+ }
174
+ return json(result.ok ? 200 : 503, {
175
+ probed: true,
176
+ ...result,
177
+ ...(health ? { health: health.getHealth() } : {}),
178
+ });
179
+ };
180
+ }
181
+ function json(status, body) {
182
+ return new Response(JSON.stringify(body), {
183
+ status,
184
+ headers: { "content-type": "application/json", "cache-control": "no-store" },
185
+ });
186
+ }
187
+ /**
188
+ * Constant-time comparison, so a wrong secret cannot be discovered one
189
+ * character at a time by timing the 401.
190
+ */
191
+ function timingSafeEqual(a, b) {
192
+ if (a.length !== b.length)
193
+ return false;
194
+ let diff = 0;
195
+ for (let i = 0; i < a.length; i += 1)
196
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
197
+ return diff === 0;
198
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitbaum/ai-kit",
3
- "version": "0.6.2",
3
+ "version": "0.8.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",
@@ -28,6 +28,7 @@
28
28
  ],
29
29
  "type": "module",
30
30
  "sideEffects": false,
31
+ "packageManager": "pnpm@11.25.0",
31
32
  "engines": {
32
33
  "node": ">=20"
33
34
  },
@@ -73,20 +74,21 @@
73
74
  "lint": "eslint .",
74
75
  "typecheck": "tsc -p tsconfig.json --noEmit",
75
76
  "test": "node --test test/*.test.js",
76
- "check:catalog": "npm run build && node scripts/check-catalog.mjs",
77
- "verify": "npm run format:check && npm run lint && npm run typecheck && npm run build && npm test",
78
- "prepare": "npm run build",
77
+ "check:catalog": "pnpm run build && node scripts/check-catalog.mjs",
78
+ "smoke": "pnpm run build && node scripts/smoke.mjs",
79
+ "verify": "pnpm run format:check && pnpm run lint && pnpm run typecheck && pnpm run build && pnpm test",
80
+ "prepare": "pnpm run build",
79
81
  "format": "prettier --write .",
80
82
  "format:check": "prettier --check ."
81
83
  },
82
84
  "devDependencies": {
83
85
  "@eslint/js": "^10.0.1",
84
- "@types/node": "^26.4.0",
86
+ "@types/node": "^26.4.1",
85
87
  "eslint": "^10.9.1",
86
- "globals": "^17.11.0",
88
+ "globals": "^17.12.0",
87
89
  "prettier": "3.9.6",
88
90
  "typescript": "^6.0.3",
89
- "typescript-eslint": "^8.68.0"
91
+ "typescript-eslint": "^8.69.0"
90
92
  },
91
93
  "dependencies": {
92
94
  "ai-forms": "^0.1.2"