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