@bitbaum/ai-kit 0.8.0 → 0.10.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 +22 -0
- package/dist/complete.d.ts +39 -0
- package/dist/complete.js +74 -2
- package/dist/liveness.js +10 -0
- package/package.json +1 -1
- package/src/complete.ts +129 -2
- package/src/liveness.ts +11 -0
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
|
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
|
*
|
|
@@ -115,6 +141,19 @@ export interface CompleteOptions {
|
|
|
115
141
|
tools?: unknown[];
|
|
116
142
|
/** Extra body fields for a vendor-specific parameter. Merged last, so it can override. */
|
|
117
143
|
extraBody?: Record<string, unknown>;
|
|
144
|
+
/**
|
|
145
|
+
* Extra request headers. Merged last, so a caller can override `content-type`
|
|
146
|
+
* — but NOT `authorization`, which stays the key this module resolved.
|
|
147
|
+
*
|
|
148
|
+
* Vendors ask for these and quietly change behaviour without them:
|
|
149
|
+
* OpenRouter reads `HTTP-Referer` and `X-Title` for app attribution in its
|
|
150
|
+
* public rankings, and an app that stops sending them simply disappears from
|
|
151
|
+
* that list with no error anywhere. Without this option, adopting `complete`
|
|
152
|
+
* would mean silently dropping them, which is exactly the kind of small,
|
|
153
|
+
* invisible regression that makes a shared engine feel worse than the
|
|
154
|
+
* hand-rolled client it replaced.
|
|
155
|
+
*/
|
|
156
|
+
extraHeaders?: Record<string, string>;
|
|
118
157
|
/** Called on each link's failure before moving on — e.g. to log which id rotted. */
|
|
119
158
|
onLinkFailure?: (link: Link, error: Error) => void;
|
|
120
159
|
/** Injected for tests. Defaults to global `fetch`. */
|
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,39 @@ 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
|
-
headers: {
|
|
194
|
+
headers: {
|
|
195
|
+
"content-type": "application/json",
|
|
196
|
+
...options.extraHeaders,
|
|
197
|
+
// Last on purpose. A caller may add or override any header it likes,
|
|
198
|
+
// but not this one: silently sending someone else's credential — or
|
|
199
|
+
// none — would turn a typo in a caller's header map into an auth
|
|
200
|
+
// failure blamed on the vendor.
|
|
201
|
+
authorization: `Bearer ${key}`,
|
|
202
|
+
},
|
|
147
203
|
body: JSON.stringify(body),
|
|
148
|
-
signal:
|
|
204
|
+
signal: deadline.signal,
|
|
149
205
|
});
|
|
150
206
|
}
|
|
151
207
|
catch (error) {
|
|
152
208
|
// A transport failure (DNS, TLS, abort) is not the vendor's answer, so it
|
|
153
209
|
// carries no rate-limit kind — it demotes like any other link failure.
|
|
210
|
+
//
|
|
211
|
+
// Name a timeout as a timeout. "The operation was aborted" in a log is
|
|
212
|
+
// indistinguishable from a caller cancelling, and the two want opposite
|
|
213
|
+
// reactions from whoever reads it.
|
|
214
|
+
if (deadline.timedOut) {
|
|
215
|
+
throw new LinkFailure(link, `${linkId(link)}: no response within ${deadline.timeoutMs}ms — abandoned, trying the next link`);
|
|
216
|
+
}
|
|
154
217
|
throw new LinkFailure(link, `${linkId(link)}: ${error.message}`);
|
|
155
218
|
}
|
|
219
|
+
finally {
|
|
220
|
+
deadline.dispose();
|
|
221
|
+
}
|
|
156
222
|
const text = await res.text();
|
|
157
223
|
if (!res.ok) {
|
|
158
224
|
if (res.status === 429) {
|
|
@@ -225,6 +291,12 @@ export async function complete(options) {
|
|
|
225
291
|
options.onLinkFailure?.(link, failure);
|
|
226
292
|
if (failure.kind === "daily")
|
|
227
293
|
deadProviders.add(link.provider.id);
|
|
294
|
+
// The caller cancelled — the request they were waiting on is gone. Walking
|
|
295
|
+
// the rest of the chain now would spend their daily budget on an answer
|
|
296
|
+
// nobody will read, and would report "every vendor failed" about vendors
|
|
297
|
+
// that were never asked.
|
|
298
|
+
if (options.signal?.aborted)
|
|
299
|
+
break;
|
|
228
300
|
// Stepping down after a size 429 reaches a model with a smaller ceiling —
|
|
229
301
|
// strictly worse. Stop, and let the caller shorten the prompt.
|
|
230
302
|
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.
|
|
3
|
+
"version": "0.10.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
|
*
|
|
@@ -120,6 +146,19 @@ export interface CompleteOptions {
|
|
|
120
146
|
tools?: unknown[];
|
|
121
147
|
/** Extra body fields for a vendor-specific parameter. Merged last, so it can override. */
|
|
122
148
|
extraBody?: Record<string, unknown>;
|
|
149
|
+
/**
|
|
150
|
+
* Extra request headers. Merged last, so a caller can override `content-type`
|
|
151
|
+
* — but NOT `authorization`, which stays the key this module resolved.
|
|
152
|
+
*
|
|
153
|
+
* Vendors ask for these and quietly change behaviour without them:
|
|
154
|
+
* OpenRouter reads `HTTP-Referer` and `X-Title` for app attribution in its
|
|
155
|
+
* public rankings, and an app that stops sending them simply disappears from
|
|
156
|
+
* that list with no error anywhere. Without this option, adopting `complete`
|
|
157
|
+
* would mean silently dropping them, which is exactly the kind of small,
|
|
158
|
+
* invisible regression that makes a shared engine feel worse than the
|
|
159
|
+
* hand-rolled client it replaced.
|
|
160
|
+
*/
|
|
161
|
+
extraHeaders?: Record<string, string>;
|
|
123
162
|
/** Called on each link's failure before moving on — e.g. to log which id rotted. */
|
|
124
163
|
onLinkFailure?: (link: Link, error: Error) => void;
|
|
125
164
|
/** Injected for tests. Defaults to global `fetch`. */
|
|
@@ -164,6 +203,66 @@ export class LinkFailure extends Error {
|
|
|
164
203
|
}
|
|
165
204
|
}
|
|
166
205
|
|
|
206
|
+
/**
|
|
207
|
+
* Long enough that a slow-but-working reasoning model finishes, short enough
|
|
208
|
+
* that a hung vendor does not hold a request open until something upstream
|
|
209
|
+
* gives up on it.
|
|
210
|
+
*/
|
|
211
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
212
|
+
|
|
213
|
+
interface LinkDeadline {
|
|
214
|
+
signal: AbortSignal | undefined;
|
|
215
|
+
/** True when THIS link's own clock fired, rather than the caller cancelling. */
|
|
216
|
+
readonly timedOut: boolean;
|
|
217
|
+
readonly timeoutMs: number;
|
|
218
|
+
/** Always call. An uncleared timer keeps the event loop alive. */
|
|
219
|
+
dispose(): void;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* One link's deadline, composed with the caller's cancellation.
|
|
224
|
+
*
|
|
225
|
+
* Hand-rolled rather than `AbortSignal.any`, which landed in Node 20.3 — this
|
|
226
|
+
* package supports Node >= 20, and a helper that works on 20.0 costs eight
|
|
227
|
+
* lines while an engines bump costs every consumer a decision.
|
|
228
|
+
*/
|
|
229
|
+
function linkDeadline(caller: AbortSignal | undefined, timeoutMs: number): LinkDeadline {
|
|
230
|
+
if (timeoutMs <= 0) {
|
|
231
|
+
return { signal: caller, timedOut: false, timeoutMs, dispose() {} };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const controller = new AbortController();
|
|
235
|
+
const state = { timedOut: false };
|
|
236
|
+
|
|
237
|
+
// Deliberately NOT unref'd. It is tempting — a stray timer holding a process
|
|
238
|
+
// open is a real nuisance — but this one is always cleared in `dispose`, so
|
|
239
|
+
// there is nothing to save, and an unref'd timer stops firing whenever
|
|
240
|
+
// nothing else keeps the loop alive. That turns the deadline into a deadline
|
|
241
|
+
// that sometimes does not happen, which is worse than none at all.
|
|
242
|
+
const timer = setTimeout(() => {
|
|
243
|
+
state.timedOut = true;
|
|
244
|
+
controller.abort(new Error(`link timeout after ${timeoutMs}ms`));
|
|
245
|
+
}, timeoutMs);
|
|
246
|
+
|
|
247
|
+
const onCallerAbort = () => controller.abort(caller?.reason);
|
|
248
|
+
if (caller) {
|
|
249
|
+
if (caller.aborted) controller.abort(caller.reason);
|
|
250
|
+
else caller.addEventListener("abort", onCallerAbort, { once: true });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
signal: controller.signal,
|
|
255
|
+
get timedOut() {
|
|
256
|
+
return state.timedOut;
|
|
257
|
+
},
|
|
258
|
+
timeoutMs,
|
|
259
|
+
dispose() {
|
|
260
|
+
clearTimeout(timer);
|
|
261
|
+
caller?.removeEventListener("abort", onCallerAbort);
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
167
266
|
/** `provider/model` — the id worth logging, because the model alone does not say whose meter it drew on. */
|
|
168
267
|
export function linkId(link: Link): string {
|
|
169
268
|
return `${link.provider.id}/${link.model}`;
|
|
@@ -230,18 +329,40 @@ async function callLink(
|
|
|
230
329
|
...options.extraBody,
|
|
231
330
|
};
|
|
232
331
|
|
|
332
|
+
const deadline = linkDeadline(options.signal, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
333
|
+
|
|
233
334
|
let res: Response;
|
|
234
335
|
try {
|
|
235
336
|
res = await doFetch(`${link.provider.baseUrl}/chat/completions`, {
|
|
236
337
|
method: "POST",
|
|
237
|
-
headers: {
|
|
338
|
+
headers: {
|
|
339
|
+
"content-type": "application/json",
|
|
340
|
+
...options.extraHeaders,
|
|
341
|
+
// Last on purpose. A caller may add or override any header it likes,
|
|
342
|
+
// but not this one: silently sending someone else's credential — or
|
|
343
|
+
// none — would turn a typo in a caller's header map into an auth
|
|
344
|
+
// failure blamed on the vendor.
|
|
345
|
+
authorization: `Bearer ${key}`,
|
|
346
|
+
},
|
|
238
347
|
body: JSON.stringify(body),
|
|
239
|
-
signal:
|
|
348
|
+
signal: deadline.signal,
|
|
240
349
|
});
|
|
241
350
|
} catch (error) {
|
|
242
351
|
// A transport failure (DNS, TLS, abort) is not the vendor's answer, so it
|
|
243
352
|
// carries no rate-limit kind — it demotes like any other link failure.
|
|
353
|
+
//
|
|
354
|
+
// Name a timeout as a timeout. "The operation was aborted" in a log is
|
|
355
|
+
// indistinguishable from a caller cancelling, and the two want opposite
|
|
356
|
+
// reactions from whoever reads it.
|
|
357
|
+
if (deadline.timedOut) {
|
|
358
|
+
throw new LinkFailure(
|
|
359
|
+
link,
|
|
360
|
+
`${linkId(link)}: no response within ${deadline.timeoutMs}ms — abandoned, trying the next link`,
|
|
361
|
+
);
|
|
362
|
+
}
|
|
244
363
|
throw new LinkFailure(link, `${linkId(link)}: ${(error as Error).message}`);
|
|
364
|
+
} finally {
|
|
365
|
+
deadline.dispose();
|
|
245
366
|
}
|
|
246
367
|
|
|
247
368
|
const text = await res.text();
|
|
@@ -328,6 +449,12 @@ export async function complete(options: CompleteOptions): Promise<CompleteResult
|
|
|
328
449
|
|
|
329
450
|
if (failure.kind === "daily") deadProviders.add(link.provider.id);
|
|
330
451
|
|
|
452
|
+
// The caller cancelled — the request they were waiting on is gone. Walking
|
|
453
|
+
// the rest of the chain now would spend their daily budget on an answer
|
|
454
|
+
// nobody will read, and would report "every vendor failed" about vendors
|
|
455
|
+
// that were never asked.
|
|
456
|
+
if (options.signal?.aborted) break;
|
|
457
|
+
|
|
331
458
|
// Stepping down after a size 429 reaches a model with a smaller ceiling —
|
|
332
459
|
// strictly worse. Stop, and let the caller shorten the prompt.
|
|
333
460
|
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,
|