vern-llm 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2,385 +2,245 @@ import { randomUUID } from "crypto";
2
2
 
3
3
  //#region src/types/errors.ts
4
4
  var LLMError = class extends Error {
5
- constructor(message, type, status, issues, cause, retryAfterMs) {
5
+ constructor(message, type, status, issues, cause, retryAfterMs, code) {
6
6
  super(message);
7
7
  this.type = type;
8
8
  this.status = status;
9
9
  this.issues = issues;
10
10
  this.cause = cause;
11
11
  this.retryAfterMs = retryAfterMs;
12
+ this.code = code;
12
13
  this.name = "LLMError";
13
14
  }
15
+ /** Every tool contract failure in one response, when there is more than one. */
16
+ toolIssues;
14
17
  };
15
18
  function isLLMError(err) {
16
19
  return err instanceof LLMError;
17
20
  }
18
21
 
19
22
  //#endregion
20
- //#region src/circuitBreaker.ts
23
+ //#region src/types/cache.ts
21
24
  /**
22
- * Per retry VernLLM-instance circuit breaker. Tracks consecutive failures across
23
- * calls. Once the threshold is hit, short-circuits new calls with an
24
- * LLMError('circuit_open') instead of hitting the provider, until the
25
- * cooldown elapses and a single trial call is allowed through
25
+ * Trivial default so the package works out of the box with no external deps
26
+ * Not shared across processes, swap in Redis/Upstash/etc for production
26
27
  */
27
- var CircuitBreaker = class {
28
- state = "closed";
29
- consecutiveFailures = 0;
30
- openedAt = 0;
31
- threshold;
32
- cooldownMs;
33
- /**
34
- * True while a single half-open trial call is in flight. Guards against
35
- * multiple concurrent callers all treating themselves as "the" trial once
36
- * the cooldown elapses
37
- */
38
- trialInFlight = false;
39
- constructor(options = {}) {
40
- this.threshold = options.threshold ?? 5;
41
- this.cooldownMs = options.cooldownMs ?? 3e4;
28
+ var InMemoryCacheAdapter = class {
29
+ store = new Map();
30
+ constructor(maxSize = 1e3) {
31
+ this.maxSize = maxSize;
42
32
  }
43
- /**
44
- * Throws if the circuit is open and the cooldown hasn't elapsed, or if
45
- * the circuit is half-open and a trial call is already in flight.
46
- * Otherwise, if the circuit just became eligible for a trial (cooldown
47
- * elapsed, or half-open with no trial currently running), this call
48
- * becomes that trial
49
- */
50
- assertClosed() {
51
- if (this.state === "closed") return;
52
- if (this.state === "open") {
53
- const elapsed = Date.now() - this.openedAt;
54
- if (elapsed < this.cooldownMs) throw new LLMError(`Circuit open, provider has failed ${this.consecutiveFailures} times in a row. Retry in ${Math.ceil((this.cooldownMs - elapsed) / 1e3)}s.`, "circuit_open");
55
- this.state = "half-open";
56
- this.trialInFlight = true;
57
- return;
58
- }
59
- if (this.trialInFlight) throw new LLMError("Circuit half-open. A trial request is already in flight. Try again shortly.", "circuit_open");
60
- this.trialInFlight = true;
61
- }
62
- recordSuccess() {
63
- this.consecutiveFailures = 0;
64
- this.state = "closed";
65
- this.trialInFlight = false;
66
- }
67
- recordFailure() {
68
- this.consecutiveFailures += 1;
69
- this.trialInFlight = false;
70
- if (this.state === "half-open") {
71
- this.state = "open";
72
- this.openedAt = Date.now();
73
- return;
33
+ async get(key) {
34
+ const entry = this.store.get(key);
35
+ if (!entry) return {
36
+ hit: false,
37
+ value: null
38
+ };
39
+ if (Date.now() >= entry.expiresAt) {
40
+ this.store.delete(key);
41
+ return {
42
+ hit: false,
43
+ value: null
44
+ };
74
45
  }
75
- if (this.consecutiveFailures >= this.threshold) {
76
- this.state = "open";
77
- this.openedAt = Date.now();
46
+ return {
47
+ hit: true,
48
+ value: entry.value
49
+ };
50
+ }
51
+ async set(key, value, ttl) {
52
+ this.cleanupExpiredEntries();
53
+ this.store.set(key, {
54
+ value,
55
+ expiresAt: Date.now() + ttl * 1e3
56
+ });
57
+ this.enforceSizeLimit();
58
+ }
59
+ async delete(key) {
60
+ this.store.delete(key);
61
+ }
62
+ cleanupExpiredEntries() {
63
+ const now = Date.now();
64
+ for (const [key, entry] of this.store) if (now >= entry.expiresAt) this.store.delete(key);
65
+ }
66
+ enforceSizeLimit() {
67
+ while (this.store.size > this.maxSize) {
68
+ const oldestKey = this.store.keys().next().value;
69
+ if (oldestKey === void 0) break;
70
+ this.store.delete(oldestKey);
78
71
  }
79
72
  }
80
- getState() {
81
- return this.state;
73
+ };
74
+ /**
75
+ * Normalizes keys before caching to avoid duplicate entries from formatting differences.
76
+ */
77
+ var NormalizedCacheAdapter = class {
78
+ constructor(inner = new InMemoryCacheAdapter()) {
79
+ this.inner = inner;
80
+ }
81
+ normalize(key) {
82
+ return key.toLowerCase().trim().replace(/[^\p{L}\p{N}\s]/gu, " ").replace(/\s+/g, " ").trim();
83
+ }
84
+ async resolveKey(key) {
85
+ return this.normalize(key);
86
+ }
87
+ async get(key) {
88
+ return this.inner.get(this.normalize(key));
89
+ }
90
+ async set(key, value, ttl) {
91
+ await this.inner.set(this.normalize(key), value, ttl);
92
+ }
93
+ async delete(key) {
94
+ await this.inner.delete?.(this.normalize(key));
95
+ }
96
+ };
97
+ /**
98
+ * Two-tier cache with fast local L1 and shared L2.
99
+ * L2 hits are promoted back to L1.
100
+ */
101
+ var TieredCacheAdapter = class {
102
+ constructor(l1, l2, l1Ttl) {
103
+ this.l1 = l1;
104
+ this.l2 = l2;
105
+ this.l1Ttl = l1Ttl;
106
+ }
107
+ /**
108
+ * Forwards to L1's `resolveKey` if it has one, otherwise L2's. L1 is
109
+ * preferred since `get()` checks L1 first, so its notion of "the same
110
+ * key" is the one that determines whether a lookup can skip L2 entirely.
111
+ */
112
+ async resolveKey(key) {
113
+ if (this.l1.resolveKey) return this.l1.resolveKey(key);
114
+ if (this.l2.resolveKey) return this.l2.resolveKey(key);
115
+ return key;
116
+ }
117
+ async get(key) {
118
+ const l1Result = await this.l1.get(key);
119
+ if (l1Result.hit) return l1Result;
120
+ const l2Result = await this.l2.get(key);
121
+ if (l2Result.hit) await this.l1.set(key, l2Result.value, this.l1Ttl ?? 60);
122
+ return l2Result;
123
+ }
124
+ async set(key, value, ttl) {
125
+ await Promise.all([this.l1.set(key, value, this.l1Ttl ?? ttl), this.l2.set(key, value, ttl)]);
126
+ }
127
+ async delete(key) {
128
+ await Promise.all([this.l1.delete?.(key), this.l2.delete?.(key)]);
82
129
  }
83
130
  };
84
131
 
85
132
  //#endregion
86
- //#region src/internal/vernLLM.utils.ts
87
- /** Translates app-facing `ToolDefinition[]` into the OpenAI-shaped wire tools array. */
88
- function toWireTools(tools) {
89
- return tools.map((tool) => ({
90
- type: "function",
91
- function: {
92
- name: tool.name,
93
- description: tool.description,
94
- parameters: tool.parameters
95
- }
96
- }));
97
- }
98
- /** Translates app-facing `ToolCall[]` (e.g. from a replayed assistant turn) into wire tool_calls. */
99
- function toWireToolCalls(toolCalls) {
100
- return toolCalls.map((tc) => ({
101
- id: tc.id,
102
- type: "function",
103
- function: {
104
- name: tc.name,
105
- arguments: JSON.stringify(tc.arguments ?? {})
106
- }
107
- }));
108
- }
133
+ //#region src/types/tools.ts
109
134
  /**
110
- * Parses the provider's wire-shaped `tool_calls` back into VernLLM's
111
- * `ToolCall[]`. Malformed argument JSON is a `'parse'` error, same
112
- * convention as malformed JSON response bodies elsewhere in VernLLM.
135
+ * Runtime-safe check for whether a `call()` result is a `tool_calls`
136
+ * result. Prefer this over relying on TypeScript's static narrowing
137
+ * whenever `params` passed to `call()` wasn't a literal with `tools`
138
+ * inlined (see the "note on the overload" in `VernLLM.call`'s docs), in
139
+ * that case TS may have typed the result as plain `T` even though it's
140
+ * actually a `CallWithToolsResult<T>` at runtime, and this check works
141
+ * either way.
113
142
  */
114
- function parseWireToolCalls(wireToolCalls) {
115
- return wireToolCalls.map((wc) => {
116
- let parsedArgs;
117
- try {
118
- parsedArgs = wc.function.arguments.trim() ? JSON.parse(wc.function.arguments) : {};
119
- } catch {
120
- throw new LLMError(`Invalid JSON arguments for tool call "${wc.function.name}"`, "parse");
121
- }
122
- return {
123
- id: wc.id,
124
- name: wc.function.name,
125
- arguments: parsedArgs
126
- };
127
- });
143
+ function isToolCallResult(result) {
144
+ return typeof result === "object" && result !== null && "type" in result && result.type === "tool_calls" && Array.isArray(result.toolCalls);
128
145
  }
129
- function defaultParseJson(content) {
146
+
147
+ //#endregion
148
+ //#region src/types/fallback.ts
149
+ /** Tool contract failures are the model ignoring the request, not a sick provider: repeating it elsewhere can't help. */
150
+ const TOOL_CONTRACT_CODES = new Set(["unknown_tool", "duplicate_tool_call_id"]);
151
+ /**
152
+ * The default `fallbackOn` policy. Exported so a caller can wrap rather
153
+ * than replace it, e.g. `fallbackOn: (e, ctx) => myCheck(e) ? 'stop' : defaultFallbackOn(e, ctx)`.
154
+ */
155
+ const defaultFallbackOn = (error) => {
156
+ if (error.type === "parse" || error.type === "validation" || error.type === "aborted") return "stop";
157
+ if (error.type === "quota_exceeded") return "stop";
158
+ if (error.code && TOOL_CONTRACT_CODES.has(error.code)) return "stop";
159
+ return "next";
160
+ };
161
+ /**
162
+ * Thrown when the chain gives up, whether because the last target failed
163
+ * or `fallbackOn` chose to stop early. Carries each attempt in order so
164
+ * an outage across providers stays debuggable without reproducing it.
165
+ * Extends `LLMError` so `isLLMError` and any `instanceof LLMError` check
166
+ * still passes, inheriting the last failure's `type` so existing
167
+ * type-based handling keeps working on a fallback-exhausted error too.
168
+ */
169
+ var FallbackExhaustedError = class extends LLMError {
170
+ constructor(attempts) {
171
+ const last = attempts[attempts.length - 1]?.error;
172
+ super(`${attempts.length} provider${attempts.length === 1 ? "" : "s"} attempted and failed: ${attempts.map((a) => `${a.provider}(${a.error.type})`).join(" then ")}`, last?.type ?? "unknown", last?.status, void 0, last, void 0, "fallback_exhausted");
173
+ this.attempts = attempts;
174
+ }
175
+ };
176
+
177
+ //#endregion
178
+ //#region src/internal/execution/usage.utils.ts
179
+ /**
180
+ * Calls `params.reserveUsage`, if present, mapping any failure to a
181
+ * `quota_exceeded` LLMError (or an aborted error, if the signal fired
182
+ * during reservation). Returns whether a reservation was actually made,
183
+ * so callers know whether a later refund is needed. Shared by
184
+ * `withReservedUsage` and `withReservedUsageForStream`, which differ only
185
+ * in whether `coalesced` is caller-supplied or always `false`.
186
+ */
187
+ async function reserve(params, coalesced, signal) {
188
+ if (!params.reserveUsage) return false;
130
189
  try {
131
- return JSON.parse(content);
132
- } catch {
133
- return void 0;
190
+ await params.reserveUsage({
191
+ coalesced,
192
+ signal
193
+ });
194
+ return true;
195
+ } catch (error) {
196
+ if (signal?.aborted) throw new LLMError("LLM request aborted", "aborted");
197
+ throw new LLMError(error instanceof Error ? error.message : "Usage reservation failed", "quota_exceeded", void 0, void 0, error);
134
198
  }
135
199
  }
136
200
  /**
137
- * Looks inside an unknown error value and pulls out an http status code
138
- * if one is present. Checks the status field first then the status code
139
- * field since different client libraries use different names for this,
140
- * falling back to AWS SDK v3's `$metadata.httpStatusCode` (e.g. Bedrock's
141
- * `ThrottlingException`), which doesn't set either of the other two.
142
- * Returns undefined when the error is not an object or carries no status
201
+ * Builds a `(logMessage) => Promise<void>` refund function bound to the
202
+ * given hooks/coalesced/signal, reporting (instead of throwing) any error
203
+ * the refund hook itself raises, so a broken refund hook never masks the
204
+ * original error it was called to clean up after.
143
205
  */
144
- function extractStatus(err) {
145
- if (!err || typeof err !== "object") return void 0;
146
- const error = err;
147
- if (typeof error.status === "number") return error.status;
148
- if (typeof error.statusCode === "number") return error.statusCode;
149
- if (typeof error.$metadata?.httpStatusCode === "number") return error.$metadata.httpStatusCode;
150
- return void 0;
151
- }
152
- function formatSafely(value) {
153
- try {
154
- return JSON.stringify(value, null, 2) ?? String(value);
155
- } catch {
206
+ function makeRefund(params, coalesced, signal, onRefundError) {
207
+ return async (logMessage) => {
156
208
  try {
157
- return String(value);
158
- } catch {
159
- return "[unprintable error]";
209
+ await params.refundUsage?.({
210
+ coalesced,
211
+ signal
212
+ });
213
+ } catch (refundError) {
214
+ onRefundError(logMessage, refundError);
160
215
  }
161
- }
216
+ };
162
217
  }
163
218
  /**
164
- * Looks inside an unknown thrown value and pulls out a human-readable
165
- * description of it. Checks the `error` field first (the provider's raw
166
- * rejection body, JSON-stringified if possible) then falls back to the
167
- * message` field. Always returns a safe string, even when the thrown value
168
- * has hostile properties or cannot be serialized normally.
219
+ * Runs `getResult` after reserving usage, if a `reserveUsage` hook was
220
+ * provided. `refundUsage` fires only if a reservation was actually made.
221
+ * `onRefundError` is called (instead of throwing) whenever a refund attempt
222
+ * itself fails, so a broken refund hook never masks the original error.
169
223
  */
170
- function describeError(err) {
171
- if (err && typeof err === "object") try {
172
- const error = err;
173
- if (error.error !== void 0) return formatSafely(error.error);
174
- if (typeof error.message === "string") return error.message;
175
- } catch {}
176
- return formatSafely(err);
177
- }
178
- /**
179
- * `setTimeout` silently clamps any delay above this (~24.8 days) or
180
- * `Infinity` down to ~1ms instead of erroring, so a caller passing
181
- * `Infinity` as "no timeout" gets the opposite of what they asked for.
182
- * Both timeout helpers below guard against this explicitly.
183
- */
184
- const MAX_SETTIMEOUT_MS = 2147483647;
185
- /** True when a timeout value should be treated as "disabled" rather than passed to `setTimeout`. */
186
- function isTimeoutDisabled(ms) {
187
- return !ms || ms <= 0 || ms === Infinity;
188
- }
189
- /** Caps a timeout at the largest delay `setTimeout` actually honors. */
190
- function clampTimeoutMs(ms) {
191
- return Math.min(ms, MAX_SETTIMEOUT_MS);
192
- }
193
- /**
194
- * Runs an async function and cancels it if it takes longer than the given
195
- * timeout. Creates an internal abort controller that fires after the
196
- * timeout elapses, and combines it with any external signal the caller
197
- * passed in so either one can cancel the underlying call. If the internal
198
- * timeout triggers and the underlying operation aborts, the error is
199
- * converted into an LLMError with type "timeout". External cancellations
200
- * continue to propagate as aborted errors. The internal timer is always
201
- * cleared afterward, whether the function succeeds, fails, or is aborted,
202
- * so nothing is left running in the background.
203
- *
204
- * `timeoutMs` of `Infinity` (or any value beyond what `setTimeout` can
205
- * represent) disables the timeout rather than firing almost immediately.
206
- */
207
- async function withTimeout(fn, timeoutMs, externalSignal) {
208
- const controller = new AbortController();
209
- const timer = isTimeoutDisabled(timeoutMs) ? void 0 : setTimeout(() => {
210
- controller.abort();
211
- }, clampTimeoutMs(timeoutMs));
212
- const signal = externalSignal ? AbortSignal.any([externalSignal, controller.signal]) : controller.signal;
213
- try {
214
- return await fn(signal);
215
- } catch (err) {
216
- if (controller.signal.aborted && !externalSignal?.aborted && err instanceof DOMException && err.name === "AbortError") throw new LLMError("Request timed out", "timeout");
217
- throw err;
218
- } finally {
219
- clearTimeout(timer);
220
- }
221
- }
222
- /**
223
- * Races one `iterator.next()` call against a per-call idle timer, to
224
- * bound the gap *between* chunks (unlike `withTimeout`, which only bounds
225
- * opening the stream and its first chunk). Without this, a connection
226
- * that streams one chunk then hangs would never fail.
227
- *
228
- * `timeoutMs` of 0/undefined/`Infinity` disables the check. Otherwise
229
- * rejects with `LLMError('timeout')` if `next()` doesn't settle in time.
230
- * The clock resets on every call, so the window is measured from the most
231
- * recent chunk, not from stream start.
232
- *
233
- * `onIdle`, if given, is called the moment the timer fires (before the
234
- * rejection), so callers can abort the underlying transport instead of
235
- * just walking away from an unread promise. `logger`, if given, records a
236
- * debug line if `next()` still settles *after* the idle timeout already
237
- * rejected. `resolve`/`reject` on an already-settled promise is otherwise
238
- * a silent no-op, so without this the late chunk (possibly the final
239
- * usage chunk) would vanish with no trace.
240
- */
241
- function withChunkIdleTimeout(next, timeoutMs, onIdle, logger) {
242
- if (isTimeoutDisabled(timeoutMs)) return next();
243
- const activeTimeoutMs = timeoutMs;
244
- let settled = false;
245
- return new Promise((resolve, reject) => {
246
- const timer = setTimeout(() => {
247
- settled = true;
248
- onIdle?.();
249
- reject(new LLMError(`No stream chunk received for ${activeTimeoutMs}ms (idle timeout)`, "timeout"));
250
- }, clampTimeoutMs(activeTimeoutMs));
251
- next().then((result) => {
252
- clearTimeout(timer);
253
- if (settled) {
254
- logger?.debug("[VernLLM] chunk resolved after idle timeout already fired; discarding");
255
- return;
256
- }
257
- settled = true;
258
- resolve(result);
259
- }, (error) => {
260
- clearTimeout(timer);
261
- if (settled) {
262
- logger?.debug("[VernLLM] chunk rejection arrived after idle timeout already fired; discarding");
263
- return;
264
- }
265
- settled = true;
266
- reject(error);
267
- });
268
- });
269
- }
270
- /**
271
- * Default cap (ms) for both exponential backoff and honored Retry-After
272
- * values, so a misbehaving/adversarial Retry-After can't stall a caller
273
- * indefinitely
274
- */
275
- const DEFAULT_MAX_DELAY_MS = 1e4;
276
- /**
277
- * Looks inside an unknown error value for a Retry-After header and
278
- * converts it to milliseconds. Checks `.headers` first (fetch-style,
279
- * Headers-like with `.get()`), then `.response.headers` (axios-style,
280
- * plain object) since different client libraries surface headers
281
- * differently. Supports both the delta-seconds form ("30") and the
282
- * HTTP-date form ("Wed, 21 Oct 2015 07:28:00 GMT"). The result is capped
283
- * at maxDelayMs. Returns undefined when no usable Retry-After is present
284
- */
285
- function extractRetryAfterMs(err, maxDelayMs = DEFAULT_MAX_DELAY_MS) {
286
- if (!err || typeof err !== "object") return void 0;
287
- const error = err;
288
- const headers = error.headers ?? error.response?.headers;
289
- if (!headers || typeof headers !== "object") return void 0;
290
- const getter = headers;
291
- const raw = typeof getter.get === "function" ? getter.get("Retry-After") : Object.entries(headers).find(([name]) => name.toLowerCase() === "retry-after")?.at(1);
292
- if (typeof raw !== "string" || raw.trim() === "") return void 0;
293
- const trimmed = raw.trim();
294
- if (/^\d+$/.test(trimmed)) return Math.max(0, Math.min(Number(trimmed) * 1e3, maxDelayMs));
295
- const dateMs = Date.parse(trimmed);
296
- if (!Number.isNaN(dateMs)) return Math.max(0, Math.min(dateMs - Date.now(), maxDelayMs));
297
- return void 0;
298
- }
299
- /** Converts any thrown value into a well-typed LLMError. */
300
- function normalizeError(error, signal) {
301
- if (signal?.aborted) return new LLMError("LLM request aborted", "aborted");
302
- if (error instanceof LLMError) return error;
303
- const status = extractStatus(error);
304
- const retryAfterMs = extractRetryAfterMs(error);
305
- if (status !== void 0) return new LLMError("LLM request failed", "api", status, void 0, error, retryAfterMs);
306
- return new LLMError("LLM request failed", "unknown", void 0, void 0, error, retryAfterMs);
307
- }
308
- /**
309
- * Exponential backoff with jitter, capped at maxDelayMs.
310
- * Jitter avoids thundering-herd retries when many callers back off in lockstep,
311
- * the cap prevents unbounded delays when maxRetries is high
312
- */
313
- function getBackoffDelay(baseDelayMs, attempt, maxDelayMs = DEFAULT_MAX_DELAY_MS) {
314
- const exp = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
315
- return exp / 2 + Math.random() * (exp / 2);
316
- }
317
- /**
318
- * Pauses execution for the given delay before a retry attempt. If an
319
- * abort signal is provided and it fires while waiting, the pending
320
- * timer is cancelled immediately and the wait rejects right away with
321
- * an aborted error instead of continuing to sit idle until the delay
322
- * would have finished on its own
323
- */
324
- async function waitForRetry(delay, signal) {
325
- await new Promise((resolve, reject) => {
326
- const onAbort = () => {
327
- clearTimeout(timer);
328
- reject(new LLMError("Operation aborted", "aborted"));
329
- };
330
- const timer = setTimeout(() => {
331
- signal?.removeEventListener("abort", onAbort);
332
- resolve();
333
- }, delay);
334
- signal?.addEventListener("abort", onAbort, { once: true });
335
- });
336
- }
337
- /**
338
- * Runs `getResult` after reserving usage, if a `reserveUsage` hook was
339
- * provided. `refundUsage` fires only if a reservation was actually made.
340
- * `onRefundError` is called (instead of throwing) whenever a refund attempt
341
- * itself fails, so a broken refund hook never masks the original error.
342
- */
343
- async function withReservedUsage(params, coalesced, getResult, signal, onRefundError) {
344
- if (signal?.aborted) throw new LLMError("LLM request aborted", "aborted");
345
- let reserved = false;
346
- try {
347
- if (params.reserveUsage) {
348
- await params.reserveUsage({
349
- coalesced,
350
- signal
351
- });
352
- reserved = true;
353
- }
354
- } catch (error) {
355
- if (signal?.aborted) throw new LLMError("LLM request aborted", "aborted");
356
- throw new LLMError(error instanceof Error ? error.message : "Usage reservation failed", "quota_exceeded", void 0, void 0, error);
357
- }
358
- const refund = async (logMessage) => {
359
- try {
360
- await params.refundUsage?.({
361
- coalesced,
362
- signal
363
- });
364
- } catch (refundError) {
365
- onRefundError(logMessage, refundError);
366
- }
367
- };
368
- if (signal?.aborted) {
369
- if (reserved) await refund("[VernLLM] refundUsage failed after abort");
370
- throw new LLMError("LLM request aborted", "aborted");
371
- }
372
- let result;
373
- try {
374
- result = await getResult();
375
- } catch (error) {
376
- if (reserved) await refund("[VernLLM] refundUsage failed");
377
- throw error;
378
- }
379
- if (signal?.aborted) {
380
- if (reserved) await refund("[VernLLM] refundUsage failed after abort");
381
- throw new LLMError("LLM request aborted", "aborted");
382
- }
383
- return result;
224
+ async function withReservedUsage(params, coalesced, getResult, signal, onRefundError) {
225
+ if (signal?.aborted) throw new LLMError("LLM request aborted", "aborted");
226
+ const reserved = await reserve(params, coalesced, signal);
227
+ const refund = makeRefund(params, coalesced, signal, onRefundError);
228
+ if (signal?.aborted) {
229
+ if (reserved) await refund("[VernLLM] refundUsage failed after abort");
230
+ throw new LLMError("LLM request aborted", "aborted");
231
+ }
232
+ let result;
233
+ try {
234
+ result = await getResult();
235
+ } catch (error) {
236
+ if (reserved) await refund("[VernLLM] refundUsage failed");
237
+ throw error;
238
+ }
239
+ if (signal?.aborted) {
240
+ if (reserved) await refund("[VernLLM] refundUsage failed after abort");
241
+ throw new LLMError("LLM request aborted", "aborted");
242
+ }
243
+ return result;
384
244
  }
385
245
  /**
386
246
  * Streaming counterpart to `withReservedUsage`. `withReservedUsage` assumes
@@ -402,29 +262,12 @@ async function withReservedUsage(params, coalesced, getResult, signal, onRefundE
402
262
  */
403
263
  async function withReservedUsageForStream(params, openStream, signal, onRefundError) {
404
264
  if (signal?.aborted) throw new LLMError("LLM request aborted", "aborted");
405
- let reserved = false;
406
- try {
407
- if (params.reserveUsage) {
408
- await params.reserveUsage({
409
- coalesced: false,
410
- signal
411
- });
412
- reserved = true;
413
- }
414
- } catch (error) {
415
- if (signal?.aborted) throw new LLMError("LLM request aborted", "aborted");
416
- throw new LLMError(error instanceof Error ? error.message : "Usage reservation failed", "quota_exceeded", void 0, void 0, error);
265
+ const reserved = await reserve(params, false, signal);
266
+ const refund = makeRefund(params, false, signal, onRefundError);
267
+ if (signal?.aborted) {
268
+ if (reserved) await refund("[VernLLM] refundUsage failed after abort");
269
+ throw new LLMError("LLM request aborted", "aborted");
417
270
  }
418
- const refund = async (logMessage) => {
419
- try {
420
- await params.refundUsage?.({
421
- coalesced: false,
422
- signal
423
- });
424
- } catch (refundError) {
425
- onRefundError(logMessage, refundError);
426
- }
427
- };
428
271
  let opened;
429
272
  try {
430
273
  opened = await openStream();
@@ -442,6 +285,9 @@ async function withReservedUsageForStream(params, openStream, signal, onRefundEr
442
285
  finalResult
443
286
  };
444
287
  }
288
+
289
+ //#endregion
290
+ //#region src/internal/cache/replay.utils.ts
445
291
  /**
446
292
  * Converts an already-known cache value back into a plausible "text" form
447
293
  * for a one-shot replay chunk: passed through unchanged if it's already a
@@ -454,7 +300,7 @@ async function withReservedUsageForStream(params, openStream, signal, onRefundEr
454
300
  * to support.
455
301
  */
456
302
  function toReplayText(value) {
457
- return typeof value === "string" ? value : JSON.stringify(value);
303
+ return typeof value === "string" ? value : JSON.stringify(value) ?? "";
458
304
  }
459
305
  /**
460
306
  * Builds a trivially-exhausted one-shot `chunks` iterable from an
@@ -521,235 +367,1031 @@ function buildReplayChunksFromPromise(promise, hasTools) {
521
367
  }
522
368
 
523
369
  //#endregion
524
- //#region src/logger.ts
370
+ //#region src/internal/cache/cacheOrchestrator.ts
525
371
  /**
526
- * Default logger. `debug` is gated by the `debug` option on VernLLM
527
- * warn/error always fire since they indicate real problems (retries, cache failures)
372
+ * Owns cache key resolution, cache reads/writes, and in-flight coalescing
373
+ * for concurrent misses on the same key. Doesn't know about `CallExecutor`,
374
+ * retries, or providers at all: `fn`/`openStream` are opaque callbacks
375
+ * (`VernLLM.cachedCall` passes `() => this.call(...)`), so this class only
376
+ * needs the cache adapter and a logger. Extracted from `VernLLM` since
377
+ * caching and per-target call mechanics are independent concerns that
378
+ * happened to live on the same class.
528
379
  */
529
- var ConsoleLogger = class {
530
- constructor(debugEnabled) {
531
- this.debugEnabled = debugEnabled;
532
- }
533
- debug(message) {
534
- if (this.debugEnabled) console.debug(message);
380
+ var CacheOrchestrator = class {
381
+ inFlight = new Map();
382
+ constructor(cache, logger) {
383
+ this.cache = cache;
384
+ this.logger = logger;
535
385
  }
536
- warn(message) {
537
- console.warn(message);
386
+ /** Resolves a cache key through the adapter when it supports normalization. */
387
+ async resolveCacheKey(key) {
388
+ return this.cache.resolveKey ? await this.cache.resolveKey(key) : key;
538
389
  }
539
- error(message, meta) {
540
- console.error(message, meta ?? "");
390
+ /**
391
+ * Removes a cached response by key when the configured cache adapter
392
+ * supports deletion. Cache invalidation is the caller's responsibility;
393
+ * only the application knows when cached data is stale.
394
+ */
395
+ async deleteCache(key) {
396
+ if (!this.cache.delete) return;
397
+ await this.cache.delete(await this.resolveCacheKey(key));
541
398
  }
542
- };
543
-
544
- //#endregion
545
- //#region src/types/cache.ts
546
- /**
547
- * Trivial default so the package works out of the box with no external deps
548
- * Not shared across processes, swap in Redis/Upstash/etc for production
549
- */
550
- var InMemoryCacheAdapter = class {
551
- store = new Map();
552
- constructor(maxSize = 1e3) {
553
- this.maxSize = maxSize;
399
+ /** Logs a failed refundUsage attempt via the configured logger. */
400
+ logRefundError(logMessage, error) {
401
+ this.logger.error(logMessage, { message: error instanceof Error ? error.message : "unknown" });
554
402
  }
555
- async get(key) {
556
- const entry = this.store.get(key);
557
- if (!entry) return {
558
- hit: false,
559
- value: null
403
+ /**
404
+ * Internal cache primitive around caller-supplied logic. Concurrent misses
405
+ * for the same `cacheKey` share a single in-flight call, avoiding cache
406
+ * stampedes.
407
+ *
408
+ * Backs the public `VernLLM.cachedCall()`, which always composes this
409
+ * with `call()` so cached results get the same retry/timeout/
410
+ * circuit-breaker guarantees as any other LLM call.
411
+ *
412
+ * @param params `cacheKey`, `ttl`, `fn` (the work to run on a cache
413
+ * miss, typically `() => this.call(...)`), and optional
414
+ * `reserveUsage`/`refundUsage`/`signal`. See `InternalCacheParams`.
415
+ * @returns The cached value on a hit, or the result of `fn()` on a miss.
416
+ */
417
+ async runCached(params) {
418
+ const resolvedKey = await this.resolveCacheKey(params.cacheKey);
419
+ const resolvedParams = resolvedKey === params.cacheKey ? params : {
420
+ ...params,
421
+ cacheKey: resolvedKey
422
+ };
423
+ const cached = await this.cache.get(resolvedKey);
424
+ if (cached.hit) return cached.value;
425
+ const existing = this.inFlight.get(resolvedKey);
426
+ if (existing) return withReservedUsage(resolvedParams, true, () => existing, params.signal, (logMessage, error) => this.logRefundError(logMessage, error));
427
+ return this.registerTrigger(resolvedParams);
428
+ }
429
+ /** Starts the shared fn() call for a cache miss and tracks it in the in-flight map until it settles. */
430
+ registerTrigger(params) {
431
+ const resultPromise = withReservedUsage(params, false, () => this.runAndCache(params), params.signal, (logMessage, error) => this.logRefundError(logMessage, error));
432
+ this.inFlight.set(params.cacheKey, resultPromise);
433
+ resultPromise.catch(() => {}).finally(() => {
434
+ this.inFlight.delete(params.cacheKey);
435
+ });
436
+ return resultPromise;
437
+ }
438
+ /** Runs `fn` and writes its result to the cache. */
439
+ async runAndCache(params) {
440
+ const result = await params.fn();
441
+ try {
442
+ await this.cache.set(params.cacheKey, result, params.ttl);
443
+ } catch (error) {
444
+ this.logger.error("[VernLLM] cache write failed", { message: error instanceof Error ? error.message : "unknown" });
445
+ }
446
+ return result;
447
+ }
448
+ /**
449
+ * Streaming counterpart to `runCached`. Three cases:
450
+ *
451
+ * - Hit: no live generation to relay. Returns immediately with
452
+ * `finalResult` resolved to the cached value and a one-shot `chunks`
453
+ * replay built from it, so `for await (const c of chunks)` call sites
454
+ * work identically on a hit or a miss. No usage hooks fire, since
455
+ * nothing was actually spent.
456
+ * - Miss, nothing else in flight for this key: delegates to
457
+ * `registerStreamTrigger`, which opens the stream and relays its
458
+ * `chunks` live.
459
+ * - Miss, but another call for the same key is already in flight: this
460
+ * call has no live chunks of its own to relay, so it's treated like a
461
+ * delayed hit. `finalResult` shares the trigger's in-flight promise
462
+ * (the same in-flight map non-streaming `runCached` uses, so
463
+ * streaming and non-streaming calls for the same key coalesce
464
+ * against each other too), and `chunks` is a one-shot replay built
465
+ * once that promise resolves.
466
+ */
467
+ async runCachedStream(params, hasTools) {
468
+ const resolvedKey = await this.resolveCacheKey(params.cacheKey);
469
+ const resolvedParams = resolvedKey === params.cacheKey ? params : {
470
+ ...params,
471
+ cacheKey: resolvedKey
472
+ };
473
+ const cached = await this.cache.get(resolvedKey);
474
+ if (cached.hit) {
475
+ const value = cached.value;
476
+ return {
477
+ chunks: buildReplayChunks(value, hasTools),
478
+ finalResult: Promise.resolve(value)
479
+ };
480
+ }
481
+ const existing = this.inFlight.get(resolvedKey);
482
+ if (existing) {
483
+ const finalResult = withReservedUsage(resolvedParams, true, () => existing, params.signal, (logMessage, error) => this.logRefundError(logMessage, error));
484
+ finalResult.catch(() => {});
485
+ return {
486
+ chunks: buildReplayChunksFromPromise(finalResult, hasTools),
487
+ finalResult
488
+ };
489
+ }
490
+ return this.registerStreamTrigger(resolvedParams);
491
+ }
492
+ /**
493
+ * Opens the shared stream for a cache miss and tracks its settled value
494
+ * in the in-flight map until it resolves or rejects. Writes to the cache
495
+ * on success only, matching `runAndCache`.
496
+ *
497
+ * Registers the in-flight promise synchronously, before anything async
498
+ * runs, so a concurrent `cachedCall` for the same key always sees it in
499
+ * time to join instead of triggering its own stream. Settlement is
500
+ * wired onto the whole `withReservedUsageForStream` call rather than a
501
+ * line inside its callback, so any failure point (reserving usage,
502
+ * opening the stream, or the stream itself) reliably settles the
503
+ * in-flight entry instead of leaving it stuck.
504
+ */
505
+ registerStreamTrigger(params) {
506
+ let resolveInFlight;
507
+ let rejectInFlight;
508
+ const inFlightResult = new Promise((resolve, reject) => {
509
+ resolveInFlight = resolve;
510
+ rejectInFlight = reject;
511
+ });
512
+ this.inFlight.set(params.cacheKey, inFlightResult);
513
+ inFlightResult.catch(() => {}).finally(() => {
514
+ this.inFlight.delete(params.cacheKey);
515
+ });
516
+ const streamPromise = withReservedUsageForStream(params, async () => {
517
+ const opened = await params.openStream();
518
+ const trackedResult = opened.finalResult.then(async (value) => {
519
+ try {
520
+ await this.cache.set(params.cacheKey, value, params.ttl);
521
+ } catch (error) {
522
+ this.logger.error("[VernLLM] cache write failed", { message: error instanceof Error ? error.message : "unknown" });
523
+ }
524
+ return value;
525
+ }, (error) => {
526
+ throw error;
527
+ });
528
+ return {
529
+ chunks: opened.chunks,
530
+ finalResult: trackedResult
531
+ };
532
+ }, params.signal, (logMessage, error) => this.logRefundError(logMessage, error));
533
+ streamPromise.then((opened) => {
534
+ opened.finalResult.then(resolveInFlight, rejectInFlight);
535
+ }, (error) => {
536
+ rejectInFlight(error);
537
+ });
538
+ return streamPromise;
539
+ }
540
+ };
541
+
542
+ //#endregion
543
+ //#region src/circuitBreaker.ts
544
+ function newBucket() {
545
+ return {
546
+ state: "closed",
547
+ consecutiveFailures: 0,
548
+ openedAt: 0,
549
+ trialInFlight: false
550
+ };
551
+ }
552
+ /** Key a bucket lookup falls into when the call omitted `model` under `isolateByModel`. */
553
+ const UNLABELED_MODEL = "";
554
+ /**
555
+ * Per retry VernLLM-instance circuit breaker. Tracks consecutive failures across
556
+ * calls. Once the threshold is hit, short-circuits new calls with an
557
+ * LLMError('circuit_open') instead of hitting the provider, until the
558
+ * cooldown elapses and a single trial call is allowed through
559
+ */
560
+ var CircuitBreaker = class {
561
+ threshold;
562
+ cooldownMs;
563
+ onStateChange;
564
+ isolateByModel;
565
+ sharedBucket = newBucket();
566
+ bucketsByModel = new Map();
567
+ constructor(options = {}) {
568
+ this.threshold = options.threshold ?? 5;
569
+ this.cooldownMs = options.cooldownMs ?? 3e4;
570
+ this.onStateChange = options.onStateChange;
571
+ this.isolateByModel = options.isolateByModel ?? false;
572
+ }
573
+ /** Returns the bucket for a model if one already exists, without allocating. */
574
+ lookupBucket(model) {
575
+ if (!this.isolateByModel) return this.sharedBucket;
576
+ const key = model ?? UNLABELED_MODEL;
577
+ return this.bucketsByModel.get(key);
578
+ }
579
+ /** Creates and stores a bucket for a model when the first mutation needs one. */
580
+ ensureBucketFor(model) {
581
+ if (!this.isolateByModel) return this.sharedBucket;
582
+ const key = model ?? UNLABELED_MODEL;
583
+ let bucket = this.bucketsByModel.get(key);
584
+ if (!bucket) {
585
+ bucket = newBucket();
586
+ this.bucketsByModel.set(key, bucket);
587
+ }
588
+ return bucket;
589
+ }
590
+ /** Every state mutation routes through here, so `onStateChange` fires exactly once per real change. */
591
+ transition(bucket, to, model) {
592
+ if (to === bucket.state) return;
593
+ const from = bucket.state;
594
+ bucket.state = to;
595
+ this.onStateChange?.(from, to, bucket.consecutiveFailures, model);
596
+ }
597
+ /**
598
+ * Throws if the circuit is open and the cooldown hasn't elapsed, or if
599
+ * the circuit is half-open and a trial call is already in flight.
600
+ * Otherwise, if the circuit just became eligible for a trial (cooldown
601
+ * elapsed, or half-open with no trial currently running), this call
602
+ * becomes that trial
603
+ */
604
+ assertClosed(model) {
605
+ const bucket = this.ensureBucketFor(model);
606
+ if (bucket.state === "closed") return;
607
+ if (bucket.state === "open") {
608
+ const elapsed = Date.now() - bucket.openedAt;
609
+ if (elapsed < this.cooldownMs) throw new LLMError(`Circuit open, provider has failed ${bucket.consecutiveFailures} times in a row. Retry in ${Math.ceil((this.cooldownMs - elapsed) / 1e3)}s.`, "circuit_open");
610
+ bucket.trialInFlight = true;
611
+ this.transition(bucket, "half-open", model);
612
+ return;
613
+ }
614
+ if (bucket.trialInFlight) throw new LLMError("Circuit half-open. A trial request is already in flight. Try again shortly.", "circuit_open");
615
+ bucket.trialInFlight = true;
616
+ }
617
+ recordSuccess(model) {
618
+ const bucket = this.lookupBucket(model);
619
+ if (!bucket) return;
620
+ bucket.consecutiveFailures = 0;
621
+ bucket.trialInFlight = false;
622
+ this.transition(bucket, "closed", model);
623
+ if (this.isolateByModel && bucket.state === "closed" && bucket.consecutiveFailures === 0) this.bucketsByModel.delete(model ?? UNLABELED_MODEL);
624
+ }
625
+ recordFailure(model) {
626
+ const bucket = this.ensureBucketFor(model);
627
+ bucket.consecutiveFailures += 1;
628
+ bucket.trialInFlight = false;
629
+ if (bucket.state === "half-open") {
630
+ bucket.openedAt = Date.now();
631
+ this.transition(bucket, "open", model);
632
+ return;
633
+ }
634
+ if (bucket.consecutiveFailures >= this.threshold) {
635
+ bucket.openedAt = Date.now();
636
+ this.transition(bucket, "open", model);
637
+ }
638
+ }
639
+ /**
640
+ * With `isolateByModel` off (the default), `model` is ignored and the
641
+ * one shared circuit's state is returned, unchanged from every version
642
+ * before this option existed. With `isolateByModel` on, returns that
643
+ * model's own state, `'closed'` for a model never seen yet, same as a
644
+ * fresh breaker.
645
+ */
646
+ getState(model) {
647
+ return this.lookupBucket(model)?.state ?? "closed";
648
+ }
649
+ };
650
+
651
+ //#endregion
652
+ //#region src/internal/circuitBreaker.utils.ts
653
+ /**
654
+ * Builds a `(event) => void` reporter that no-ops when `onEvent` is unset,
655
+ * and otherwise calls it, swallowing and logging any error the handler
656
+ * throws so a broken `onEvent` can't break the call that triggered it.
657
+ * Shared by `buildCircuitBreaker` (which needs to report before any
658
+ * executor exists) and `CallExecutor.reportEvent`, kept independent of the
659
+ * executor for that reason.
660
+ */
661
+ function makeEventReporter(onEvent, logger) {
662
+ return (event) => {
663
+ if (!onEvent) return;
664
+ try {
665
+ onEvent(event);
666
+ } catch (error) {
667
+ logger.error("[VernLLM] onEvent failed", { message: error instanceof Error ? error.message : "unknown" });
668
+ }
669
+ };
670
+ }
671
+ /**
672
+ * Builds the optional circuit breaker for one provider target, wiring its
673
+ * `onStateChange` to emit a `circuit_state` event and chain any
674
+ * caller-supplied `onStateChange`. Returns `undefined` when
675
+ * `circuitBreakerOption` is falsy, matching the option's own semantics.
676
+ *
677
+ * Lives outside `CallExecutor` (and outside `VernLLM`, once this were
678
+ * inlined) because the breaker has to exist *before* the executor it's
679
+ * passed into, so its construction can't be an executor concern.
680
+ * `onEvent` is called directly rather than through the executor for the
681
+ * same reason: nothing executor-shaped exists yet at this point.
682
+ *
683
+ * Takes the specific fields it needs (rather than a full `VernLLMOptions`)
684
+ * so it works identically for the primary target and for each fallback
685
+ * target, which carry their own `circuitBreaker` override alongside the
686
+ * shared `onEvent`.
687
+ */
688
+ function buildCircuitBreaker(circuitBreakerOption, providerName, defaultModel, onEvent, logger) {
689
+ if (!circuitBreakerOption) return void 0;
690
+ const breakerOptions = typeof circuitBreakerOption === "object" ? circuitBreakerOption : void 0;
691
+ const userOnStateChange = breakerOptions?.onStateChange;
692
+ const reportEvent = makeEventReporter(onEvent, logger);
693
+ return new CircuitBreaker({
694
+ ...breakerOptions,
695
+ onStateChange: (from, to, consecutiveFailures, model) => {
696
+ reportEvent({
697
+ kind: "circuit_state",
698
+ provider: providerName,
699
+ model: model ?? defaultModel,
700
+ from,
701
+ to,
702
+ consecutiveFailures
703
+ });
704
+ if (!userOnStateChange) return;
705
+ try {
706
+ userOnStateChange(from, to, consecutiveFailures, model);
707
+ } catch (error) {
708
+ logger.error("[VernLLM] circuitBreaker.onStateChange failed", { message: error instanceof Error ? error.message : "unknown" });
709
+ }
710
+ }
711
+ });
712
+ }
713
+
714
+ //#endregion
715
+ //#region src/internal/execution/retry.utils.ts
716
+ /**
717
+ * Default cap (ms) for both exponential backoff and honored Retry-After
718
+ * values, so a misbehaving/adversarial Retry-After can't stall a caller
719
+ * indefinitely
720
+ */
721
+ const DEFAULT_MAX_DELAY_MS = 1e4;
722
+ /**
723
+ * `setTimeout` silently clamps any delay above this (~24.8 days) or
724
+ * `Infinity` down to ~1ms instead of erroring, so a caller passing
725
+ * `Infinity` as "no timeout" gets the opposite of what they asked for.
726
+ * Both timeout helpers below guard against this explicitly.
727
+ */
728
+ const MAX_SETTIMEOUT_MS = 2147483647;
729
+ /**
730
+ * Resolves a timeout value to the number `setTimeout` should actually use,
731
+ * or `undefined` when the timeout should be treated as disabled (0,
732
+ * negative, or `Infinity`). Returning the resolved value directly, rather
733
+ * than a boolean, lets callers narrow `number | undefined` to `number`
734
+ * without an `as number` cast.
735
+ */
736
+ function resolveActiveTimeoutMs(ms) {
737
+ return !ms || ms <= 0 || ms === Infinity ? void 0 : ms;
738
+ }
739
+ /** Caps a timeout at the largest delay `setTimeout` actually honors. */
740
+ function clampTimeoutMs(ms) {
741
+ return Math.min(ms, MAX_SETTIMEOUT_MS);
742
+ }
743
+ /**
744
+ * Runs an async function and cancels it if it takes longer than the given
745
+ * timeout. Creates an internal abort controller that fires after the
746
+ * timeout elapses, and combines it with any external signal the caller
747
+ * passed in so either one can cancel the underlying call. If the internal
748
+ * timeout triggers and the underlying operation aborts, the error is
749
+ * converted into an LLMError with type "timeout". External cancellations
750
+ * continue to propagate as aborted errors. The internal timer is always
751
+ * cleared afterward, whether the function succeeds, fails, or is aborted,
752
+ * so nothing is left running in the background.
753
+ *
754
+ * `timeoutMs` of `Infinity` (or any value beyond what `setTimeout` can
755
+ * represent) disables the timeout rather than firing almost immediately.
756
+ */
757
+ async function withTimeout(fn, timeoutMs, externalSignal) {
758
+ const controller = new AbortController();
759
+ const activeTimeoutMs = resolveActiveTimeoutMs(timeoutMs);
760
+ const timer = activeTimeoutMs === void 0 ? void 0 : setTimeout(() => {
761
+ controller.abort();
762
+ }, clampTimeoutMs(activeTimeoutMs));
763
+ const signal = externalSignal ? AbortSignal.any([externalSignal, controller.signal]) : controller.signal;
764
+ try {
765
+ return await fn(signal);
766
+ } catch (err) {
767
+ if (controller.signal.aborted && !externalSignal?.aborted && err instanceof DOMException && err.name === "AbortError") throw new LLMError("Request timed out", "timeout");
768
+ throw err;
769
+ } finally {
770
+ clearTimeout(timer);
771
+ }
772
+ }
773
+ /**
774
+ * Races one `iterator.next()` call against a per-call idle timer, to
775
+ * bound the gap *between* chunks (unlike `withTimeout`, which only bounds
776
+ * opening the stream and its first chunk). Without this, a connection
777
+ * that streams one chunk then hangs would never fail.
778
+ *
779
+ * `timeoutMs` of 0/undefined/`Infinity` disables the check. Otherwise
780
+ * rejects with `LLMError('timeout')` if `next()` doesn't settle in time.
781
+ * The clock resets on every call, so the window is measured from the most
782
+ * recent chunk, not from stream start.
783
+ *
784
+ * `onIdle`, if given, is called the moment the timer fires (before the
785
+ * rejection), so callers can abort the underlying transport instead of
786
+ * just walking away from an unread promise. `logger`, if given, records a
787
+ * debug line if `next()` still settles *after* the idle timeout already
788
+ * rejected. `resolve`/`reject` on an already-settled promise is otherwise
789
+ * a silent no-op, so without this the late chunk (possibly the final
790
+ * usage chunk) would vanish with no trace.
791
+ */
792
+ function withChunkIdleTimeout(next, timeoutMs, onIdle, logger) {
793
+ const activeTimeoutMs = resolveActiveTimeoutMs(timeoutMs);
794
+ if (activeTimeoutMs === void 0) return next();
795
+ let settled = false;
796
+ return new Promise((resolve, reject) => {
797
+ const timer = setTimeout(() => {
798
+ settled = true;
799
+ onIdle?.();
800
+ reject(new LLMError(`No stream chunk received for ${activeTimeoutMs}ms (idle timeout)`, "timeout"));
801
+ }, clampTimeoutMs(activeTimeoutMs));
802
+ next().then((result) => {
803
+ clearTimeout(timer);
804
+ if (settled) {
805
+ logger?.debug("[VernLLM] chunk resolved after idle timeout already fired; discarding");
806
+ return;
807
+ }
808
+ settled = true;
809
+ resolve(result);
810
+ }, (error) => {
811
+ clearTimeout(timer);
812
+ if (settled) {
813
+ logger?.debug("[VernLLM] chunk rejection arrived after idle timeout already fired; discarding");
814
+ return;
815
+ }
816
+ settled = true;
817
+ reject(error);
818
+ });
819
+ });
820
+ }
821
+ /**
822
+ * Looks inside an unknown error value for a Retry-After header and
823
+ * converts it to milliseconds. Checks `.headers` first (fetch-style,
824
+ * Headers-like with `.get()`), then `.response.headers` (axios-style,
825
+ * plain object) since different client libraries surface headers
826
+ * differently. Supports both the delta-seconds form ("30") and the
827
+ * HTTP-date form ("Wed, 21 Oct 2015 07:28:00 GMT"). The result is capped
828
+ * at maxDelayMs. Returns undefined when no usable Retry-After is present
829
+ */
830
+ function extractRetryAfterMs(err, maxDelayMs = DEFAULT_MAX_DELAY_MS) {
831
+ if (!err || typeof err !== "object") return void 0;
832
+ const error = err;
833
+ const headers = error.headers ?? error.response?.headers;
834
+ if (!headers || typeof headers !== "object") return void 0;
835
+ const getter = headers;
836
+ const raw = typeof getter.get === "function" ? getter.get("Retry-After") : Object.entries(headers).find(([name]) => name.toLowerCase() === "retry-after")?.at(1);
837
+ if (typeof raw !== "string" || raw.trim() === "") return void 0;
838
+ const trimmed = raw.trim();
839
+ if (/^\d+$/.test(trimmed)) return Math.max(0, Math.min(Number(trimmed) * 1e3, maxDelayMs));
840
+ const dateMs = Date.parse(trimmed);
841
+ if (!Number.isNaN(dateMs)) return Math.max(0, Math.min(dateMs - Date.now(), maxDelayMs));
842
+ return void 0;
843
+ }
844
+ /**
845
+ * Exponential backoff with jitter, capped at maxDelayMs.
846
+ * Jitter avoids thundering-herd retries when many callers back off in lockstep,
847
+ * the cap prevents unbounded delays when maxRetries is high
848
+ */
849
+ function getBackoffDelay(baseDelayMs, attempt, maxDelayMs = DEFAULT_MAX_DELAY_MS) {
850
+ const exp = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
851
+ return exp / 2 + Math.random() * (exp / 2);
852
+ }
853
+ /**
854
+ * Pauses execution for the given delay before a retry attempt. If an
855
+ * abort signal is provided and it fires while waiting, the pending
856
+ * timer is cancelled immediately and the wait rejects right away with
857
+ * an aborted error instead of continuing to sit idle until the delay
858
+ * would have finished on its own
859
+ */
860
+ async function waitForRetry(delay, signal) {
861
+ if (signal?.aborted) throw new LLMError("Operation aborted", "aborted");
862
+ await new Promise((resolve, reject) => {
863
+ const onAbort = () => {
864
+ clearTimeout(timer);
865
+ reject(new LLMError("Operation aborted", "aborted"));
866
+ };
867
+ const timer = setTimeout(() => {
868
+ signal?.removeEventListener("abort", onAbort);
869
+ resolve();
870
+ }, delay);
871
+ signal?.addEventListener("abort", onAbort, { once: true });
872
+ });
873
+ }
874
+
875
+ //#endregion
876
+ //#region src/internal/execution/errors.utils.ts
877
+ /**
878
+ * Looks inside an unknown error value and pulls out an http status code
879
+ * if one is present. Checks the status field first then the status code
880
+ * field since different client libraries use different names for this,
881
+ * falling back to AWS SDK v3's `$metadata.httpStatusCode` (e.g. Bedrock's
882
+ * `ThrottlingException`), which doesn't set either of the other two.
883
+ * Returns undefined when the error is not an object or carries no status
884
+ */
885
+ function extractStatus(err) {
886
+ if (!err || typeof err !== "object") return void 0;
887
+ const error = err;
888
+ if (typeof error.status === "number") return error.status;
889
+ if (typeof error.statusCode === "number") return error.statusCode;
890
+ if (typeof error.$metadata?.httpStatusCode === "number") return error.$metadata.httpStatusCode;
891
+ return void 0;
892
+ }
893
+ function formatSafely(value) {
894
+ try {
895
+ return JSON.stringify(value, null, 2) ?? String(value);
896
+ } catch {
897
+ try {
898
+ return String(value);
899
+ } catch {
900
+ return "[unprintable error]";
901
+ }
902
+ }
903
+ }
904
+ /**
905
+ * Looks inside an unknown thrown value and pulls out a human-readable
906
+ * description of it. Checks the `error` field first (the provider's raw
907
+ * rejection body, JSON-stringified if possible) then falls back to the
908
+ * message` field. Always returns a safe string, even when the thrown value
909
+ * has hostile properties or cannot be serialized normally.
910
+ */
911
+ function describeError(err) {
912
+ if (err && typeof err === "object") try {
913
+ const error = err;
914
+ if (error.error !== void 0) return formatSafely(error.error);
915
+ if (typeof error.message === "string") return error.message;
916
+ } catch {}
917
+ return formatSafely(err);
918
+ }
919
+ /** Converts any thrown value into a well-typed LLMError. */
920
+ function normalizeError(error, signal) {
921
+ if (signal?.aborted) return new LLMError("LLM request aborted", "aborted");
922
+ if (error instanceof LLMError) {
923
+ if (error.status === 429 && error.code === void 0) error.code = "provider_rate_limited";
924
+ return error;
925
+ }
926
+ const status = extractStatus(error);
927
+ const retryAfterMs = extractRetryAfterMs(error);
928
+ if (status !== void 0) return new LLMError("LLM request failed", "api", status, void 0, error, retryAfterMs, status === 429 ? "provider_rate_limited" : void 0);
929
+ return new LLMError("LLM request failed", "unknown", void 0, void 0, error, retryAfterMs);
930
+ }
931
+
932
+ //#endregion
933
+ //#region src/internal/execution/parse.utils.ts
934
+ /** Default `parseJson`: `JSON.parse` wrapped in try/catch, returning `undefined` on failure. */
935
+ function defaultParseJson(content) {
936
+ try {
937
+ return JSON.parse(content);
938
+ } catch {
939
+ return void 0;
940
+ }
941
+ }
942
+
943
+ //#endregion
944
+ //#region src/internal/execution/wire.utils.ts
945
+ /** Translates app-facing `ToolDefinition[]` into the OpenAI-shaped wire tools array. */
946
+ function toWireTools(tools) {
947
+ return tools.map((tool) => ({
948
+ type: "function",
949
+ function: {
950
+ name: tool.name,
951
+ description: tool.description,
952
+ parameters: tool.parameters
953
+ }
954
+ }));
955
+ }
956
+ /** Translates app-facing `ToolCall[]` (e.g. from a replayed assistant turn) into wire tool_calls. */
957
+ function toWireToolCalls(toolCalls) {
958
+ return toolCalls.map((tc) => ({
959
+ id: tc.id,
960
+ type: "function",
961
+ function: {
962
+ name: tc.name,
963
+ arguments: JSON.stringify(tc.arguments ?? {})
964
+ }
965
+ }));
966
+ }
967
+ /**
968
+ * Parses the provider's wire-shaped `tool_calls` back into VernLLM's
969
+ * `ToolCall[]`. Malformed argument JSON is a `'parse'` error, same
970
+ * convention as malformed JSON response bodies elsewhere in VernLLM.
971
+ */
972
+ function parseWireToolCalls(wireToolCalls) {
973
+ return wireToolCalls.map((wc) => {
974
+ let parsedArgs;
975
+ try {
976
+ parsedArgs = wc.function.arguments.trim() ? JSON.parse(wc.function.arguments) : {};
977
+ } catch {
978
+ throw new LLMError(`Invalid JSON arguments for tool call "${wc.function.name}"`, "parse");
979
+ }
980
+ return {
981
+ id: wc.id,
982
+ name: wc.function.name,
983
+ arguments: parsedArgs
984
+ };
985
+ });
986
+ }
987
+
988
+ //#endregion
989
+ //#region src/internal/execution/requestBuilder.ts
990
+ /**
991
+ * Builds the wire request object for one call, applying per-instance
992
+ * defaults (model, max tokens, temperature) and per-call overrides.
993
+ * Owns every validation that depends only on shape, not on execution:
994
+ * history alternation, duplicate/empty tool lists, `toolChoice` naming a
995
+ * real tool. Has no knowledge of retry, timeouts, or the breaker, only
996
+ * the three defaults a `FallbackTarget` can override per-target (see the
997
+ * `defaultMaxTokens`/`defaultTemperature` overrides in the fallback
998
+ * design), which is what keeps it separable from `CallExecutor`.
999
+ */
1000
+ var RequestBuilder = class {
1001
+ model;
1002
+ defaultMaxTokens;
1003
+ defaultTemperature;
1004
+ constructor(options) {
1005
+ this.model = options.model;
1006
+ this.defaultMaxTokens = options.defaultMaxTokens;
1007
+ this.defaultTemperature = options.defaultTemperature;
1008
+ }
1009
+ /** Applies per-call defaults and shapes params into the client's request object. */
1010
+ build(params) {
1011
+ const { systemPrompt, userContent, history = [], maxTokens = this.defaultMaxTokens, model = this.model, reasoningEffort, jsonSchema, tools, toolChoice } = params;
1012
+ const temperature = params.temperature === void 0 ? this.defaultTemperature : params.temperature;
1013
+ if (tools && tools.length === 0) throw new LLMError("`tools` was an empty array. This is almost always a bug (e.g. a filtered tool list that ended up empty). An empty `tools` array still switches on tool-call mode (response shape, jsonMode default, wire format) with nothing for the model to call. Omit `tools` entirely for a normal call, or make sure the array is non-empty.", "validation");
1014
+ if (tools) {
1015
+ const seen = new Set();
1016
+ const duplicates = new Set();
1017
+ for (const tool of tools) {
1018
+ if (seen.has(tool.name)) duplicates.add(tool.name);
1019
+ seen.add(tool.name);
1020
+ }
1021
+ if (duplicates.size) throw new LLMError(`\`tools\` has duplicate name(s): [${[...duplicates].join(", ")}]. Tool names must be unique.`, "validation");
1022
+ }
1023
+ if (toolChoice && !tools) throw new LLMError("`toolChoice` was set without `tools`. There is nothing for it to choose between. Set `tools`, or remove `toolChoice`.", "validation");
1024
+ if (tools && typeof toolChoice === "object" && !tools.some((t) => t.name === toolChoice.name)) throw new LLMError(`toolChoice names "${toolChoice.name}", which is not in \`tools\` ([${tools.map((t) => t.name).join(", ")}]).`, "validation");
1025
+ const jsonMode = params.jsonMode ?? (tools ? false : true);
1026
+ const useJson = jsonMode || Boolean(jsonSchema);
1027
+ if (params.schema && !useJson) throw new LLMError("schema was provided but jsonMode: false disables JSON parsing, so nothing would validate it. Remove jsonMode: false, set jsonSchema, or remove schema.", "validation");
1028
+ const responseFormat = this.buildResponseFormat(jsonSchema, useJson);
1029
+ this.validateHistory(history);
1030
+ const request = {
1031
+ model,
1032
+ ...temperature !== null ? { temperature } : {},
1033
+ max_tokens: maxTokens,
1034
+ ...responseFormat ? { response_format: responseFormat } : {},
1035
+ ...reasoningEffort ? { reasoning_effort: reasoningEffort } : {},
1036
+ ...tools ? { tools: toWireTools(tools) } : {},
1037
+ ...tools ? { tool_choice: this.buildWireToolChoice(toolChoice) } : {},
1038
+ messages: [
1039
+ ...systemPrompt ? [{
1040
+ role: "system",
1041
+ content: systemPrompt
1042
+ }] : [],
1043
+ ...history.flatMap((turn) => this.turnToWireMessages(turn)),
1044
+ {
1045
+ role: "user",
1046
+ content: userContent
1047
+ }
1048
+ ]
560
1049
  };
561
- if (Date.now() >= entry.expiresAt) {
562
- this.store.delete(key);
563
- return {
564
- hit: false,
565
- value: null
566
- };
567
- }
568
1050
  return {
569
- hit: true,
570
- value: entry.value
1051
+ useJson,
1052
+ model,
1053
+ request
571
1054
  };
572
1055
  }
573
- async set(key, value, ttl) {
574
- this.cleanupExpiredEntries();
575
- this.store.set(key, {
576
- value,
577
- expiresAt: Date.now() + ttl * 1e3
578
- });
579
- this.enforceSizeLimit();
580
- }
581
- async delete(key) {
582
- this.store.delete(key);
583
- }
584
- cleanupExpiredEntries() {
585
- const now = Date.now();
586
- for (const [key, entry] of this.store) if (now >= entry.expiresAt) this.store.delete(key);
587
- }
588
- enforceSizeLimit() {
589
- while (this.store.size > this.maxSize) {
590
- const oldestKey = this.store.keys().next().value;
591
- if (oldestKey === void 0) break;
592
- this.store.delete(oldestKey);
1056
+ /**
1057
+ * Validates `history` alternates user/assistant turns, since providers
1058
+ * like Anthropic/Gemini reject or mishandle consecutive same-role turns.
1059
+ */
1060
+ validateHistory(history) {
1061
+ let previousTurn;
1062
+ for (const [index, turn] of history.entries()) {
1063
+ if (turn.role === "tool") {
1064
+ if (previousTurn?.role !== "assistant" || !previousTurn.toolCalls?.length) throw new LLMError(`history[${index}] is a "tool" turn, but must immediately follow an "assistant" turn that requested tools`, "validation");
1065
+ if (!turn.toolResults?.length) throw new LLMError(`history[${index}] is a "tool" turn but has no toolResults`, "validation");
1066
+ const requestedIds = new Set(previousTurn.toolCalls.map((tc) => tc.id));
1067
+ const resultIds = turn.toolResults.map((tr) => tr.toolCallId);
1068
+ const unknownIds = resultIds.filter((id) => !requestedIds.has(id));
1069
+ if (unknownIds.length) throw new LLMError(`history[${index}].toolResults references unknown toolCallId(s) [${unknownIds.join(", ")}]`, "validation");
1070
+ const seenIds = new Set();
1071
+ const duplicateIds = new Set();
1072
+ for (const id of resultIds) {
1073
+ if (seenIds.has(id)) duplicateIds.add(id);
1074
+ seenIds.add(id);
1075
+ }
1076
+ if (duplicateIds.size) throw new LLMError(`history[${index}].toolResults has duplicate toolCallId(s) [${[...duplicateIds].join(", ")}]`, "validation");
1077
+ const missingIds = [...requestedIds].filter((id) => !resultIds.includes(id));
1078
+ if (missingIds.length) throw new LLMError(`history[${index}] is missing toolResults for toolCallId(s) [${missingIds.join(", ")}]`, "validation");
1079
+ } else {
1080
+ if (turn.role === previousTurn?.role) throw new LLMError(`history must alternate user/assistant turns: consecutive "${turn.role}" turns at history[${index - 1}] and history[${index}]`, "validation");
1081
+ if (previousTurn?.role === "assistant" && previousTurn.toolCalls?.length) throw new LLMError(`history[${index}] follows an assistant tool request without tool results`, "validation");
1082
+ }
1083
+ previousTurn = turn;
593
1084
  }
1085
+ if (previousTurn?.role === "assistant" && previousTurn.toolCalls?.length) throw new LLMError("The last entry in history is an assistant tool request without tool results", "validation");
1086
+ if (previousTurn?.role === "user") throw new LLMError("The last entry in history is a \"user\" turn, which would collide with the current userContent turn.", "validation");
594
1087
  }
595
- };
596
- /**
597
- * Normalizes keys before caching to avoid duplicate entries from formatting differences.
598
- */
599
- var NormalizedCacheAdapter = class {
600
- constructor(inner = new InMemoryCacheAdapter()) {
601
- this.inner = inner;
602
- }
603
- normalize(key) {
604
- return key.toLowerCase().trim().replace(/[^\p{L}\p{N}\s]/gu, " ").replace(/\s+/g, " ").trim();
605
- }
606
- async resolveKey(key) {
607
- return this.normalize(key);
608
- }
609
- async get(key) {
610
- return this.inner.get(this.normalize(key));
611
- }
612
- async set(key, value, ttl) {
613
- await this.inner.set(this.normalize(key), value, ttl);
614
- }
615
- async delete(key) {
616
- await this.inner.delete?.(this.normalize(key));
617
- }
618
- };
619
- /**
620
- * Two-tier cache with fast local L1 and shared L2.
621
- * L2 hits are promoted back to L1.
622
- */
623
- var TieredCacheAdapter = class {
624
- constructor(l1, l2, l1Ttl) {
625
- this.l1 = l1;
626
- this.l2 = l2;
627
- this.l1Ttl = l1Ttl;
1088
+ /** Maps VernLLM's app-facing `ToolChoice` onto the OpenAI-shaped wire `tool_choice`. */
1089
+ buildWireToolChoice(toolChoice) {
1090
+ if (!toolChoice || toolChoice === "auto") return "auto";
1091
+ if (toolChoice === "none" || toolChoice === "required") return toolChoice;
1092
+ return {
1093
+ type: "function",
1094
+ function: { name: toolChoice.name }
1095
+ };
628
1096
  }
629
1097
  /**
630
- * Forwards to L1's `resolveKey` if it has one, otherwise L2's. L1 is
631
- * preferred since `get()` checks L1 first, so its notion of "the same
632
- * key" is the one that determines whether a lookup can skip L2 entirely.
1098
+ * Expands one `ConversationTurn` into one or more wire messages. Plain
1099
+ * user/assistant turns map 1:1. An assistant turn with `toolCalls` maps
1100
+ * to an assistant message carrying wire-shaped `tool_calls`. A `'tool'`
1101
+ * turn expands into one wire `tool` message per `toolResult`, since
1102
+ * OpenAI-shaped wire format wants one message per tool_call_id.
633
1103
  */
634
- async resolveKey(key) {
635
- if (this.l1.resolveKey) return this.l1.resolveKey(key);
636
- if (this.l2.resolveKey) return this.l2.resolveKey(key);
637
- return key;
638
- }
639
- async get(key) {
640
- const l1Result = await this.l1.get(key);
641
- if (l1Result.hit) return l1Result;
642
- const l2Result = await this.l2.get(key);
643
- if (l2Result.hit) await this.l1.set(key, l2Result.value, this.l1Ttl ?? 60);
644
- return l2Result;
645
- }
646
- async set(key, value, ttl) {
647
- await Promise.all([this.l1.set(key, value, this.l1Ttl ?? ttl), this.l2.set(key, value, ttl)]);
1104
+ turnToWireMessages(turn) {
1105
+ if (turn.role === "tool") return (turn.toolResults ?? []).map((tr) => ({
1106
+ role: "tool",
1107
+ tool_call_id: tr.toolCallId,
1108
+ content: typeof tr.content === "string" ? tr.content : JSON.stringify(tr.content ?? null),
1109
+ ...tr.isError ? { is_error: true } : {}
1110
+ }));
1111
+ if (turn.role === "assistant" && turn.toolCalls?.length) return [{
1112
+ role: "assistant",
1113
+ ...turn.content ? { content: turn.content } : {},
1114
+ tool_calls: toWireToolCalls(turn.toolCalls)
1115
+ }];
1116
+ return [{
1117
+ role: turn.role,
1118
+ content: turn.content ?? ""
1119
+ }];
648
1120
  }
649
- async delete(key) {
650
- await Promise.all([this.l1.delete?.(key), this.l2.delete?.(key)]);
1121
+ /**
1122
+ * Chooses the response format: a provider-native `jsonSchema` takes
1123
+ * priority when supplied (constrains generation directly), otherwise
1124
+ * falls back to the looser `json_object` mode when JSON output is
1125
+ * requested, or no format at all for plain text responses.
1126
+ */
1127
+ buildResponseFormat(jsonSchema, useJson) {
1128
+ if (jsonSchema) return {
1129
+ type: "json_schema",
1130
+ json_schema: {
1131
+ name: jsonSchema.name,
1132
+ schema: jsonSchema.schema,
1133
+ strict: jsonSchema.strict ?? true,
1134
+ description: jsonSchema.description
1135
+ }
1136
+ };
1137
+ return useJson ? { type: "json_object" } : void 0;
651
1138
  }
652
1139
  };
653
1140
 
654
1141
  //#endregion
655
- //#region src/types/tools.ts
1142
+ //#region src/internal/execution/streamAccumulator.ts
656
1143
  /**
657
- * Runtime-safe check for whether a `call()` result is a `tool_calls`
658
- * result. Prefer this over relying on TypeScript's static narrowing
659
- * whenever `params` passed to `call()` wasn't a literal with `tools`
660
- * inlined (see the "note on the overload" in `VernLLM.call`'s docs), in
661
- * that case TS may have typed the result as plain `T` even though it's
662
- * actually a `CallWithToolsResult<T>` at runtime, and this check works
663
- * either way.
1144
+ * The streaming accumulator: wraps the raw `WireStreamChunk` iterator in
1145
+ * an async generator that yields translated `StreamChunk`s to the caller
1146
+ * live, as they arrive, with no per-chunk timeout and no bound on total
1147
+ * duration, and accumulates text/tool-call deltas internally so that
1148
+ * `finalize` can produce `finalResult` once the stream completes.
1149
+ *
1150
+ * Two separate try/catches: the iteration loop's catch handles errors
1151
+ * the transport itself throws, which aren't normalized yet, so that
1152
+ * happens here, alongside the one `onStreamFailure` call for them. The
1153
+ * second catch, around `finalize`, does not re-normalize or re-report,
1154
+ * since `finalize`'s caller (`finalizeResponse`) already does both
1155
+ * internally.
664
1156
  */
665
- function isToolCallResult(result) {
666
- return typeof result === "object" && result !== null && "type" in result && result.type === "tool_calls" && Array.isArray(result.toolCalls);
1157
+ function buildStreamResult(iterator, first, options) {
1158
+ const { requestId, model, providerName, isFallback, chunkIdleTimeoutMs, streamController, logger, signal } = options;
1159
+ let resolveFinal;
1160
+ let rejectFinal;
1161
+ const finalResult = new Promise((resolve, reject) => {
1162
+ resolveFinal = resolve;
1163
+ rejectFinal = reject;
1164
+ });
1165
+ finalResult.catch(() => {});
1166
+ const MAX_BUFFERED_CHUNKS = 1e4;
1167
+ const buffered = [];
1168
+ const pending = [];
1169
+ let streamDone = false;
1170
+ let streamError;
1171
+ let hasLoggedEviction = false;
1172
+ const push = (chunk) => {
1173
+ const waiter = pending.shift();
1174
+ if (waiter) {
1175
+ waiter.resolve({
1176
+ done: false,
1177
+ value: chunk
1178
+ });
1179
+ return;
1180
+ }
1181
+ buffered.push(chunk);
1182
+ if (buffered.length > MAX_BUFFERED_CHUNKS * 2) {
1183
+ if (!hasLoggedEviction) {
1184
+ hasLoggedEviction = true;
1185
+ logger.warn(`[VernLLM] stream chunk buffer exceeded cap (${MAX_BUFFERED_CHUNKS}), evicting ${buffered.length - MAX_BUFFERED_CHUNKS} oldest chunk(s); buffered=${buffered.length}. The chunks iterable was never read (or fell far behind) for this stream.`);
1186
+ }
1187
+ buffered.splice(0, buffered.length - MAX_BUFFERED_CHUNKS);
1188
+ }
1189
+ };
1190
+ const finish = () => {
1191
+ streamDone = true;
1192
+ for (const waiter of pending.splice(0)) waiter.resolve({
1193
+ done: true,
1194
+ value: void 0
1195
+ });
1196
+ };
1197
+ const fail = (error) => {
1198
+ streamDone = true;
1199
+ streamError = error;
1200
+ for (const waiter of pending.splice(0)) waiter.reject(error);
1201
+ };
1202
+ const chunks = { [Symbol.asyncIterator]() {
1203
+ return { next() {
1204
+ if (buffered.length) return Promise.resolve({
1205
+ done: false,
1206
+ value: buffered.shift()
1207
+ });
1208
+ if (streamDone) return streamError ? Promise.reject(streamError) : Promise.resolve({
1209
+ done: true,
1210
+ value: void 0
1211
+ });
1212
+ return new Promise((resolve, reject) => {
1213
+ pending.push({
1214
+ resolve,
1215
+ reject
1216
+ });
1217
+ });
1218
+ } };
1219
+ } };
1220
+ const toolCallAcc = new Map();
1221
+ let textAcc = "";
1222
+ let usage;
1223
+ (async () => {
1224
+ try {
1225
+ let result = first;
1226
+ while (!result.done) {
1227
+ const wireChunk = result.value;
1228
+ if (wireChunk.type === "ping") {} else if (wireChunk.type === "text-delta") {
1229
+ textAcc += wireChunk.delta;
1230
+ push({
1231
+ type: "text-delta",
1232
+ delta: wireChunk.delta
1233
+ });
1234
+ } else if (wireChunk.type === "tool_call_delta") {
1235
+ const entry = toolCallAcc.get(wireChunk.index) ?? { args: "" };
1236
+ entry.id ??= wireChunk.id;
1237
+ entry.name ??= wireChunk.name;
1238
+ entry.args += wireChunk.argumentsDelta ?? "";
1239
+ toolCallAcc.set(wireChunk.index, entry);
1240
+ push({
1241
+ type: "tool_call_delta",
1242
+ index: wireChunk.index,
1243
+ id: wireChunk.id,
1244
+ name: wireChunk.name,
1245
+ argsDelta: wireChunk.argumentsDelta,
1246
+ complete: wireChunk.complete
1247
+ });
1248
+ } else if (wireChunk.type === "usage") {
1249
+ usage = {
1250
+ promptTokens: wireChunk.usage.prompt_tokens ?? 0,
1251
+ completionTokens: wireChunk.usage.completion_tokens ?? 0,
1252
+ totalTokens: wireChunk.usage.total_tokens ?? 0,
1253
+ requestId,
1254
+ model,
1255
+ provider: providerName,
1256
+ usedFallback: isFallback
1257
+ };
1258
+ push({
1259
+ type: "usage",
1260
+ usage
1261
+ });
1262
+ }
1263
+ result = await withChunkIdleTimeout(() => iterator.next(), chunkIdleTimeoutMs, () => streamController.abort(), logger);
1264
+ }
1265
+ } catch (error) {
1266
+ try {
1267
+ await iterator.return?.();
1268
+ } catch {}
1269
+ streamController.abort();
1270
+ const normalized = normalizeError(error, signal);
1271
+ try {
1272
+ options.onStreamFailure(normalized, usage);
1273
+ } catch {}
1274
+ fail(normalized);
1275
+ rejectFinal(normalized);
1276
+ return;
1277
+ }
1278
+ finish();
1279
+ try {
1280
+ options.onStreamSuccess(usage);
1281
+ } catch {}
1282
+ try {
1283
+ const wireToolCalls = toolCallAcc.size ? [...toolCallAcc.entries()].sort(([indexA], [indexB]) => indexA - indexB).map(([, entry]) => ({
1284
+ id: entry.id ?? "",
1285
+ type: "function",
1286
+ function: {
1287
+ name: entry.name ?? "",
1288
+ arguments: entry.args
1289
+ }
1290
+ })) : void 0;
1291
+ const finalized = options.finalize(textAcc, wireToolCalls, usage);
1292
+ resolveFinal(finalized);
1293
+ } catch (error) {
1294
+ rejectFinal(error);
1295
+ }
1296
+ })();
1297
+ return {
1298
+ chunks,
1299
+ finalResult
1300
+ };
667
1301
  }
668
1302
 
669
1303
  //#endregion
670
- //#region src/vernLLM.ts
1304
+ //#region src/internal/execution/callExecutor.ts
671
1305
  /**
672
- * A resilient layer around an LLM chat completions client. This is VernLLM!
673
- *
674
- * Adds retry with backoff and jitter, per-attempt timeouts, an optional
675
- * circuit breaker, JSON parsing with optional schema validation, usage
676
- * tracking, and an optional response cache. All configurable, all opt-in
677
- * beyond sensible defaults.
1306
+ * Everything one provider target needs to attempt a call: request
1307
+ * building, retry with backoff, the per-target breaker, the per-target
1308
+ * limiter. Never exported publicly. `VernLLM` holds one per target and
1309
+ * owns the fallback loop and caching on top.
678
1310
  */
679
- var VernLLM = class {
680
- client;
681
- model;
1311
+ var CallExecutor = class {
682
1312
  maxRetries;
683
1313
  timeoutMs;
684
1314
  chunkIdleTimeoutMs;
685
1315
  baseDelayMs;
686
- defaultMaxTokens;
687
- defaultTemperature;
688
- cache;
689
1316
  nonRetryableStatus;
690
- inFlight = new Map();
691
1317
  parseJson;
1318
+ logger;
1319
+ redact;
692
1320
  onUsage;
693
1321
  onUsageFailure;
694
- logger;
1322
+ reportEvent;
695
1323
  breaker;
696
- /**
697
- * @param options Client, model, and tunables. Defaults: `maxRetries` 1,
698
- * `timeoutMs` 25000, `baseDelayMs` 500, `defaultMaxTokens` 1000,
699
- * `defaultTemperature` 0.2, `cache` an in-memory adapter,
700
- * `nonRetryableStatus` `[400, 401, 403, 404, 422]`, `debug` false.
701
- */
702
- constructor(options) {
703
- this.client = options.client;
704
- this.model = options.model;
705
- this.maxRetries = options.maxRetries ?? 1;
706
- this.timeoutMs = options.timeoutMs ?? 25e3;
707
- this.chunkIdleTimeoutMs = options.chunkIdleTimeoutMs ?? 3e4;
708
- this.baseDelayMs = options.baseDelayMs ?? 500;
709
- this.defaultMaxTokens = options.defaultMaxTokens ?? 1e3;
710
- this.defaultTemperature = options.defaultTemperature === void 0 ? .2 : options.defaultTemperature;
711
- this.cache = options.cache ?? new InMemoryCacheAdapter();
712
- this.nonRetryableStatus = options.nonRetryableStatus ?? [
713
- 400,
714
- 401,
715
- 403,
716
- 404,
717
- 422
718
- ];
1324
+ limiter;
1325
+ isFallback;
1326
+ requestBuilder;
1327
+ constructor(providerName, client, model, options) {
1328
+ this.providerName = providerName;
1329
+ this.client = client;
1330
+ this.model = model;
1331
+ this.maxRetries = options.maxRetries;
1332
+ this.timeoutMs = options.timeoutMs;
1333
+ this.chunkIdleTimeoutMs = options.chunkIdleTimeoutMs;
1334
+ this.baseDelayMs = options.baseDelayMs;
1335
+ this.nonRetryableStatus = options.nonRetryableStatus;
719
1336
  this.parseJson = options.parseJson ?? defaultParseJson;
1337
+ this.logger = options.logger;
1338
+ this.redact = options.redact;
720
1339
  this.onUsage = options.onUsage;
721
1340
  this.onUsageFailure = options.onUsageFailure;
722
- this.logger = options.logger ?? new ConsoleLogger(options.debug ?? false);
723
- this.breaker = options.circuitBreaker ? new CircuitBreaker(options.circuitBreaker === true ? void 0 : options.circuitBreaker) : void 0;
1341
+ this.reportEvent = makeEventReporter(options.onEvent, this.logger);
1342
+ this.breaker = options.breaker;
1343
+ this.limiter = options.limiter;
1344
+ this.isFallback = options.isFallback ?? false;
1345
+ this.requestBuilder = new RequestBuilder({
1346
+ model,
1347
+ defaultMaxTokens: options.defaultMaxTokens,
1348
+ defaultTemperature: options.defaultTemperature
1349
+ });
724
1350
  }
725
- /** Resolves a cache key through the adapter when it supports normalization. */
726
- async resolveCacheKey(key) {
727
- return this.cache.resolveKey ? await this.cache.resolveKey(key) : key;
1351
+ getCircuitState(model) {
1352
+ return this.breaker?.getState(model);
1353
+ }
1354
+ /**
1355
+ * Throws if the breaker is open for this target/model, exactly like the
1356
+ * check `run`/`runStream` used to make internally. Exposed so `VernLLM`
1357
+ * can gate on it before reserving usage, avoiding a reserve-then-refund
1358
+ * round trip on a call that was never going to be attempted. `assertClosed`
1359
+ * has a stateful side effect (claiming a half-open trial slot), so it must
1360
+ * run exactly once per logical call: `run`/`runStream` no longer call it
1361
+ * themselves, this is now the only call site.
1362
+ */
1363
+ assertBreakerClosed(model) {
1364
+ this.breaker?.assertClosed(model ?? this.model);
1365
+ }
1366
+ /**
1367
+ * Runs a single logical call against this target: retry with backoff,
1368
+ * normalized error on exhaustion. Mirrors the old `VernLLM.call`'s
1369
+ * non-streaming branch, minus cache/usage-reservation and the breaker
1370
+ * check, which stay one layer up since they aren't per-target concerns
1371
+ * (see `assertBreakerClosed`).
1372
+ */
1373
+ async run(params, requestId, onAttempt) {
1374
+ const model = params.model ?? this.model;
1375
+ try {
1376
+ return await this.retryWithBackoff((attempt) => this.executeCall(params, requestId, attempt), requestId, model, params.signal, onAttempt);
1377
+ } catch (error) {
1378
+ const normalized = normalizeError(error, params.signal);
1379
+ if (this.countsTowardBreaker(normalized)) this.breaker?.recordFailure(model);
1380
+ this.logger.debug(`[VernLLM:${requestId}] error:\n${this.redactText(describeError(error))}`);
1381
+ throw normalized;
1382
+ }
728
1383
  }
729
- async call(params) {
730
- this.breaker?.assertClosed();
731
- if (params.signal?.aborted) throw new LLMError("LLM request aborted", "aborted");
732
- const requestId = params.requestId ?? randomUUID();
733
- if (params.stream) return withReservedUsageForStream(params, async () => {
734
- try {
735
- return await this.retryWithBackoff((attempt) => this.executeStreamCall(params, requestId, attempt), requestId, params.signal);
736
- } catch (error) {
737
- const normalized = normalizeError(error, params.signal);
738
- if (normalized.type !== "validation" && normalized.type !== "parse" && normalized.type !== "aborted") this.breaker?.recordFailure();
739
- this.logger.debug(`[VernLLM:${requestId}] stream-open error:\n${describeError(error)}`);
740
- throw normalized;
741
- }
742
- }, params.signal, (logMessage, error) => this.logRefundError(logMessage, error));
743
- return withReservedUsage(params, false, async () => {
744
- try {
745
- return await this.retryWithBackoff((attempt) => this.executeCall(params, requestId, attempt), requestId, params.signal);
746
- } catch (error) {
747
- const normalized = normalizeError(error, params.signal);
748
- if (normalized.type !== "validation" && normalized.type !== "parse" && normalized.type !== "aborted") this.breaker?.recordFailure();
749
- this.logger.debug(`[VernLLM:${requestId}] error:\n${describeError(error)}`);
750
- throw normalized;
751
- }
752
- }, params.signal, (logMessage, error) => this.logRefundError(logMessage, error));
1384
+ /** Streaming counterpart to `run`. Mirrors the old streaming branch of `VernLLM.call`. */
1385
+ async runStream(params, requestId, onAttempt) {
1386
+ const model = params.model ?? this.model;
1387
+ try {
1388
+ return await this.retryWithBackoff((attempt) => this.executeStreamCall(params, requestId, attempt), requestId, model, params.signal, onAttempt);
1389
+ } catch (error) {
1390
+ const normalized = normalizeError(error, params.signal);
1391
+ if (this.countsTowardBreaker(normalized)) this.breaker?.recordFailure(model);
1392
+ this.logger.debug(`[VernLLM:${requestId}] stream-open error:\n${this.redactText(describeError(error))}`);
1393
+ throw normalized;
1394
+ }
753
1395
  }
754
1396
  /**
755
1397
  * Performs a single attempt: builds the request (translating `tools` to
@@ -759,12 +1401,46 @@ var VernLLM = class {
759
1401
  * retry loop treats it like any other transient failure.
760
1402
  */
761
1403
  async executeCall(params, requestId, attempt) {
762
- const { useJson, model, request } = this.buildRequestPayload(params);
763
- const response = await withTimeout((attemptSignal) => this.client.chat.completions.create(request, { signal: attemptSignal }), this.timeoutMs, params.signal);
764
- const usage = this.extractUsage(response, requestId, model);
765
- const rawContent = response.choices?.[0]?.message?.content;
766
- const wireToolCalls = response.choices?.[0]?.message?.tool_calls;
767
- return this.finalizeResponse(rawContent, wireToolCalls, params, useJson, usage, requestId, attempt);
1404
+ const { useJson, model, request } = this.requestBuilder.build(params);
1405
+ let release;
1406
+ if (this.limiter) {
1407
+ const acquired = await this.limiter.acquire(this.limiter.estimate(request), params.signal);
1408
+ release = acquired.release;
1409
+ if (acquired.waitedMs > 0) this.reportEvent({
1410
+ kind: "rate_limited",
1411
+ requestId,
1412
+ provider: this.providerName,
1413
+ model,
1414
+ waitedMs: acquired.waitedMs,
1415
+ reason: acquired.reason ?? "rpm"
1416
+ });
1417
+ }
1418
+ try {
1419
+ const response = await withTimeout((attemptSignal) => this.client.chat.completions.create(request, { signal: attemptSignal }), this.timeoutMs, params.signal);
1420
+ const usage = this.extractUsage(response, requestId, model);
1421
+ release?.(this.actualTokensFor(usage));
1422
+ release = void 0;
1423
+ const rawContent = response.choices?.[0]?.message?.content;
1424
+ const wireToolCalls = response.choices?.[0]?.message?.tool_calls;
1425
+ return this.finalizeResponse(rawContent, wireToolCalls, params, useJson, model, usage, requestId, attempt);
1426
+ } finally {
1427
+ release?.();
1428
+ }
1429
+ }
1430
+ /** Applies `redact` (if configured); otherwise returns `text` unchanged. */
1431
+ redactText(text) {
1432
+ return this.redact ? this.redact(text) : text;
1433
+ }
1434
+ /**
1435
+ * Applies `redact` (if configured) to whatever the debug log is about
1436
+ * to show: real content when there is any, otherwise the tool-call
1437
+ * placeholder, which carries no user data and passes through
1438
+ * `redact` unchanged in practice but is included for a caller whose
1439
+ * `redact` does something structural (e.g. adding a marker) rather
1440
+ * than just scrubbing PII.
1441
+ */
1442
+ redactedOutput(content, wireToolCalls) {
1443
+ return this.redactText(content ?? `[${wireToolCalls?.length ?? 0} tool call(s)]`);
768
1444
  }
769
1445
  /**
770
1446
  * Shapes a fully-arrived response (content and/or tool_calls, already
@@ -776,16 +1452,16 @@ var VernLLM = class {
776
1452
  * Normalizes and reports usage failure on error itself, so every caller
777
1453
  * gets identical error handling without duplicating it.
778
1454
  */
779
- finalizeResponse(rawContent, wireToolCalls, params, useJson, usage, requestId, attempt) {
1455
+ finalizeResponse(rawContent, wireToolCalls, params, useJson, model, usage, requestId, attempt) {
780
1456
  try {
781
1457
  const content = rawContent?.trim();
782
1458
  if (!content && !wireToolCalls?.length) throw new LLMError("Empty LLM response", "api");
783
- this.logger.debug(`[VernLLM:${requestId}] output:\n${(content ?? `[${wireToolCalls?.length ?? 0} tool call(s)]`).slice(0, 800)}`);
1459
+ this.logger.debug(`[VernLLM:${requestId}] output:\n${this.redactedOutput(content, wireToolCalls).slice(0, 800)}`);
784
1460
  if (wireToolCalls?.length) {
785
1461
  if (!params.tools) throw new LLMError("Provider returned tool_calls but no `tools` were sent with this call.", "api");
786
1462
  const toolCalls = parseWireToolCalls(wireToolCalls);
787
1463
  this.validateToolCallArguments(toolCalls, params.tools);
788
- this.breaker?.recordSuccess();
1464
+ this.breaker?.recordSuccess(model);
789
1465
  this.reportUsage(usage);
790
1466
  return {
791
1467
  type: "tool_calls",
@@ -795,7 +1471,7 @@ var VernLLM = class {
795
1471
  }
796
1472
  const textContent = content ?? "";
797
1473
  if (!useJson) {
798
- this.breaker?.recordSuccess();
1474
+ this.breaker?.recordSuccess(model);
799
1475
  this.reportUsage(usage);
800
1476
  return params.tools ? {
801
1477
  type: "content",
@@ -803,7 +1479,7 @@ var VernLLM = class {
803
1479
  } : textContent;
804
1480
  }
805
1481
  const result = this.parseAndValidate(textContent, params.schema);
806
- this.breaker?.recordSuccess();
1482
+ this.breaker?.recordSuccess(model);
807
1483
  this.reportUsage(usage);
808
1484
  return params.tools ? {
809
1485
  type: "content",
@@ -833,199 +1509,115 @@ var VernLLM = class {
833
1509
  * dies mid-stream isn't masked as a success (see `buildStreamResult`).
834
1510
  */
835
1511
  async executeStreamCall(params, requestId, attempt) {
836
- const { useJson, model, request } = this.buildRequestPayload(params);
837
- const createStream = this.client.chat.completions.createStream;
838
- if (!createStream) throw new LLMError("stream: true requires a client/adapter with createStream", "validation");
1512
+ const { useJson, model, request } = this.requestBuilder.build(params);
1513
+ const completions = this.client.chat.completions;
1514
+ if (!completions.createStream) throw new LLMError("stream: true requires a client/adapter with createStream", "validation");
1515
+ const createStream = completions.createStream.bind(completions);
1516
+ let release;
1517
+ if (this.limiter) {
1518
+ const acquired = await this.limiter.acquire(this.limiter.estimate(request), params.signal);
1519
+ release = acquired.release;
1520
+ if (acquired.waitedMs > 0) this.reportEvent({
1521
+ kind: "rate_limited",
1522
+ requestId,
1523
+ provider: this.providerName,
1524
+ model,
1525
+ waitedMs: acquired.waitedMs,
1526
+ reason: acquired.reason ?? "rpm"
1527
+ });
1528
+ }
839
1529
  const streamController = new AbortController();
840
1530
  const combinedExternal = params.signal ? AbortSignal.any([params.signal, streamController.signal]) : streamController.signal;
841
- const { iterator, first } = await withTimeout(async (attemptSignal) => {
842
- const streamIterator = createStream(request, { signal: attemptSignal })[Symbol.asyncIterator]();
843
- const firstResult = await streamIterator.next();
844
- return {
845
- iterator: streamIterator,
846
- first: firstResult
847
- };
848
- }, this.timeoutMs, combinedExternal);
849
- if (first.done) throw new LLMError("Empty LLM response", "api");
850
- return this.buildStreamResult(iterator, first, params, useJson, requestId, model, attempt, streamController);
851
- }
852
- /**
853
- * The streaming accumulator: wraps the raw `WireStreamChunk` iterator in
854
- * an async generator that yields translated `StreamChunk`s to the caller
855
- * live, as they arrive, with no per-chunk timeout and no bound on total
856
- * duration, and accumulates text/tool-call deltas internally so that
857
- * `finalizeResponse` can produce `finalResult` once the stream completes.
858
- *
859
- * Two separate try/catches: the iteration loop's catch handles errors
860
- * the transport itself throws, which aren't normalized yet, so that
861
- * happens here along with the one `reportUsageFailure` call for them.
862
- * The second catch, around `finalizeResponse`, does not re-normalize or
863
- * re-report since `finalizeResponse` already does both internally.
864
- * Circuit-breaker success is only recorded once the stream fully
865
- * completes, not when the first chunk arrives, so a connection that
866
- * opens and then dies mid-way still counts as a failure below instead
867
- * of masking it.
868
- */
869
- buildStreamResult(iterator, first, params, useJson, requestId, model, attempt, streamController) {
870
- let resolveFinal;
871
- let rejectFinal;
872
- const finalResult = new Promise((resolve, reject) => {
873
- resolveFinal = resolve;
874
- rejectFinal = reject;
875
- });
876
- finalResult.catch(() => {});
877
- const MAX_BUFFERED_CHUNKS = 1e4;
878
- const buffered = [];
879
- const pending = [];
880
- let streamDone = false;
881
- let streamError;
882
- let hasLoggedEviction = false;
883
- const push = (chunk) => {
884
- const waiter = pending.shift();
885
- if (waiter) {
886
- waiter.resolve({
887
- done: false,
888
- value: chunk
889
- });
890
- return;
891
- }
892
- buffered.push(chunk);
893
- if (buffered.length > MAX_BUFFERED_CHUNKS * 2) {
894
- if (!hasLoggedEviction) {
895
- hasLoggedEviction = true;
896
- this.logger.debug(`[VernLLM] stream chunk buffer exceeded cap (${MAX_BUFFERED_CHUNKS}), evicting ${buffered.length - MAX_BUFFERED_CHUNKS} oldest chunk(s); buffered=${buffered.length}. The chunks iterable was never read (or fell far behind) for this stream.`);
897
- }
898
- buffered.splice(0, buffered.length - MAX_BUFFERED_CHUNKS);
899
- }
900
- };
901
- const finish = () => {
902
- streamDone = true;
903
- for (const waiter of pending.splice(0)) waiter.resolve({
904
- done: true,
905
- value: void 0
1531
+ try {
1532
+ const { iterator, first } = await withTimeout(async (attemptSignal) => {
1533
+ const streamIterator = createStream(request, { signal: attemptSignal })[Symbol.asyncIterator]();
1534
+ const firstResult = await streamIterator.next();
1535
+ return {
1536
+ iterator: streamIterator,
1537
+ first: firstResult
1538
+ };
1539
+ }, this.timeoutMs, combinedExternal);
1540
+ if (first.done) throw new LLMError("Empty LLM response", "api");
1541
+ const releaseAtOpen = release;
1542
+ const result = buildStreamResult(iterator, first, {
1543
+ requestId,
1544
+ model,
1545
+ providerName: this.providerName,
1546
+ isFallback: this.isFallback,
1547
+ chunkIdleTimeoutMs: params.chunkIdleTimeoutMs ?? this.chunkIdleTimeoutMs,
1548
+ streamController,
1549
+ logger: this.logger,
1550
+ signal: params.signal,
1551
+ onStreamSuccess: (usage) => {
1552
+ this.breaker?.recordSuccess(model);
1553
+ releaseAtOpen?.(this.actualTokensFor(usage));
1554
+ },
1555
+ onStreamFailure: (normalized, usage) => {
1556
+ if (normalized.type === "timeout") this.breaker?.recordFailure(model);
1557
+ if (usage && normalized.type !== "aborted") this.reportUsageFailure(usage, normalized, attempt, true);
1558
+ releaseAtOpen?.(this.actualTokensFor(usage));
1559
+ },
1560
+ finalize: (textAcc, wireToolCalls, usage) => this.finalizeResponse(textAcc, wireToolCalls, params, useJson, model, usage, requestId, attempt)
906
1561
  });
907
- };
908
- const fail = (error) => {
909
- streamDone = true;
910
- streamError = error;
911
- for (const waiter of pending.splice(0)) waiter.reject(error);
912
- };
913
- const chunks = { [Symbol.asyncIterator]() {
914
- return { next() {
915
- if (buffered.length) return Promise.resolve({
916
- done: false,
917
- value: buffered.shift()
918
- });
919
- if (streamDone) return streamError ? Promise.reject(streamError) : Promise.resolve({
920
- done: true,
921
- value: void 0
922
- });
923
- return new Promise((resolve, reject) => {
924
- pending.push({
925
- resolve,
926
- reject
927
- });
928
- });
929
- } };
930
- } };
931
- const toolCallAcc = new Map();
932
- let textAcc = "";
933
- let usage;
934
- (async () => {
935
- try {
936
- let result = first;
937
- while (!result.done) {
938
- const wireChunk = result.value;
939
- if (wireChunk.type === "ping") {} else if (wireChunk.type === "text-delta") {
940
- textAcc += wireChunk.delta;
941
- push({
942
- type: "text-delta",
943
- delta: wireChunk.delta
944
- });
945
- } else if (wireChunk.type === "tool_call_delta") {
946
- const entry = toolCallAcc.get(wireChunk.index) ?? { args: "" };
947
- entry.id ??= wireChunk.id;
948
- entry.name ??= wireChunk.name;
949
- entry.args += wireChunk.argumentsDelta ?? "";
950
- toolCallAcc.set(wireChunk.index, entry);
951
- push({
952
- type: "tool_call_delta",
953
- index: wireChunk.index,
954
- id: wireChunk.id,
955
- name: wireChunk.name,
956
- argsDelta: wireChunk.argumentsDelta,
957
- complete: wireChunk.complete
958
- });
959
- } else if (wireChunk.type === "usage") {
960
- usage = {
961
- promptTokens: wireChunk.usage.prompt_tokens ?? 0,
962
- completionTokens: wireChunk.usage.completion_tokens ?? 0,
963
- totalTokens: wireChunk.usage.total_tokens ?? 0,
964
- requestId,
965
- model
966
- };
967
- push({
968
- type: "usage",
969
- usage
970
- });
971
- }
972
- result = await withChunkIdleTimeout(() => iterator.next(), params.chunkIdleTimeoutMs ?? this.chunkIdleTimeoutMs, () => streamController.abort(), this.logger);
973
- }
974
- } catch (error) {
975
- try {
976
- await iterator.return?.();
977
- } catch {}
978
- streamController.abort();
979
- const normalized = normalizeError(error, params.signal);
980
- if (normalized.type === "timeout") this.breaker?.recordFailure();
981
- if (usage && normalized.type !== "aborted") this.reportUsageFailure(usage, normalized, attempt, true);
982
- fail(normalized);
983
- rejectFinal(normalized);
984
- return;
985
- }
986
- finish();
987
- this.breaker?.recordSuccess();
988
- try {
989
- const wireToolCalls = toolCallAcc.size ? [...toolCallAcc.entries()].sort(([indexA], [indexB]) => indexA - indexB).map(([, entry]) => ({
990
- id: entry.id ?? "",
991
- type: "function",
992
- function: {
993
- name: entry.name ?? "",
994
- arguments: entry.args
995
- }
996
- })) : void 0;
997
- const finalized = this.finalizeResponse(textAcc, wireToolCalls, params, useJson, usage, requestId, attempt);
998
- resolveFinal(finalized);
999
- } catch (error) {
1000
- rejectFinal(error);
1001
- }
1002
- })();
1003
- return {
1004
- chunks,
1005
- finalResult
1006
- };
1562
+ release = void 0;
1563
+ return result;
1564
+ } finally {
1565
+ release?.();
1566
+ }
1007
1567
  }
1008
1568
  /**
1009
1569
  * Checks every `ToolCall` against the `tools` that were offered, catching
1010
- * a hallucinated tool name early instead of letting it reach the
1011
- * application's dispatch table. Then runs each tool's `argumentsSchema`,
1012
- * if present, throwing `LLMError('validation')` on failure.
1570
+ * a hallucinated tool name and a duplicate call id before either reaches
1571
+ * the application's dispatch table, then runs each tool's
1572
+ * `argumentsSchema`, if present.
1573
+ *
1574
+ * Contract failures (unknown name, duplicate id) are collected across
1575
+ * every call and thrown together, since retrying a request that already
1576
+ * has these errors cannot help (`shouldRetry` excludes them by `code`)
1577
+ * and a caller fixing them wants to see every one, not just the first.
1578
+ * Schema failures keep the original single-error, `type: 'validation'`
1579
+ * shape rather than being folded into the aggregate, since they're a
1580
+ * distinct failure kind from the contract failures above (also excluded
1581
+ * from retry, by `type` rather than `code`; see `shouldRetry`).
1013
1582
  */
1014
1583
  validateToolCallArguments(toolCalls, tools) {
1015
- const knownNames = new Set(tools.map((t) => t.name));
1584
+ const known = new Map(tools.map((t) => [t.name, t]));
1585
+ const seenIds = new Set();
1586
+ const toolIssues = [];
1587
+ for (const call of toolCalls) {
1588
+ if (seenIds.has(call.id)) toolIssues.push({
1589
+ name: call.name,
1590
+ toolCallId: call.id,
1591
+ code: "duplicate_tool_call_id"
1592
+ });
1593
+ seenIds.add(call.id);
1594
+ if (!known.has(call.name)) toolIssues.push({
1595
+ name: call.name,
1596
+ toolCallId: call.id,
1597
+ code: "unknown_tool"
1598
+ });
1599
+ }
1600
+ if (toolIssues.length > 0) {
1601
+ const unknownTool = toolIssues.find((i) => i.code === "unknown_tool");
1602
+ const primary = unknownTool ? `Model requested tool "${unknownTool.name}", which was not in the tools offered ([${[...known.keys()].join(", ")}]).` : `Duplicate tool call id "${toolIssues[0].toolCallId}" in the model's response.`;
1603
+ const message = toolIssues.length > 1 ? `${primary} (${toolIssues.length} tool call issues total, see toolIssues.)` : primary;
1604
+ const error = new LLMError(message, "api", void 0, void 0, void 0, void 0, unknownTool ? "unknown_tool" : "duplicate_tool_call_id");
1605
+ error.toolIssues = toolIssues;
1606
+ throw error;
1607
+ }
1016
1608
  for (const call of toolCalls) {
1017
- if (!knownNames.has(call.name)) throw new LLMError(`Model requested tool "${call.name}", which was not in the tools offered ([${[...knownNames].join(", ")}]).`, "api");
1018
- const definition = tools.find((t) => t.name === call.name);
1609
+ const definition = known.get(call.name);
1019
1610
  if (!definition?.argumentsSchema) continue;
1020
1611
  const result = definition.argumentsSchema.safeParse(call.arguments);
1021
1612
  if (!result.success) throw new LLMError(`Arguments for tool call "${call.name}" failed validation`, "validation", void 0, result.error);
1022
1613
  }
1023
1614
  }
1024
1615
  /** Runs `fn`, retrying with backoff according to `shouldRetry`. */
1025
- async retryWithBackoff(fn, requestId, signal) {
1616
+ async retryWithBackoff(fn, requestId, model, signal, onAttempt) {
1026
1617
  let lastError;
1027
1618
  for (let attempt = 0; attempt <= this.maxRetries; attempt++) try {
1028
- if (attempt > 0) await this.recoverDelay(requestId, attempt, lastError, signal);
1619
+ if (attempt > 0) await this.recoverDelay(requestId, model, attempt, lastError, signal);
1620
+ onAttempt?.();
1029
1621
  return await fn(attempt);
1030
1622
  } catch (error) {
1031
1623
  lastError = error;
@@ -1034,137 +1626,6 @@ var VernLLM = class {
1034
1626
  throw lastError;
1035
1627
  }
1036
1628
  /**
1037
- * Validates `history` alternates user/assistant turns, since providers
1038
- * like Anthropic/Gemini reject or mishandle consecutive same-role turns.
1039
- */
1040
- validateHistory(history) {
1041
- let previousTurn;
1042
- for (const [index, turn] of history.entries()) {
1043
- if (turn.role === "tool") {
1044
- if (previousTurn?.role !== "assistant" || !previousTurn.toolCalls?.length) throw new LLMError(`history[${index}] is a "tool" turn, but must immediately follow an "assistant" turn that requested tools`, "validation");
1045
- if (!turn.toolResults?.length) throw new LLMError(`history[${index}] is a "tool" turn but has no toolResults`, "validation");
1046
- const requestedIds = new Set(previousTurn.toolCalls.map((tc) => tc.id));
1047
- const resultIds = turn.toolResults.map((tr) => tr.toolCallId);
1048
- const unknownIds = resultIds.filter((id) => !requestedIds.has(id));
1049
- if (unknownIds.length) throw new LLMError(`history[${index}].toolResults references unknown toolCallId(s) [${unknownIds.join(", ")}]`, "validation");
1050
- const seenIds = new Set();
1051
- const duplicateIds = new Set();
1052
- for (const id of resultIds) {
1053
- if (seenIds.has(id)) duplicateIds.add(id);
1054
- seenIds.add(id);
1055
- }
1056
- if (duplicateIds.size) throw new LLMError(`history[${index}].toolResults has duplicate toolCallId(s) [${[...duplicateIds].join(", ")}]`, "validation");
1057
- const missingIds = [...requestedIds].filter((id) => !resultIds.includes(id));
1058
- if (missingIds.length) throw new LLMError(`history[${index}] is missing toolResults for toolCallId(s) [${missingIds.join(", ")}]`, "validation");
1059
- } else {
1060
- if (turn.role === previousTurn?.role) throw new LLMError(`history must alternate user/assistant turns: consecutive "${turn.role}" turns at history[${index - 1}] and history[${index}]`, "validation");
1061
- if (previousTurn?.role === "assistant" && previousTurn.toolCalls?.length) throw new LLMError(`history[${index}] follows an assistant tool request without tool results`, "validation");
1062
- }
1063
- previousTurn = turn;
1064
- }
1065
- if (previousTurn?.role === "assistant" && previousTurn.toolCalls?.length) throw new LLMError("The last entry in history is an assistant tool request without tool results", "validation");
1066
- if (previousTurn?.role === "user") throw new LLMError("The last entry in history is a \"user\" turn, which would collide with the current userContent turn.", "validation");
1067
- }
1068
- /** Applies per-call defaults and shapes params into the client's request object. */
1069
- buildRequestPayload(params) {
1070
- const { systemPrompt, userContent, history = [], maxTokens = this.defaultMaxTokens, model = this.model, reasoningEffort, jsonSchema, tools, toolChoice } = params;
1071
- const temperature = params.temperature === void 0 ? this.defaultTemperature : params.temperature;
1072
- if (tools && (jsonSchema || params.schema)) throw new LLMError("`tools` cannot be combined with `jsonSchema`/`schema`: on Anthropic and Bedrock, jsonSchema is implemented internally as a forced single-tool call, which would collide with real tools. Use one or the other.", "validation");
1073
- if (tools && tools.length === 0) throw new LLMError("`tools` was an empty array. This is almost always a bug (e.g. a filtered tool list that ended up empty). An empty `tools` array still switches on tool-call mode (response shape, jsonMode default, wire format) with nothing for the model to call. Omit `tools` entirely for a normal call, or make sure the array is non-empty.", "validation");
1074
- if (tools) {
1075
- const seen = new Set();
1076
- const duplicates = new Set();
1077
- for (const tool of tools) {
1078
- if (seen.has(tool.name)) duplicates.add(tool.name);
1079
- seen.add(tool.name);
1080
- }
1081
- if (duplicates.size) throw new LLMError(`\`tools\` has duplicate name(s): [${[...duplicates].join(", ")}]. Tool names must be unique.`, "validation");
1082
- }
1083
- if (toolChoice && !tools) throw new LLMError("`toolChoice` was set without `tools`. There is nothing for it to choose between. Set `tools`, or remove `toolChoice`.", "validation");
1084
- if (tools && typeof toolChoice === "object" && !tools.some((t) => t.name === toolChoice.name)) throw new LLMError(`toolChoice names "${toolChoice.name}", which is not in \`tools\` ([${tools.map((t) => t.name).join(", ")}]).`, "validation");
1085
- const jsonMode = params.jsonMode ?? (tools ? false : true);
1086
- const useJson = jsonMode || Boolean(jsonSchema);
1087
- if (params.schema && !useJson) throw new LLMError("schema was provided but jsonMode: false disables JSON parsing, so nothing would validate it. Remove jsonMode: false, set jsonSchema, or remove schema.", "validation");
1088
- const responseFormat = this.buildResponseFormat(jsonSchema, useJson);
1089
- this.validateHistory(history);
1090
- const request = {
1091
- model,
1092
- ...temperature !== null ? { temperature } : {},
1093
- max_tokens: maxTokens,
1094
- ...responseFormat ? { response_format: responseFormat } : {},
1095
- ...reasoningEffort ? { reasoning_effort: reasoningEffort } : {},
1096
- ...tools ? { tools: toWireTools(tools) } : {},
1097
- ...tools ? { tool_choice: this.buildWireToolChoice(toolChoice) } : {},
1098
- messages: [
1099
- ...systemPrompt ? [{
1100
- role: "system",
1101
- content: systemPrompt
1102
- }] : [],
1103
- ...history.flatMap((turn) => this.turnToWireMessages(turn)),
1104
- {
1105
- role: "user",
1106
- content: userContent
1107
- }
1108
- ]
1109
- };
1110
- return {
1111
- useJson,
1112
- model,
1113
- request
1114
- };
1115
- }
1116
- /** Maps VernLLM's app-facing `ToolChoice` onto the OpenAI-shaped wire `tool_choice`. */
1117
- buildWireToolChoice(toolChoice) {
1118
- if (!toolChoice || toolChoice === "auto") return "auto";
1119
- if (toolChoice === "none" || toolChoice === "required") return toolChoice;
1120
- return {
1121
- type: "function",
1122
- function: { name: toolChoice.name }
1123
- };
1124
- }
1125
- /**
1126
- * Expands one `ConversationTurn` into one or more wire messages. Plain
1127
- * user/assistant turns map 1:1. An assistant turn with `toolCalls` maps
1128
- * to an assistant message carrying wire-shaped `tool_calls`. A `'tool'`
1129
- * turn expands into one wire `tool` message per `toolResult`, since
1130
- * OpenAI-shaped wire format wants one message per tool_call_id.
1131
- */
1132
- turnToWireMessages(turn) {
1133
- if (turn.role === "tool") return (turn.toolResults ?? []).map((tr) => ({
1134
- role: "tool",
1135
- tool_call_id: tr.toolCallId,
1136
- content: typeof tr.content === "string" ? tr.content : JSON.stringify(tr.content ?? null),
1137
- ...tr.isError ? { is_error: true } : {}
1138
- }));
1139
- if (turn.role === "assistant" && turn.toolCalls?.length) return [{
1140
- role: "assistant",
1141
- ...turn.content ? { content: turn.content } : {},
1142
- tool_calls: toWireToolCalls(turn.toolCalls)
1143
- }];
1144
- return [{
1145
- role: turn.role,
1146
- content: turn.content ?? ""
1147
- }];
1148
- }
1149
- /**
1150
- * Chooses the response format: a provider-native `jsonSchema` takes
1151
- * priority when supplied (constrains generation directly), otherwise
1152
- * falls back to the looser `json_object` mode when JSON output is
1153
- * requested, or no format at all for plain text responses.
1154
- */
1155
- buildResponseFormat(jsonSchema, useJson) {
1156
- if (jsonSchema) return {
1157
- type: "json_schema",
1158
- json_schema: {
1159
- name: jsonSchema.name,
1160
- schema: jsonSchema.schema,
1161
- strict: jsonSchema.strict ?? true,
1162
- description: jsonSchema.description
1163
- }
1164
- };
1165
- return useJson ? { type: "json_object" } : void 0;
1166
- }
1167
- /**
1168
1629
  * Pulls `TokenUsage` out of a raw response, if the provider reported it.
1169
1630
  * Extraction doesn't depend on what happens to the response afterward, so
1170
1631
  * a malformed body can still yield usage if the provider's usage block
@@ -1177,9 +1638,21 @@ var VernLLM = class {
1177
1638
  completionTokens: response.usage.completion_tokens ?? 0,
1178
1639
  totalTokens: response.usage.total_tokens ?? 0,
1179
1640
  requestId,
1180
- model
1641
+ model,
1642
+ provider: this.providerName,
1643
+ usedFallback: this.isFallback
1181
1644
  };
1182
1645
  }
1646
+ /**
1647
+ * The token count to reconcile the rate limiter against for a finished
1648
+ * attempt: `totalTokens` when reported, otherwise the sum of prompt and
1649
+ * completion tokens, matching `reportUsageFailure`'s own fallback below
1650
+ * for a hand-rolled client that reports the parts but omits the total.
1651
+ */
1652
+ actualTokensFor(usage) {
1653
+ if (!usage) return void 0;
1654
+ return usage.totalTokens || usage.promptTokens + usage.completionTokens;
1655
+ }
1183
1656
  /** Reports token usage for a successful call, swallowing and logging any error `onUsage` throws. */
1184
1657
  reportUsage(usage) {
1185
1658
  if (!usage || !this.onUsage) return;
@@ -1225,180 +1698,536 @@ var VernLLM = class {
1225
1698
  * Waits out the backoff delay for a retry attempt, honoring a
1226
1699
  * Retry-After header on the failed attempt's error when present.
1227
1700
  * Both Retry-After and plain exponential backoff are capped at the same
1228
- * max delay (see `DEFAULT_MAX_DELAY_MS` in `vernLLM.utils.ts`).
1701
+ * max delay (see `DEFAULT_MAX_DELAY_MS` in `retry.utils.ts`).
1229
1702
  */
1230
- async recoverDelay(requestId, attempt, error, signal) {
1703
+ async recoverDelay(requestId, model, attempt, error, signal) {
1231
1704
  const retryAfterMs = extractRetryAfterMs(error);
1232
1705
  const delay = retryAfterMs ?? getBackoffDelay(this.baseDelayMs, attempt);
1233
- this.logger.warn(`[VernLLM:${requestId}] recovery attempt ${attempt}/${this.maxRetries}, waiting ${delay}ms` + (retryAfterMs !== void 0 ? " (honoring Retry-After)" : ""));
1706
+ const retryAfterHonored = retryAfterMs !== void 0;
1707
+ this.logger.warn(`[VernLLM:${requestId}] recovery attempt ${attempt}/${this.maxRetries}, waiting ${delay}ms` + (retryAfterHonored ? " (honoring Retry-After)" : ""));
1708
+ this.reportEvent({
1709
+ kind: "retry",
1710
+ requestId,
1711
+ provider: this.providerName,
1712
+ model,
1713
+ attempt,
1714
+ maxRetries: this.maxRetries,
1715
+ delayMs: delay,
1716
+ retryAfterHonored,
1717
+ error: normalizeError(error, signal)
1718
+ });
1234
1719
  await waitForRetry(delay, signal);
1235
1720
  }
1721
+ isNonRetryableToolContractError(error) {
1722
+ return error instanceof LLMError && (error.code === "unknown_tool" || error.code === "duplicate_tool_call_id");
1723
+ }
1236
1724
  /** Decides whether a failed attempt is worth retrying. */
1237
1725
  shouldRetry(error, signal) {
1238
1726
  if (signal?.aborted) return false;
1239
1727
  if (error instanceof LLMError && (error.type === "parse" || error.type === "validation")) return false;
1728
+ if (error instanceof LLMError && error.code === "local_rate_limit") return false;
1729
+ if (this.isNonRetryableToolContractError(error)) return false;
1240
1730
  const status = extractStatus(error);
1241
1731
  return !(status !== void 0 && this.nonRetryableStatus.includes(status));
1242
1732
  }
1243
1733
  /**
1244
- * Removes a cached response by key when the configured cache adapter
1245
- * supports deletion. Cache invalidation is the caller's responsibility;
1246
- * only the application knows when cached data is stale.
1247
- *
1248
- * @param key The raw cache key (resolved through the adapter's
1249
- * `resolveKey`, if any, before deletion).
1734
+ * Decides whether a failed attempt should count toward the circuit
1735
+ * breaker's failure threshold. A model hallucinating a tool name or
1736
+ * reusing a call id isn't the provider being unhealthy, it's a model
1737
+ * response defect that will very likely recur regardless of provider
1738
+ * health, so it shouldn't push a healthy provider's circuit toward
1739
+ * opening. Mirrors the same reasoning `shouldRetry` already applies to
1740
+ * `parse`/`validation`/these same tool-contract codes.
1741
+ */
1742
+ countsTowardBreaker(error) {
1743
+ if (error.type === "validation" || error.type === "parse" || error.type === "aborted" || error.code === "local_rate_limit" || this.isNonRetryableToolContractError(error)) return false;
1744
+ return true;
1745
+ }
1746
+ };
1747
+
1748
+ //#endregion
1749
+ //#region src/logger.ts
1750
+ /**
1751
+ * Default logger. `debug` is gated by the `debug` option on VernLLM
1752
+ * warn/error always fire since they indicate real problems (retries, cache failures)
1753
+ */
1754
+ var ConsoleLogger = class {
1755
+ constructor(debugEnabled) {
1756
+ this.debugEnabled = debugEnabled;
1757
+ }
1758
+ debug(message) {
1759
+ if (this.debugEnabled) console.debug(message);
1760
+ }
1761
+ warn(message) {
1762
+ console.warn(message);
1763
+ }
1764
+ error(message, meta) {
1765
+ console.error(message, meta ?? "");
1766
+ }
1767
+ };
1768
+
1769
+ //#endregion
1770
+ //#region src/rateLimit.ts
1771
+ /** Default `estimateTokens`: chars/4 over every message's content, plus the requested `max_tokens`. */
1772
+ function defaultEstimateTokens(request) {
1773
+ const messagesChars = request.messages.reduce((sum, message) => {
1774
+ const content = message.content;
1775
+ if (typeof content === "string") return sum + content.length;
1776
+ if (content === void 0 || content === null) return sum;
1777
+ try {
1778
+ return sum + JSON.stringify(content).length;
1779
+ } catch {
1780
+ return sum;
1781
+ }
1782
+ }, 0);
1783
+ return Math.ceil(messagesChars / 4) + (request.max_tokens ?? 0);
1784
+ }
1785
+ /**
1786
+ * A capacity that refills continuously. Used for requests per minute and
1787
+ * tokens per minute, where `refillPerMs` is `capacity / 60000`, and for
1788
+ * concurrency, where `refillPerMs` is 0 and every release calls
1789
+ * `give(1)` instead of relying on the clock.
1790
+ */
1791
+ var TokenBucket = class {
1792
+ available;
1793
+ lastRefill = Date.now();
1794
+ constructor(capacity, refillPerMs) {
1795
+ this.capacity = capacity;
1796
+ this.refillPerMs = refillPerMs;
1797
+ this.available = capacity;
1798
+ }
1799
+ refill() {
1800
+ if (this.refillPerMs === 0) return;
1801
+ const now = Date.now();
1802
+ this.available = Math.min(this.capacity, this.available + (now - this.lastRefill) * this.refillPerMs);
1803
+ this.lastRefill = now;
1804
+ }
1805
+ /** Refills, then takes `amount` if available. Leaves the bucket untouched if it can't. */
1806
+ tryTake(amount) {
1807
+ this.refill();
1808
+ if (this.available < amount) return false;
1809
+ this.available -= amount;
1810
+ return true;
1811
+ }
1812
+ /**
1813
+ * Refills, then reports how many ms until this bucket could supply
1814
+ * `amount`, assuming nothing else takes from it meanwhile. Returns 0 if
1815
+ * it already can, `Infinity` if it never will on its own (a
1816
+ * concurrency bucket, `refillPerMs === 0`, only frees via `give`).
1817
+ */
1818
+ msUntilAvailable(amount) {
1819
+ this.refill();
1820
+ if (this.available >= amount) return 0;
1821
+ if (this.refillPerMs === 0) return Infinity;
1822
+ return (amount - this.available) / this.refillPerMs;
1823
+ }
1824
+ /**
1825
+ * Gives capacity back. Not floored at 0: a bad token-usage estimate can
1826
+ * push `available` negative, and it self-corrects on the next refill
1827
+ * rather than being clamped away immediately. Only ceilinged at
1828
+ * `capacity`, so a give can never overfill the bucket.
1250
1829
  */
1251
- async deleteCache(key) {
1252
- if (!this.cache.delete) return;
1253
- await this.cache.delete(await this.resolveCacheKey(key));
1830
+ give(amount) {
1831
+ this.available = Math.min(this.capacity, this.available + amount);
1832
+ }
1833
+ /** The bucket's ceiling, e.g. so a request that could never fit can fail fast instead of queueing forever. */
1834
+ getCapacity() {
1835
+ return this.capacity;
1254
1836
  }
1837
+ };
1838
+ /**
1839
+ * `setTimeout` silently clamps any delay above this (~24.8 days) instead
1840
+ * of erroring, so an uncapped delay derived from a very small
1841
+ * `requestsPerMinute`/`tokensPerMinute` could wrap around to firing
1842
+ * almost immediately instead of waiting. Mirrors the same guard in
1843
+ * `withTimeout`/`withChunkIdleTimeout`.
1844
+ */
1845
+ const MAX_WAKE_DELAY_MS = 2147483647;
1846
+ /**
1847
+ * Per-target rate limiter. Up to three buckets (requests/min, tokens/min,
1848
+ * concurrency) behind one FIFO queue, so a large call isn't starved by a
1849
+ * stream of small ones. Any bucket omitted from `options` has infinite
1850
+ * capacity and never blocks.
1851
+ */
1852
+ var RateLimiter = class {
1853
+ requests;
1854
+ tokens;
1855
+ concurrency;
1856
+ maxQueueMs;
1857
+ maxQueueSize;
1858
+ estimateTokensFn;
1859
+ queue = [];
1255
1860
  /**
1256
- * Internal cache primitive around caller-supplied logic. Concurrent misses
1257
- * for the same `cacheKey` share a single in-flight call, avoiding cache
1258
- * stampedes.
1259
- *
1260
- * Not part of the public API. Backs the public `cachedCall()`, which
1261
- * always composes this with `call()` so cached results get the same
1262
- * retry/timeout/circuit-breaker guarantees as any other LLM call.
1263
- *
1264
- * @param params `cacheKey`, `ttl`, `fn` (the work to run on a cache
1265
- * miss, typically `() => this.call(...)`), and optional
1266
- * `reserveUsage`/`refundUsage`/`signal`. See `InternalCacheParams`.
1267
- * @returns The cached value on a hit, or the result of `fn()` on a miss.
1861
+ * A single scheduled re-check for the head of the queue when it's
1862
+ * blocked on a bucket that refills on its own clock (rpm/tpm), so a
1863
+ * queue that nobody calls `acquire`/`release` on again isn't stuck
1864
+ * forever waiting for an external trigger to re-drain it. Not needed
1865
+ * for a concurrency block, which only clears via `release`.
1268
1866
  */
1269
- async runCached(params) {
1270
- const resolvedKey = await this.resolveCacheKey(params.cacheKey);
1271
- const resolvedParams = resolvedKey === params.cacheKey ? params : {
1272
- ...params,
1273
- cacheKey: resolvedKey
1274
- };
1275
- const cached = await this.cache.get(resolvedKey);
1276
- if (cached.hit) return cached.value;
1277
- const existing = this.inFlight.get(resolvedKey);
1278
- if (existing) return withReservedUsage(resolvedParams, true, () => existing, params.signal, (logMessage, error) => this.logRefundError(logMessage, error));
1279
- return this.registerTrigger(resolvedParams, false);
1867
+ wakeTimer;
1868
+ constructor(options) {
1869
+ if (options.requestsPerMinute) this.requests = new TokenBucket(options.requestsPerMinute, options.requestsPerMinute / 6e4);
1870
+ if (options.tokensPerMinute) this.tokens = new TokenBucket(options.tokensPerMinute, options.tokensPerMinute / 6e4);
1871
+ if (options.maxConcurrent) this.concurrency = new TokenBucket(options.maxConcurrent, 0);
1872
+ this.maxQueueMs = options.maxQueueMs ?? 3e4;
1873
+ this.maxQueueSize = options.maxQueueSize ?? 0;
1874
+ this.estimateTokensFn = options.estimateTokens ?? defaultEstimateTokens;
1280
1875
  }
1281
- /** Starts the shared fn() call for a cache miss and tracks it in the in-flight map until it settles. */
1282
- registerTrigger(params, coalesced) {
1283
- const resultPromise = withReservedUsage(params, coalesced, () => this.runAndCache(params), params.signal, (logMessage, error) => this.logRefundError(logMessage, error));
1284
- this.inFlight.set(params.cacheKey, resultPromise);
1285
- resultPromise.catch(() => {}).finally(() => {
1286
- this.inFlight.delete(params.cacheKey);
1287
- });
1288
- return resultPromise;
1876
+ /** Pre-flight token estimate for a request, per the configured (or default) heuristic. */
1877
+ estimate(request) {
1878
+ return this.estimateTokensFn(request);
1289
1879
  }
1290
- /** Runs `fn` and writes its result to the cache. */
1291
- async runAndCache(params) {
1292
- const result = await params.fn();
1293
- try {
1294
- await this.cache.set(params.cacheKey, result, params.ttl);
1295
- } catch (error) {
1296
- this.logger.error("[VernLLM] cache write failed", { message: error instanceof Error ? error.message : "unknown" });
1880
+ /**
1881
+ * Waits for capacity in every configured bucket, then takes from each.
1882
+ * The returned `release` gives the concurrency slot back and reconciles
1883
+ * the token bucket against real usage; it must run in a `finally` block.
1884
+ */
1885
+ async acquire(estimatedTokens, signal) {
1886
+ if (signal?.aborted) throw new LLMError("LLM request aborted", "aborted");
1887
+ if (!Number.isFinite(estimatedTokens) || estimatedTokens < 0) throw new LLMError(`estimatedTokens must be a finite, non-negative number, got ${String(estimatedTokens)}`, "validation");
1888
+ if (this.tokens && estimatedTokens > this.tokens.getCapacity()) throw new LLMError(`estimatedTokens (${estimatedTokens}) exceeds the configured tokensPerMinute capacity (${this.tokens.getCapacity()}); this call could never acquire capacity.`, "quota_exceeded", void 0, void 0, void 0, void 0, "local_rate_limit");
1889
+ if (this.queue.length === 0) {
1890
+ const attempt = this.tryAcquireBuckets(estimatedTokens);
1891
+ if (attempt.ok) return {
1892
+ release: this.makeRelease(estimatedTokens),
1893
+ waitedMs: 0
1894
+ };
1895
+ if (this.maxQueueSize > 0 && this.queue.length >= this.maxQueueSize) throw this.queueFullError();
1896
+ return this.enqueue(estimatedTokens, attempt.reason, signal);
1297
1897
  }
1298
- return result;
1898
+ if (this.maxQueueSize > 0 && this.queue.length >= this.maxQueueSize) throw this.queueFullError();
1899
+ return this.enqueue(estimatedTokens, void 0, signal);
1900
+ }
1901
+ queueFullError() {
1902
+ return new LLMError("Rate limit queue is full", "quota_exceeded", void 0, void 0, void 0, void 0, "local_rate_limit");
1903
+ }
1904
+ enqueue(estimatedTokens, initialReason, signal) {
1905
+ return new Promise((resolvePromise, rejectPromise) => {
1906
+ const waiter = {
1907
+ estimatedTokens,
1908
+ enqueuedAt: Date.now(),
1909
+ lastReason: initialReason,
1910
+ resolve: (result) => {
1911
+ cleanup();
1912
+ resolvePromise(result);
1913
+ },
1914
+ reject: (error) => {
1915
+ cleanup();
1916
+ if (this.wakeTimer) {
1917
+ clearTimeout(this.wakeTimer);
1918
+ this.wakeTimer = void 0;
1919
+ }
1920
+ this.drain();
1921
+ rejectPromise(error);
1922
+ }
1923
+ };
1924
+ let queueTimer;
1925
+ const onAbort = () => {
1926
+ waiter.reject(new LLMError("LLM request aborted", "aborted"));
1927
+ };
1928
+ const cleanup = () => {
1929
+ if (queueTimer) clearTimeout(queueTimer);
1930
+ signal?.removeEventListener("abort", onAbort);
1931
+ const index = this.queue.indexOf(waiter);
1932
+ if (index !== -1) this.queue.splice(index, 1);
1933
+ };
1934
+ if (this.maxQueueMs > 0) queueTimer = setTimeout(() => {
1935
+ waiter.reject(new LLMError("Rate limit queue timed out before capacity was available", "quota_exceeded", void 0, void 0, void 0, void 0, "local_rate_limit"));
1936
+ }, this.maxQueueMs);
1937
+ signal?.addEventListener("abort", onAbort, { once: true });
1938
+ this.queue.push(waiter);
1939
+ this.drain();
1940
+ });
1299
1941
  }
1300
1942
  /**
1301
- * Streaming counterpart to `runCached`. Three cases:
1302
- *
1303
- * - Hit: no live generation to relay. Returns immediately with
1304
- * `finalResult` resolved to the cached value and a one-shot `chunks`
1305
- * replay built from it, so `for await (const c of chunks)` call sites
1306
- * work identically on a hit or a miss. No usage hooks fire, since
1307
- * nothing was actually spent.
1308
- * - Miss, nothing else in flight for this key: delegates to
1309
- * `registerStreamTrigger`, which opens the stream and relays its
1310
- * `chunks` live.
1311
- * - Miss, but another call for the same key is already in flight: this
1312
- * call has no live chunks of its own to relay, so it's treated like a
1313
- * delayed hit. `finalResult` shares the trigger's in-flight promise
1314
- * (the same `this.inFlight` map non-streaming `runCached` uses, so
1315
- * streaming and non-streaming `cachedCall`s for the same key coalesce
1316
- * against each other too), and `chunks` is a one-shot replay built
1317
- * once that promise resolves.
1943
+ * Checks and takes from every configured bucket as one atomic unit: if
1944
+ * any bucket lacks capacity, whatever was already taken from the
1945
+ * earlier ones in this attempt is rolled back before reporting which
1946
+ * bucket blocked.
1318
1947
  */
1319
- async runCachedStream(params, hasTools) {
1320
- const resolvedKey = await this.resolveCacheKey(params.cacheKey);
1321
- const resolvedParams = resolvedKey === params.cacheKey ? params : {
1322
- ...params,
1323
- cacheKey: resolvedKey
1948
+ tryAcquireBuckets(estimatedTokens) {
1949
+ const taken = [];
1950
+ const take = (bucket, amount) => {
1951
+ if (!bucket) return true;
1952
+ if (!bucket.tryTake(amount)) return false;
1953
+ taken.push({
1954
+ bucket,
1955
+ amount
1956
+ });
1957
+ return true;
1324
1958
  };
1325
- const cached = await this.cache.get(resolvedKey);
1326
- if (cached.hit) {
1327
- const value = cached.value;
1959
+ if (!take(this.concurrency, 1)) return {
1960
+ ok: false,
1961
+ reason: "concurrency"
1962
+ };
1963
+ if (!take(this.requests, 1)) {
1964
+ for (const entry of taken) entry.bucket.give(entry.amount);
1328
1965
  return {
1329
- chunks: buildReplayChunks(value, hasTools),
1330
- finalResult: Promise.resolve(value)
1966
+ ok: false,
1967
+ reason: "rpm"
1331
1968
  };
1332
1969
  }
1333
- const existing = this.inFlight.get(resolvedKey);
1334
- if (existing) {
1335
- const finalResult = withReservedUsage(resolvedParams, true, () => existing, params.signal, (logMessage, error) => this.logRefundError(logMessage, error));
1970
+ if (!take(this.tokens, estimatedTokens)) {
1971
+ for (const entry of taken) entry.bucket.give(entry.amount);
1336
1972
  return {
1337
- chunks: buildReplayChunksFromPromise(finalResult, hasTools),
1338
- finalResult
1973
+ ok: false,
1974
+ reason: "tpm"
1339
1975
  };
1340
1976
  }
1341
- return this.registerStreamTrigger(resolvedParams);
1977
+ return { ok: true };
1978
+ }
1979
+ /** Drains the queue head first. Stops at the first waiter that still can't proceed, so no one is starved out of turn. */
1980
+ drain() {
1981
+ while (this.queue.length > 0) {
1982
+ const waiter = this.queue[0];
1983
+ const attempt = this.tryAcquireBuckets(waiter.estimatedTokens);
1984
+ if (!attempt.ok) {
1985
+ waiter.lastReason = attempt.reason;
1986
+ this.scheduleWake(attempt.reason, waiter.estimatedTokens);
1987
+ return;
1988
+ }
1989
+ const waitedMs = Date.now() - waiter.enqueuedAt;
1990
+ waiter.resolve({
1991
+ release: this.makeRelease(waiter.estimatedTokens),
1992
+ waitedMs,
1993
+ reason: waiter.lastReason
1994
+ });
1995
+ }
1342
1996
  }
1343
1997
  /**
1344
- * Opens the shared stream for a cache miss and tracks its settled value
1345
- * in `this.inFlight` until it resolves or rejects. Writes to the cache
1346
- * on success only, matching `runAndCache`.
1347
- *
1348
- * Registers the in-flight promise synchronously, before anything async
1349
- * runs, so a concurrent `cachedCall` for the same key always sees it in
1350
- * time to join instead of triggering its own stream. Settlement is
1351
- * wired onto the whole `withReservedUsageForStream` call rather than a
1352
- * line inside its callback, so any failure point (reserving usage,
1353
- * opening the stream, or the stream itself) reliably settles the
1354
- * in-flight entry instead of leaving it stuck.
1998
+ * Schedules a one-shot re-check of the queue for whenever the bucket
1999
+ * that's currently blocking the head waiter should next have enough
2000
+ * capacity. A no-op for a concurrency block (only `release` can clear
2001
+ * that) or while a wake is already pending.
1355
2002
  */
1356
- registerStreamTrigger(params) {
1357
- let resolveInFlight;
1358
- let rejectInFlight;
1359
- const inFlightResult = new Promise((resolve, reject) => {
1360
- resolveInFlight = resolve;
1361
- rejectInFlight = reject;
1362
- });
1363
- this.inFlight.set(params.cacheKey, inFlightResult);
1364
- inFlightResult.catch(() => {}).finally(() => {
1365
- this.inFlight.delete(params.cacheKey);
1366
- });
1367
- const streamPromise = withReservedUsageForStream(params, async () => {
1368
- const opened = await params.openStream();
1369
- const trackedResult = opened.finalResult.then(async (value) => {
1370
- try {
1371
- await this.cache.set(params.cacheKey, value, params.ttl);
1372
- } catch (error) {
1373
- this.logger.error("[VernLLM] cache write failed", { message: error instanceof Error ? error.message : "unknown" });
1374
- }
1375
- return value;
1376
- }, (error) => {
1377
- throw error;
2003
+ scheduleWake(reason, estimatedTokens) {
2004
+ if (this.wakeTimer) return;
2005
+ const ms = reason === "rpm" ? this.requests?.msUntilAvailable(1) : reason === "tpm" ? this.tokens?.msUntilAvailable(estimatedTokens) : void 0;
2006
+ if (ms === void 0 || !Number.isFinite(ms)) return;
2007
+ const delay = Math.min(Math.max(1, Math.ceil(ms)), MAX_WAKE_DELAY_MS);
2008
+ this.wakeTimer = setTimeout(() => {
2009
+ this.wakeTimer = void 0;
2010
+ this.drain();
2011
+ }, delay);
2012
+ }
2013
+ /**
2014
+ * Builds the one-shot release closure for an acquired slot. Only the
2015
+ * concurrency bucket is given back on release; the requests-per-minute
2016
+ * bucket is a real spend that only recovers via its own refill, and the
2017
+ * tokens bucket is reconciled against `actualTokens` rather than fully
2018
+ * refunded, since real tokens really were spent.
2019
+ */
2020
+ makeRelease(estimatedTokens) {
2021
+ let released = false;
2022
+ return (actualTokens) => {
2023
+ if (released) return;
2024
+ released = true;
2025
+ this.concurrency?.give(1);
2026
+ if (this.tokens && actualTokens !== void 0 && Number.isFinite(actualTokens)) this.tokens.give(estimatedTokens - actualTokens);
2027
+ this.drain();
2028
+ };
2029
+ }
2030
+ };
2031
+
2032
+ //#endregion
2033
+ //#region src/vernLLM.ts
2034
+ /**
2035
+ * A resilient layer around an LLM chat completions client. This is VernLLM!
2036
+ *
2037
+ * Adds retry with backoff and jitter, per-attempt timeouts, an optional
2038
+ * circuit breaker, JSON parsing with optional schema validation, usage
2039
+ * tracking, and an optional response cache. All configurable, all opt-in
2040
+ * beyond sensible defaults.
2041
+ */
2042
+ var VernLLM = class {
2043
+ logger;
2044
+ /**
2045
+ * One `CallExecutor` per provider target: index 0 is the primary,
2046
+ * everything after it is a `fallback` target, in the order declared.
2047
+ * Each owns its own request building, retry/timeout, circuit breaker,
2048
+ * and rate limiter. `call()` walks this array in `runFallbackChain`,
2049
+ * moving to the next entry only when `fallbackOn` says to.
2050
+ */
2051
+ executors;
2052
+ /** Decides whether a failed target is followed by the next one or the chain stops. See `VernLLMOptions['fallbackOn']`. */
2053
+ fallbackOn;
2054
+ /** Reports a `'fallback'` event when the chain moves to the next target. Shared `onEvent` plumbing, same as every executor's. */
2055
+ reportEvent;
2056
+ /**
2057
+ * Owns cache key resolution, cache reads/writes, and in-flight
2058
+ * coalescing for `cachedCall()`. Independent of `executor`: it only
2059
+ * ever calls back into `this.call()` as an opaque function.
2060
+ */
2061
+ cacheOrchestrator;
2062
+ /**
2063
+ * @param options Client, model, and tunables. Defaults: `maxRetries` 1,
2064
+ * `timeoutMs` 25000, `baseDelayMs` 500, `defaultMaxTokens` 1000,
2065
+ * `defaultTemperature` 0.2, `cache` an in-memory adapter,
2066
+ * `nonRetryableStatus` `[400, 401, 403, 404, 422]`, `debug` false.
2067
+ */
2068
+ constructor(options) {
2069
+ this.logger = options.logger ?? new ConsoleLogger(options.debug ?? false);
2070
+ const providerName = options.name ?? "primary";
2071
+ this.cacheOrchestrator = new CacheOrchestrator(options.cache ?? new InMemoryCacheAdapter(), this.logger);
2072
+ this.fallbackOn = options.fallbackOn ?? defaultFallbackOn;
2073
+ this.reportEvent = makeEventReporter(options.onEvent, this.logger);
2074
+ const primaryDefaultTemperature = options.defaultTemperature === void 0 ? .2 : options.defaultTemperature;
2075
+ const primaryTarget = {
2076
+ client: options.client,
2077
+ model: options.model,
2078
+ name: providerName,
2079
+ maxRetries: options.maxRetries,
2080
+ timeoutMs: options.timeoutMs,
2081
+ chunkIdleTimeoutMs: options.chunkIdleTimeoutMs,
2082
+ baseDelayMs: options.baseDelayMs,
2083
+ defaultMaxTokens: options.defaultMaxTokens,
2084
+ defaultTemperature: primaryDefaultTemperature,
2085
+ nonRetryableStatus: options.nonRetryableStatus,
2086
+ circuitBreaker: options.circuitBreaker,
2087
+ rateLimit: options.rateLimit
2088
+ };
2089
+ const declaredFallbacks = Array.isArray(options.fallback) ? options.fallback : options.fallback ? [options.fallback] : [];
2090
+ const targets = [primaryTarget, ...declaredFallbacks];
2091
+ this.executors = targets.map((target, i) => {
2092
+ const isFallback = i > 0;
2093
+ const name = target.name ?? (isFallback ? `fallback[${i - 1}]` : providerName);
2094
+ const breaker = buildCircuitBreaker(target.circuitBreaker, name, target.model, options.onEvent, this.logger);
2095
+ return new CallExecutor(name, target.client, target.model, {
2096
+ maxRetries: target.maxRetries ?? options.maxRetries ?? 1,
2097
+ timeoutMs: target.timeoutMs ?? options.timeoutMs ?? 25e3,
2098
+ chunkIdleTimeoutMs: target.chunkIdleTimeoutMs ?? options.chunkIdleTimeoutMs ?? 3e4,
2099
+ baseDelayMs: target.baseDelayMs ?? options.baseDelayMs ?? 500,
2100
+ defaultMaxTokens: target.defaultMaxTokens ?? options.defaultMaxTokens ?? 1e3,
2101
+ defaultTemperature: target.defaultTemperature === void 0 ? primaryDefaultTemperature : target.defaultTemperature,
2102
+ nonRetryableStatus: target.nonRetryableStatus ?? options.nonRetryableStatus ?? [
2103
+ 400,
2104
+ 401,
2105
+ 403,
2106
+ 404,
2107
+ 422
2108
+ ],
2109
+ parseJson: options.parseJson,
2110
+ logger: this.logger,
2111
+ redact: options.redact,
2112
+ onUsage: options.onUsage,
2113
+ onUsageFailure: options.onUsageFailure,
2114
+ onEvent: options.onEvent,
2115
+ breaker,
2116
+ limiter: target.rateLimit ? new RateLimiter(target.rateLimit) : void 0,
2117
+ isFallback
1378
2118
  });
1379
- return {
1380
- chunks: opened.chunks,
1381
- finalResult: trackedResult
1382
- };
1383
- }, params.signal, (logMessage, error) => this.logRefundError(logMessage, error));
1384
- streamPromise.then((opened) => {
1385
- opened.finalResult.then(resolveInFlight, rejectInFlight);
1386
- }, (error) => {
1387
- rejectInFlight(error);
1388
2119
  });
1389
- return streamPromise;
1390
2120
  }
1391
2121
  /** Logs a failed refundUsage attempt via the configured logger. */
1392
2122
  logRefundError(logMessage, error) {
1393
2123
  this.logger.error(logMessage, { message: error instanceof Error ? error.message : "unknown" });
1394
2124
  }
2125
+ /**
2126
+ * Walks `this.executors` in order, running `attempt` against each until
2127
+ * one succeeds or every target has failed. `run` on a lone target
2128
+ * (no `fallback` configured) throws exactly what it throws today: the
2129
+ * loop's single iteration path is unchanged from pre-fallback behavior.
2130
+ *
2131
+ * For streaming, `attempt` is `executor.runStream`, whose own retries
2132
+ * only cover *opening* the stream (see `CallExecutor.runStream`). A
2133
+ * mid-stream failure surfaces through `finalResult` after this function
2134
+ * has already returned, so it's never seen here and never falls over,
2135
+ * per the streaming limitation: splicing a second model's output into a
2136
+ * response the consumer has already partially rendered would corrupt
2137
+ * it.
2138
+ */
2139
+ async runFallbackChain(params, requestId, attempt, skipBreakerCheckForFirst = false) {
2140
+ const attempts = [];
2141
+ for (let i = 0; i < this.executors.length; i++) {
2142
+ const executor = this.executors[i];
2143
+ const startedAt = Date.now();
2144
+ let attemptCount = 0;
2145
+ try {
2146
+ if (!(i === 0 && skipBreakerCheckForFirst)) executor.assertBreakerClosed(params.model);
2147
+ const result = await attempt(executor, () => {
2148
+ attemptCount += 1;
2149
+ });
2150
+ return {
2151
+ result,
2152
+ executor,
2153
+ index: i,
2154
+ attemptCount
2155
+ };
2156
+ } catch (error) {
2157
+ const normalized = normalizeError(error, params.signal);
2158
+ attempts.push({
2159
+ index: i - 1,
2160
+ provider: executor.providerName,
2161
+ model: params.model ?? executor.model,
2162
+ error: normalized
2163
+ });
2164
+ const isLast = i === this.executors.length - 1;
2165
+ const policyDecision = this.fallbackOn(normalized, { isLastTarget: isLast });
2166
+ const decision = isLast ? "stop" : policyDecision;
2167
+ if (decision === "stop") throw attempts.length > 1 ? new FallbackExhaustedError(attempts) : normalized;
2168
+ const next = this.executors[i + 1];
2169
+ this.reportEvent({
2170
+ kind: "fallback",
2171
+ requestId,
2172
+ from: executor.providerName,
2173
+ to: next.providerName,
2174
+ fromIndex: i - 1,
2175
+ toIndex: i,
2176
+ error: normalized,
2177
+ elapsedMs: Date.now() - startedAt
2178
+ });
2179
+ }
2180
+ }
2181
+ throw new LLMError("No provider targets configured", "unknown");
2182
+ }
2183
+ async call(params) {
2184
+ if (params.signal?.aborted) throw new LLMError("LLM request aborted", "aborted");
2185
+ const requestId = params.requestId ?? randomUUID();
2186
+ const soleTarget = this.executors.length === 1;
2187
+ if (soleTarget) this.executors[0].assertBreakerClosed(params.model);
2188
+ if (params.stream) return withReservedUsageForStream(params, async () => {
2189
+ const { result } = await this.runFallbackChain(params, requestId, (executor, onAttempt) => executor.runStream(params, requestId, onAttempt), soleTarget);
2190
+ return result;
2191
+ }, params.signal, (logMessage, error) => this.logRefundError(logMessage, error));
2192
+ return withReservedUsage(params, false, async () => {
2193
+ const { result, executor, index, attemptCount } = await this.runFallbackChain(params, requestId, (target, onAttempt) => target.run(params, requestId, onAttempt), soleTarget);
2194
+ if (params.meta) params.meta.current = {
2195
+ provider: executor.providerName,
2196
+ model: params.model ?? executor.model,
2197
+ fallbackIndex: index - 1,
2198
+ usedFallback: index > 0,
2199
+ attempts: attemptCount
2200
+ };
2201
+ return result;
2202
+ }, params.signal, (logMessage, error) => this.logRefundError(logMessage, error));
2203
+ }
2204
+ /**
2205
+ * Thin delegator kept private on `VernLLM` (rather than only existing on
2206
+ * `CacheOrchestrator`) since it's the one caching primitive exercised
2207
+ * directly by white-box tests, independent of the public `cachedCall()`
2208
+ * surface.
2209
+ */
2210
+ runCached(params) {
2211
+ return this.cacheOrchestrator.runCached(params);
2212
+ }
2213
+ /**
2214
+ * Removes a cached response by key when the configured cache adapter
2215
+ * supports deletion. Cache invalidation is the caller's responsibility;
2216
+ * only the application knows when cached data is stale.
2217
+ *
2218
+ * @param key The raw cache key (resolved through the adapter's
2219
+ * `resolveKey`, if any, before deletion).
2220
+ */
2221
+ async deleteCache(key) {
2222
+ await this.cacheOrchestrator.deleteCache(key);
2223
+ }
1395
2224
  async cachedCall(params) {
1396
2225
  const { call: callParams,...cacheParams } = params;
1397
2226
  const { reserveUsage, refundUsage,...restCallParams } = callParams;
1398
2227
  if (reserveUsage || refundUsage) this.logger.warn("[VernLLM] reserveUsage/refundUsage on `call` are ignored by cachedCall; set them at the top level instead.");
1399
2228
  if (restCallParams.stream) {
1400
2229
  const streamParams = restCallParams;
1401
- return this.runCachedStream({
2230
+ return this.cacheOrchestrator.runCachedStream({
1402
2231
  ...cacheParams,
1403
2232
  openStream: () => this.call(streamParams)
1404
2233
  }, Boolean(restCallParams.tools));
@@ -1409,16 +2238,39 @@ var VernLLM = class {
1409
2238
  });
1410
2239
  }
1411
2240
  /**
2241
+ * @param model With `circuitBreaker.isolateByModel` on, returns that
2242
+ * model's own circuit state instead of the shared one. Ignored
2243
+ * otherwise. Omit for the shared circuit (the default) or, under
2244
+ * isolation, the state of calls that didn't resolve a model.
1412
2245
  * @returns The current circuit breaker state (`'closed' | 'open' |
1413
2246
  * 'half-open'`), or undefined if no circuit breaker was configured.
1414
2247
  */
1415
- getCircuitState() {
1416
- return this.breaker?.getState();
2248
+ getCircuitState(model) {
2249
+ return this.executors[0].getCircuitState(model);
2250
+ }
2251
+ /**
2252
+ * @param model With `circuitBreaker.isolateByModel` on, returns each
2253
+ * target's circuit state for that model instead of its shared state.
2254
+ * Ignored otherwise. Omit for the shared circuit (the default) or, under
2255
+ * isolation, the state of calls that didn't resolve a model.
2256
+ * @returns The current circuit state for every target in declaration
2257
+ * order, including the primary and all fallback targets. Each entry
2258
+ * includes the target's provider name, chain index, whether it is a
2259
+ * fallback, and its circuit state, or undefined if that target has no
2260
+ * circuit breaker configured.
2261
+ */
2262
+ getCircuitStates(model) {
2263
+ return this.executors.map((executor, index) => ({
2264
+ provider: executor.providerName,
2265
+ index,
2266
+ isFallback: index > 0,
2267
+ state: executor.getCircuitState(model)
2268
+ }));
1417
2269
  }
1418
2270
  };
1419
2271
 
1420
2272
  //#endregion
1421
- //#region src/internal/sse.ts
2273
+ //#region src/adapters/internal/sse.ts
1422
2274
  /**
1423
2275
  * Parses a Server-Sent-Events byte/text stream into the JSON payload of
1424
2276
  * each `data:` frame, in arrival order. Generic over transport: works with
@@ -1520,7 +2372,7 @@ function parseSseFrame(frame) {
1520
2372
  }
1521
2373
 
1522
2374
  //#endregion
1523
- //#region src/internal/imageFormat.ts
2375
+ //#region src/adapters/internal/imageFormat.ts
1524
2376
  /**
1525
2377
  * MIME types accepted for `ImageBlock.mimeType` across all adapters. This is
1526
2378
  * the intersection of what Anthropic, Gemini, OpenAI-compatible, and Bedrock
@@ -1544,6 +2396,14 @@ function assertSupportedImageMimeType(mimeType) {
1544
2396
  throw new LLMError(`Unsupported image mimeType "${mimeType}": expected one of ${SUPPORTED_IMAGE_MIME_TYPES.join(", ")}`, "validation");
1545
2397
  }
1546
2398
 
2399
+ //#endregion
2400
+ //#region src/adapters/internal/nativeStructuredOutput.ts
2401
+ /** Resolves whether `model` is covered by a caller-supplied allow-list/predicate. */
2402
+ function supportsNativeStructuredOutput(model, override) {
2403
+ if (!override) return false;
2404
+ return Array.isArray(override) ? override.includes(model) : override(model);
2405
+ }
2406
+
1547
2407
  //#endregion
1548
2408
  //#region src/adapters/anthropic.ts
1549
2409
  /**
@@ -1594,6 +2454,23 @@ function toAnthropicToolChoice(toolChoice) {
1594
2454
  };
1595
2455
  }
1596
2456
  /**
2457
+ * Maps VernLLM's OpenAI-shaped wire `tools`/`tool_choice` into Anthropic's
2458
+ * `tools`/`tool_choice` shape. Shared by the two call sites that build real
2459
+ * (non-schema-forced) tool definitions: the plain tools-only branch, and
2460
+ * the native-structured-output branch, which sends real tools alongside
2461
+ * `output_config` rather than instead of it.
2462
+ */
2463
+ function buildAnthropicTools(tools, toolChoiceParam) {
2464
+ return {
2465
+ tools: tools.map((t) => ({
2466
+ name: t.function.name,
2467
+ description: t.function.description,
2468
+ input_schema: assertObjectSchema(t.function.parameters, t.function.name)
2469
+ })),
2470
+ toolChoice: toAnthropicToolChoice(toolChoiceParam)
2471
+ };
2472
+ }
2473
+ /**
1597
2474
  * Builds the Anthropic-shaped request body from VernLLM's wire params,
1598
2475
  * shared between `create` and `createStream` so both go through identical
1599
2476
  * translation (system prompt, message shaping, and the jsonSchema →
@@ -1601,21 +2478,38 @@ function toAnthropicToolChoice(toolChoice) {
1601
2478
  * point).
1602
2479
  *
1603
2480
  * Returns `toolName` alongside the body: when set, the model was forced to
1604
- * call a single synthetic tool standing in for `jsonSchema` output, and
2481
+ * call a single synthetic tool standing in for `jsonSchema` output (the
2482
+ * legacy path, for models without native structured-output support), and
1605
2483
  * both `create` and `createStream` need to know this so they can unwrap
1606
2484
  * that tool call back into plain text content instead of treating it like
1607
- * a real tool call.
2485
+ * a real tool call. On the native path (model supports `output_config`),
2486
+ * `toolName` is `undefined`: the schema-conforming JSON already arrives as
2487
+ * ordinary text content, nothing to unwrap, and any real tool calls in
2488
+ * `params.tools` are left for the normal, non-forced tool-call handling
2489
+ * both `create` and `createStream` already do when `toolName` is unset.
1608
2490
  */
1609
- function buildAnthropicRequestBody(params) {
2491
+ function buildAnthropicRequestBody(params, nativeStructuredOutputModels) {
1610
2492
  const systemMessage = params.messages.find((m) => m.role === "system");
1611
2493
  const conversationMessages = params.messages.filter((m) => m.role === "user" || m.role === "assistant" || m.role === "tool");
1612
- const toolName = params.response_format?.type === "json_schema" ? params.response_format.json_schema.name.trim() : void 0;
1613
- if (params.response_format?.type === "json_schema" && !toolName) throw new LLMError("json_schema.name must not be empty.", "validation");
2494
+ const jsonSchema = params.response_format?.type === "json_schema" ? params.response_format.json_schema : void 0;
2495
+ const schemaName = jsonSchema?.name.trim();
2496
+ if (jsonSchema && !schemaName) throw new LLMError("json_schema.name must not be empty.", "validation");
2497
+ const isNative = Boolean(jsonSchema) && supportsNativeStructuredOutput(params.model, nativeStructuredOutputModels);
2498
+ if (jsonSchema && params.tools?.length && !isNative) throw new LLMError(`Anthropic model "${params.model}" is not covered by nativeStructuredOutputModels, so \`jsonSchema\` is emulated as a forced single tool call there, which collides with the \`tools\` you also provided. Either drop \`tools\` or \`jsonSchema\` for this call, or pass this model in fromAnthropic's \`nativeStructuredOutputModels\` option once you've confirmed it supports Anthropic's \`output_config.format\`.`, "validation");
2499
+ let toolName;
1614
2500
  let jsonInstruction;
2501
+ let outputFormat;
1615
2502
  let tools;
1616
2503
  let toolChoice;
1617
- if (params.response_format?.type === "json_schema" && toolName) {
1618
- const { schema, description, strict } = params.response_format.json_schema;
2504
+ if (jsonSchema && isNative) {
2505
+ outputFormat = {
2506
+ type: "json_schema",
2507
+ schema: jsonSchema.schema
2508
+ };
2509
+ if (params.tools?.length) ({tools, toolChoice} = buildAnthropicTools(params.tools, params.tool_choice));
2510
+ } else if (jsonSchema && schemaName) {
2511
+ const { schema, description, strict } = jsonSchema;
2512
+ toolName = schemaName;
1619
2513
  tools = [{
1620
2514
  name: toolName,
1621
2515
  description,
@@ -1627,14 +2521,7 @@ function buildAnthropicRequestBody(params) {
1627
2521
  name: toolName
1628
2522
  };
1629
2523
  } else if (params.response_format?.type === "json_object") jsonInstruction = "Respond with valid JSON only, no prose or markdown fences.";
1630
- else if (params.tools?.length) {
1631
- tools = params.tools.map((t) => ({
1632
- name: t.function.name,
1633
- description: t.function.description,
1634
- input_schema: assertObjectSchema(t.function.parameters, t.function.name)
1635
- }));
1636
- toolChoice = toAnthropicToolChoice(params.tool_choice);
1637
- }
2524
+ if (!jsonSchema && params.tools?.length) ({tools, toolChoice} = buildAnthropicTools(params.tools, params.tool_choice));
1638
2525
  const system = [systemMessage?.content, jsonInstruction].filter(Boolean).join("\n\n");
1639
2526
  const body = {
1640
2527
  model: params.model,
@@ -1645,7 +2532,8 @@ function buildAnthropicRequestBody(params) {
1645
2532
  ...tools ? {
1646
2533
  tools,
1647
2534
  tool_choice: toolChoice
1648
- } : {}
2535
+ } : {},
2536
+ ...outputFormat ? { output_config: { format: outputFormat } } : {}
1649
2537
  };
1650
2538
  return {
1651
2539
  body,
@@ -1656,22 +2544,37 @@ function buildAnthropicRequestBody(params) {
1656
2544
  * Wraps an Anthropic SDK client so it satisfies the same `LLMClient`
1657
2545
  * interface VernLLM uses for OpenAI/Groq.
1658
2546
  *
1659
- * `response_format: json_schema` is mapped to Anthropic's forced tool-use:
1660
- * a single tool is defined with `input_schema` set to the caller's schema,
1661
- * `description` forwarded when provided, and `strict` forwarded when set.
1662
- * `tool_choice` forces the model to call it. Provider-constrained schema
1663
- * matching applies only when `strict: true` is forwarded and supported.
2547
+ * `response_format: json_schema`, on a model covered by
2548
+ * `options.nativeStructuredOutputModels`, is sent as `output_config.format`,
2549
+ * its own request field, independent of `tools`/`tool_choice`, so it can be
2550
+ * combined with real, caller-supplied `tools` in the same request. Only
2551
+ * `type` and `schema` are sent on this path, the real Anthropic API's
2552
+ * `output_config.format` has no `name`/`description`/`strict` fields.
2553
+ *
2554
+ * On any other model (the default, since `nativeStructuredOutputModels` is
2555
+ * opt-in), `response_format: json_schema` is mapped to Anthropic's forced
2556
+ * tool-use instead: a single tool is defined with `input_schema` set to
2557
+ * the caller's schema, `description` forwarded when provided, and `strict`
2558
+ * forwarded when set, and `tool_choice` forces the model to call it. This
2559
+ * legacy path cannot be combined with real `tools` (both would need the
2560
+ * same `tools`/`tool_choice` field), and a call that tries throws
2561
+ * `LLMError('validation')` before reaching the API. Provider-constrained
2562
+ * schema matching applies only when `strict: true` is forwarded and
2563
+ * supported.
1664
2564
  *
1665
2565
  * `response_format: json_object` (no schema to build a tool from) falls
1666
2566
  * back to a system-prompt instruction, since there's nothing to constrain
1667
- * generation against.
2567
+ * generation against. Unlike `jsonSchema`, this combines with real `tools`
2568
+ * freely on every model: it's a prompt nudge, not a request field, so
2569
+ * there's nothing for it to collide with.
1668
2570
  */
1669
- function fromAnthropic(anthropicClient) {
2571
+ function fromAnthropic(anthropicClient, options) {
2572
+ const nativeStructuredOutputModels = options?.nativeStructuredOutputModels;
1670
2573
  const rawMessagesCreate = anthropicClient.messages.create.bind(anthropicClient.messages);
1671
2574
  return { chat: { completions: {
1672
- async create(params, options) {
1673
- const { body, toolName } = buildAnthropicRequestBody(params);
1674
- const response = await anthropicClient.messages.create(body, options);
2575
+ async create(params, options$1) {
2576
+ const { body, toolName } = buildAnthropicRequestBody(params, nativeStructuredOutputModels);
2577
+ const response = await anthropicClient.messages.create(body, options$1);
1675
2578
  let text;
1676
2579
  let wireToolCalls;
1677
2580
  if (toolName) {
@@ -1703,12 +2606,12 @@ function fromAnthropic(anthropicClient) {
1703
2606
  }
1704
2607
  };
1705
2608
  },
1706
- async *createStream(params, options) {
1707
- const { body, toolName } = buildAnthropicRequestBody(params);
2609
+ async *createStream(params, options$1) {
2610
+ const { body, toolName } = buildAnthropicRequestBody(params, nativeStructuredOutputModels);
1708
2611
  const stream = await rawMessagesCreate({
1709
2612
  ...body,
1710
2613
  stream: true
1711
- }, options);
2614
+ }, options$1);
1712
2615
  const blockKinds = new Map();
1713
2616
  let inputTokens = 0;
1714
2617
  let sawJsonTool = false;
@@ -1966,10 +2869,10 @@ function buildGeminiRequest(params) {
1966
2869
  * Anthropic.
1967
2870
  *
1968
2871
  * `tools` maps to Gemini's native `functionDeclarations`/`functionCall`;
1969
- * `tool_choice` maps to `toolConfig.functionCallingConfig`. `jsonSchema`
1970
- * and `tools` are mutually exclusive by the time a call reaches here
1971
- * (enforced in vernLLM.ts), so `responseSchema` and `tools` never
1972
- * both apply.
2872
+ * `tool_choice` maps to `toolConfig.functionCallingConfig`. Gemini accepts
2873
+ * `responseSchema` and `tools` in the same request natively, so both are
2874
+ * set independently here and no special-casing is needed for the
2875
+ * combination, unlike `fromAnthropic`/`fromBedrock`.
1973
2876
  *
1974
2877
  * `createStream` calls `generateContentStream` (optional on `GeminiClient`
1975
2878
  *, required only if the caller sets `stream: true`) and translates each
@@ -2093,6 +2996,23 @@ function toBedrockContent(blocks) {
2093
2996
  } } : { text: block.text });
2094
2997
  }
2095
2998
  /**
2999
+ * Maps VernLLM's OpenAI-shaped wire `tools`/`tool_choice` into Converse's
3000
+ * `toolConfig` shape. Shared by the two call sites that build real
3001
+ * (non-schema-forced) tool definitions: the plain tools-only branch, and
3002
+ * the native-structured-output branch, which sends real tools alongside
3003
+ * `outputConfig` rather than instead of it.
3004
+ */
3005
+ function buildBedrockToolConfig(tools, toolChoiceParam) {
3006
+ return {
3007
+ tools: tools.map((t) => ({ toolSpec: {
3008
+ name: t.function.name,
3009
+ description: t.function.description,
3010
+ inputSchema: { json: t.function.parameters }
3011
+ } })),
3012
+ toolChoice: toBedrockToolChoice(toolChoiceParam)
3013
+ };
3014
+ }
3015
+ /**
2096
3016
  * Builds the Converse-shaped request from VernLLM's wire params, shared
2097
3017
  * between `create` and `createStream` so both go through identical
2098
3018
  * translation (system prompt, message shaping, the jsonSchema →
@@ -2100,21 +3020,42 @@ function toBedrockContent(blocks) {
2100
3020
  * check all happen exactly once).
2101
3021
  *
2102
3022
  * Returns `toolName` alongside the request: when set, the model was forced
2103
- * to call a single synthetic tool standing in for `jsonSchema` output, and
2104
- * both `create` and `createStream` need to know this so they can unwrap
2105
- * that tool call back into plain text content instead of treating it like
2106
- * a real tool call.
3023
+ * to call a single synthetic tool standing in for `jsonSchema` output (the
3024
+ * legacy path, for models not covered by `nativeStructuredOutputModels`),
3025
+ * and both `create` and `createStream` need to know this so they can
3026
+ * unwrap that tool call back into plain text content instead of treating
3027
+ * it like a real tool call. On the native path (model covered by
3028
+ * `nativeStructuredOutputModels`), `toolName` is `undefined`: the
3029
+ * schema-conforming JSON already arrives as ordinary text content, nothing
3030
+ * to unwrap, and any real tool calls in `params.tools` are left for the
3031
+ * normal, non-forced tool-call handling both `create` and `createStream`
3032
+ * already do when `toolName` is unset.
2107
3033
  */
2108
- function buildBedrockRequest(params, toolUseSupportedModels) {
3034
+ function buildBedrockRequest(params, toolUseSupportedModels, nativeStructuredOutputModels) {
2109
3035
  const systemMessage = params.messages.find((m) => m.role === "system");
2110
3036
  const conversationMessages = params.messages.filter((m) => m.role === "user" || m.role === "assistant" || m.role === "tool");
2111
3037
  const jsonSchema = params.response_format?.type === "json_schema" ? params.response_format.json_schema : void 0;
2112
- const toolName = jsonSchema?.name.trim();
2113
- if (jsonSchema && !toolName) throw new LLMError("json_schema.name must not be empty.", "validation");
3038
+ const schemaName = jsonSchema?.name.trim();
3039
+ if (jsonSchema && !schemaName) throw new LLMError("json_schema.name must not be empty.", "validation");
3040
+ const isNative = Boolean(jsonSchema) && supportsNativeStructuredOutput(params.model, nativeStructuredOutputModels);
3041
+ if (jsonSchema && params.tools?.length && !isNative) throw new LLMError(`Bedrock model "${params.model}" is not covered by nativeStructuredOutputModels, so \`jsonSchema\` is emulated as a forced single tool call there (via \`toolConfig\`), which collides with the \`tools\` you also provided. Either drop \`tools\` or \`jsonSchema\` for this call, or pass this model in fromBedrock's \`nativeStructuredOutputModels\` option once you've confirmed it supports Converse's \`outputConfig.textFormat\`.`, "validation");
3042
+ let toolName;
2114
3043
  let jsonInstruction;
2115
3044
  let toolConfig;
2116
- if (jsonSchema) {
3045
+ let outputConfig;
3046
+ if (jsonSchema && isNative) {
3047
+ const { schema, description } = jsonSchema;
3048
+ outputConfig = { textFormat: {
3049
+ type: "json_schema",
3050
+ structure: { jsonSchema: {
3051
+ schema: JSON.stringify(schema),
3052
+ name: schemaName,
3053
+ description
3054
+ } }
3055
+ } };
3056
+ } else if (jsonSchema && schemaName) {
2117
3057
  const { schema, description, strict } = jsonSchema;
3058
+ toolName = schemaName;
2118
3059
  toolConfig = {
2119
3060
  tools: [{ toolSpec: {
2120
3061
  name: toolName,
@@ -2125,17 +3066,10 @@ function buildBedrockRequest(params, toolUseSupportedModels) {
2125
3066
  toolChoice: { tool: { name: toolName } }
2126
3067
  };
2127
3068
  } else if (params.response_format?.type === "json_object") jsonInstruction = "Respond with valid JSON only, no prose or markdown fences.";
2128
- else if (params.tools?.length) toolConfig = {
2129
- tools: params.tools.map((t) => ({ toolSpec: {
2130
- name: t.function.name,
2131
- description: t.function.description,
2132
- inputSchema: { json: t.function.parameters }
2133
- } })),
2134
- toolChoice: toBedrockToolChoice(params.tool_choice)
2135
- };
2136
- if (jsonSchema && toolUseSupportedModels) {
3069
+ if (params.tools?.length && !toolName) toolConfig = buildBedrockToolConfig(params.tools, params.tool_choice);
3070
+ if (jsonSchema && toolConfig && toolUseSupportedModels) {
2137
3071
  const isSupported = Array.isArray(toolUseSupportedModels) ? toolUseSupportedModels.includes(params.model) : toolUseSupportedModels(params.model);
2138
- if (!isSupported) throw new LLMError(`Bedrock model "${params.model}" is not listed in toolUseSupportedModels, but jsonSchema structured output requires Converse tool use.`, "validation");
3072
+ if (!isSupported) throw new LLMError(`Bedrock model "${params.model}" is not listed in toolUseSupportedModels, but this call requires Converse tool use (either jsonSchema emulated as a forced tool call, or real \`tools\` sent alongside native structured output).`, "validation");
2139
3073
  }
2140
3074
  const systemParts = [systemMessage?.content, jsonInstruction].filter((s) => Boolean(s));
2141
3075
  const request = {
@@ -2146,7 +3080,8 @@ function buildBedrockRequest(params, toolUseSupportedModels) {
2146
3080
  ...params.temperature !== void 0 ? { temperature: params.temperature } : {},
2147
3081
  maxTokens: params.max_tokens
2148
3082
  },
2149
- ...toolConfig ? { toolConfig } : {}
3083
+ ...toolConfig ? { toolConfig } : {},
3084
+ ...outputConfig ? { outputConfig } : {}
2150
3085
  };
2151
3086
  return {
2152
3087
  request,
@@ -2161,22 +3096,36 @@ function buildBedrockRequest(params, toolUseSupportedModels) {
2161
3096
  * regardless of which underlying model `modelId` points at, as long as
2162
3097
  * that model supports Converse (most current-generation ones do)
2163
3098
  *
2164
- * `response_format: json_schema` is mapped to Converse's `toolConfig`: a
2165
- * single tool is defined from the schema, description, and strictness settings,
2166
- * and `toolChoice` forces the model to call it. Provider-constrained schema
2167
- * matching applies only when `strict: true` is forwarded and supported.
2168
- * Native tool support varies by model family; pass
2169
- * `toolUseSupportedModels` to preflight-check it (see
3099
+ * `response_format: json_schema`, on a model covered by
3100
+ * `options.nativeStructuredOutputModels` (opt-in, unset by default), is
3101
+ * sent as `outputConfig.textFormat`, its own request field, independent of
3102
+ * `toolConfig`, so it can be combined with real, caller-supplied `tools`
3103
+ * in the same request. Matches the real Converse API's shape exactly: the
3104
+ * schema is nested under `structure.jsonSchema` and JSON-encoded as a
3105
+ * string, not the parsed object `toolConfig`'s tool schemas use, and there
3106
+ * is no `strict` field on this path.
3107
+ *
3108
+ * On any other model (the default), `response_format: json_schema` is
3109
+ * mapped to Converse's `toolConfig` instead: a single tool is defined from
3110
+ * the schema, description, and strictness settings, and `toolChoice`
3111
+ * forces the model to call it. This legacy path cannot be combined with
3112
+ * real `tools` (both would need the same `toolConfig`), and a call that
3113
+ * tries throws `LLMError('validation')` before reaching the API.
3114
+ * Provider-constrained schema matching applies only when `strict: true` is
3115
+ * forwarded and supported. Native tool support varies by model family;
3116
+ * pass `toolUseSupportedModels` to preflight-check it (see
2170
3117
  * `BedrockAdapterOptions`), otherwise a `jsonSchema` call to an
2171
3118
  * unsupported model surfaces Bedrock's raw error unchanged.
2172
3119
  *
2173
3120
  * `response_format: json_object` (no schema to build a tool from) and
2174
3121
  * `reasoning_effort` (no Converse equivalent) fall back to a system-prompt
2175
- * instruction and are dropped respectively.
3122
+ * instruction and are dropped respectively. Unlike `jsonSchema`,
3123
+ * `json_object` combines with real `tools` freely on every model: it's a
3124
+ * prompt nudge, not a request field, so there's nothing for it to collide
3125
+ * with.
2176
3126
  *
2177
- * `tools` maps to Converse's native `toolConfig`/`toolUse`/`toolResult`;
2178
- * `tool_choice` maps to `toolConfig.toolChoice`. Mutually exclusive with
2179
- * `jsonSchema` by the time a call reaches here (enforced in vernLLM.ts).
3127
+ * `tools` alone maps to Converse's native `toolConfig`/`toolUse`/
3128
+ * `toolResult`; `tool_choice` maps to `toolConfig.toolChoice`.
2180
3129
  *
2181
3130
  * `createStream` calls `converseStream` (optional on `BedrockConverseClient`
2182
3131
  *, required only if the caller sets `stream: true`) and translates its
@@ -2192,9 +3141,10 @@ function buildBedrockRequest(params, toolUseSupportedModels) {
2192
3141
  */
2193
3142
  function fromBedrock(bedrockClient, options) {
2194
3143
  const toolUseSupportedModels = options?.toolUseSupportedModels;
3144
+ const nativeStructuredOutputModels = options?.nativeStructuredOutputModels;
2195
3145
  return { chat: { completions: {
2196
3146
  async create(params, requestOptions) {
2197
- const { request, toolName } = buildBedrockRequest(params, toolUseSupportedModels);
3147
+ const { request, toolName } = buildBedrockRequest(params, toolUseSupportedModels, nativeStructuredOutputModels);
2198
3148
  const response = await bedrockClient.converse(request, requestOptions);
2199
3149
  let text;
2200
3150
  let wireToolCalls;
@@ -2232,7 +3182,7 @@ function fromBedrock(bedrockClient, options) {
2232
3182
  },
2233
3183
  async *createStream(params, requestOptions) {
2234
3184
  if (!bedrockClient.converseStream) throw new LLMError("stream: true requires a Bedrock client with converseStream", "validation");
2235
- const { request, toolName } = buildBedrockRequest(params, toolUseSupportedModels);
3185
+ const { request, toolName } = buildBedrockRequest(params, toolUseSupportedModels, nativeStructuredOutputModels);
2236
3186
  const { stream } = await bedrockClient.converseStream(request, requestOptions);
2237
3187
  const blockKinds = new Map();
2238
3188
  for await (const event of stream) if ("contentBlockStart" in event) {
@@ -2694,5 +3644,5 @@ const fromAtlasCloud = fromOpenAICompatible;
2694
3644
  const from01AI = fromOpenAICompatible;
2695
3645
 
2696
3646
  //#endregion
2697
- export { CircuitBreaker, ConsoleLogger, InMemoryCacheAdapter, LLMError, NormalizedCacheAdapter, SSE_PING, TieredCacheAdapter, VernLLM, from01AI, fromAnthropic, fromAnyscale, fromAtlasCloud, fromBaseten, fromBedrock, fromCerebras, fromCloudflareWorkersAI, fromDeepInfra, fromDeepSeek, fromFeatherless, fromFetch, fromFireworks, fromFriendli, fromGemini, fromGitHubModels, fromGroq, fromHyperbolic, fromInferenceNet, fromInfermatic, fromKlusterAI, fromLMStudio, fromLambdaLabs, fromLepton, fromMiniMax, fromMistral, fromMoonshot, fromNebius, fromNovita, fromNvidiaNIM, fromOllama, fromOpenAICompatible, fromOpenRouter, fromParasail, fromPerplexity, fromSambaNova, fromSiliconFlow, fromSnowflakeCortex, fromStepFun, fromTogether, fromVLLM, fromVercelAIGateway, fromXAI, fromZhipu, isLLMError, isToolCallResult, parseSseStream };
3647
+ export { CircuitBreaker, ConsoleLogger, FallbackExhaustedError, InMemoryCacheAdapter, LLMError, NormalizedCacheAdapter, RateLimiter, SSE_PING, TieredCacheAdapter, VernLLM, defaultEstimateTokens, defaultFallbackOn, from01AI, fromAnthropic, fromAnyscale, fromAtlasCloud, fromBaseten, fromBedrock, fromCerebras, fromCloudflareWorkersAI, fromDeepInfra, fromDeepSeek, fromFeatherless, fromFetch, fromFireworks, fromFriendli, fromGemini, fromGitHubModels, fromGroq, fromHyperbolic, fromInferenceNet, fromInfermatic, fromKlusterAI, fromLMStudio, fromLambdaLabs, fromLepton, fromMiniMax, fromMistral, fromMoonshot, fromNebius, fromNovita, fromNvidiaNIM, fromOllama, fromOpenAICompatible, fromOpenRouter, fromParasail, fromPerplexity, fromSambaNova, fromSiliconFlow, fromSnowflakeCortex, fromStepFun, fromTogether, fromVLLM, fromVercelAIGateway, fromXAI, fromZhipu, isLLMError, isToolCallResult, parseSseStream };
2698
3648
  //# sourceMappingURL=index.js.map