@bitbaum/ai-kit 0.8.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 CHANGED
@@ -100,6 +100,28 @@ a visible token: `groq/openai/gpt-oss-20b` answered *empty* at 16 and correctly
100
100
  at 256 for the same one-word question. A mean budget makes a healthy model look
101
101
  dead.
102
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
+
103
125
  `tryChain` stays for a caller with a genuinely unusual request to make.
104
126
 
105
127
  ### Does it work RIGHT NOW? — a probe, not a guess
@@ -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: options.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/liveness.js CHANGED
@@ -52,6 +52,15 @@ const DEFAULT_MIN_INTERVAL_MS = 10 * 60 * 1000;
52
52
  * report itself dead — the exact false alarm this module exists to prevent.
53
53
  */
54
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;
55
64
  /** A question with one short right answer, cheap to ask and easy to sanity-check. */
56
65
  const PROBE_MESSAGES = [
57
66
  { role: "system", content: "Answer with a single word, no punctuation." },
@@ -82,6 +91,7 @@ export function createLivenessProbe(options = {}) {
82
91
  const started = now();
83
92
  try {
84
93
  const result = await complete({
94
+ timeoutMs: PROBE_TIMEOUT_MS,
85
95
  ...options,
86
96
  messages: PROBE_MESSAGES,
87
97
  maxTokens: PROBE_MAX_TOKENS,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitbaum/ai-kit",
3
- "version": "0.8.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: options.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/liveness.ts CHANGED
@@ -89,6 +89,16 @@ const DEFAULT_MIN_INTERVAL_MS = 10 * 60 * 1000;
89
89
  */
90
90
  const PROBE_MAX_TOKENS = 256;
91
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
+
92
102
  /** A question with one short right answer, cheap to ask and easy to sanity-check. */
93
103
  const PROBE_MESSAGES = [
94
104
  { role: "system" as const, content: "Answer with a single word, no punctuation." },
@@ -131,6 +141,7 @@ export function createLivenessProbe(options: LivenessOptions = {}): LivenessProb
131
141
  const started = now();
132
142
  try {
133
143
  const result = await complete({
144
+ timeoutMs: PROBE_TIMEOUT_MS,
134
145
  ...options,
135
146
  messages: PROBE_MESSAGES,
136
147
  maxTokens: PROBE_MAX_TOKENS,