vern-llm 1.6.0 → 1.7.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
@@ -209,6 +209,15 @@ function extractRetryAfterMs(err, maxDelayMs = DEFAULT_MAX_DELAY_MS) {
209
209
  if (!Number.isNaN(dateMs)) return Math.max(0, Math.min(dateMs - Date.now(), maxDelayMs));
210
210
  return void 0;
211
211
  }
212
+ /** Converts any thrown value into a well-typed LLMError. */
213
+ function normalizeError(error, signal) {
214
+ if (signal?.aborted) return new LLMError("LLM request aborted", "aborted");
215
+ if (error instanceof LLMError) return error;
216
+ const status = extractStatus(error);
217
+ const retryAfterMs = extractRetryAfterMs(error);
218
+ if (status !== void 0) return new LLMError("LLM request failed", "api", status, void 0, error, retryAfterMs);
219
+ return new LLMError("LLM request failed", "unknown", void 0, void 0, error, retryAfterMs);
220
+ }
212
221
  /**
213
222
  * Exponential backoff with jitter, capped at maxDelayMs.
214
223
  * Jitter avoids thundering-herd retries when many callers back off in lockstep,
@@ -238,6 +247,54 @@ async function waitForRetry(delay, signal) {
238
247
  signal?.addEventListener("abort", onAbort, { once: true });
239
248
  });
240
249
  }
250
+ /**
251
+ * Runs `getResult` after reserving usage, if a `reserveUsage` hook was
252
+ * provided. `refundUsage` fires only if a reservation was actually made.
253
+ * `onRefundError` is called (instead of throwing) whenever a refund attempt
254
+ * itself fails, so a broken refund hook never masks the original error.
255
+ */
256
+ async function withReservedUsage(params, coalesced, getResult, signal, onRefundError) {
257
+ if (signal?.aborted) throw new LLMError("LLM request aborted", "aborted");
258
+ let reserved = false;
259
+ try {
260
+ if (params.reserveUsage) {
261
+ await params.reserveUsage({
262
+ coalesced,
263
+ signal
264
+ });
265
+ reserved = true;
266
+ }
267
+ } catch (error) {
268
+ if (signal?.aborted) throw new LLMError("LLM request aborted", "aborted");
269
+ throw new LLMError(error instanceof Error ? error.message : "Usage reservation failed", "quota_exceeded", void 0, void 0, error);
270
+ }
271
+ const refund = async (logMessage) => {
272
+ try {
273
+ await params.refundUsage?.({
274
+ coalesced,
275
+ signal
276
+ });
277
+ } catch (refundError) {
278
+ onRefundError(logMessage, refundError);
279
+ }
280
+ };
281
+ if (signal?.aborted) {
282
+ if (reserved) await refund("[VernLLM] refundUsage failed after abort");
283
+ throw new LLMError("LLM request aborted", "aborted");
284
+ }
285
+ let result;
286
+ try {
287
+ result = await getResult();
288
+ } catch (error) {
289
+ if (reserved) await refund("[VernLLM] refundUsage failed");
290
+ throw error;
291
+ }
292
+ if (signal?.aborted) {
293
+ if (reserved) await refund("[VernLLM] refundUsage failed after abort");
294
+ throw new LLMError("LLM request aborted", "aborted");
295
+ }
296
+ return result;
297
+ }
241
298
 
242
299
  //#endregion
243
300
  //#region src/logger.ts
@@ -312,16 +369,73 @@ var InMemoryCacheAdapter = class {
312
369
  }
313
370
  }
314
371
  };
372
+ /**
373
+ * Normalizes keys before caching to avoid duplicate entries from formatting differences.
374
+ */
375
+ var NormalizedCacheAdapter = class {
376
+ constructor(inner = new InMemoryCacheAdapter()) {
377
+ this.inner = inner;
378
+ }
379
+ normalize(key) {
380
+ return key.toLowerCase().trim().replace(/[^\p{L}\p{N}\s]/gu, " ").replace(/\s+/g, " ").trim();
381
+ }
382
+ async resolveKey(key) {
383
+ return this.normalize(key);
384
+ }
385
+ async get(key) {
386
+ return this.inner.get(this.normalize(key));
387
+ }
388
+ async set(key, value, ttl) {
389
+ await this.inner.set(this.normalize(key), value, ttl);
390
+ }
391
+ async delete(key) {
392
+ await this.inner.delete?.(this.normalize(key));
393
+ }
394
+ };
395
+ /**
396
+ * Two-tier cache with fast local L1 and shared L2.
397
+ * L2 hits are promoted back to L1.
398
+ */
399
+ var TieredCacheAdapter = class {
400
+ constructor(l1, l2, l1Ttl) {
401
+ this.l1 = l1;
402
+ this.l2 = l2;
403
+ this.l1Ttl = l1Ttl;
404
+ }
405
+ /**
406
+ * Forwards to L1's `resolveKey` if it has one, otherwise L2's. L1 is
407
+ * preferred since `get()` checks L1 first, so its notion of "the same
408
+ * key" is the one that determines whether a lookup can skip L2 entirely.
409
+ */
410
+ async resolveKey(key) {
411
+ if (this.l1.resolveKey) return this.l1.resolveKey(key);
412
+ if (this.l2.resolveKey) return this.l2.resolveKey(key);
413
+ return key;
414
+ }
415
+ async get(key) {
416
+ const l1Result = await this.l1.get(key);
417
+ if (l1Result.hit) return l1Result;
418
+ const l2Result = await this.l2.get(key);
419
+ if (l2Result.hit) await this.l1.set(key, l2Result.value, this.l1Ttl ?? 60);
420
+ return l2Result;
421
+ }
422
+ async set(key, value, ttl) {
423
+ await Promise.all([this.l1.set(key, value, this.l1Ttl ?? ttl), this.l2.set(key, value, ttl)]);
424
+ }
425
+ async delete(key) {
426
+ await Promise.all([this.l1.delete?.(key), this.l2.delete?.(key)]);
427
+ }
428
+ };
315
429
 
316
430
  //#endregion
317
431
  //#region src/vernLLM.ts
318
432
  /**
319
- * A resilient wrapper around an LLM chat completions client, this is VernLLM!
433
+ * A resilient layer around an LLM chat completions client, this is VernLLM!
320
434
  *
321
- * Adds retry with exponential backoff and jitter, per-attempt timeouts,
322
- * an optional circuit breaker, JSON parsing with optional schema
323
- * validation, usage tracking, and an optional response cache, all
324
- * configurable, all opt-in beyond sensible defaults
435
+ * Adds retry with backoff/jitter, per-attempt timeouts, an optional circuit breaker,
436
+ * JSON parsing with optional schema validation, usage tracking, and an
437
+ * optional response cache, all configurable, all opt-in beyond sensible
438
+ * defaults.
325
439
  */
326
440
  var VernLLM = class {
327
441
  client;
@@ -338,18 +452,18 @@ var VernLLM = class {
338
452
  logger;
339
453
  breaker;
340
454
  /**
341
- * @param options: Client, model, and all tunables (retries, timeout,
342
- * backoff, cache, circuit breaker, logger, etc). See VernLLMOptions in `types.ts`
343
- * for individual defaults
455
+ * @param options - Client, model, and tunables. Notable defaults:
456
+ * `maxRetries` 1, `timeoutMs` 25000, `baseDelayMs` 500 (exponential backoff
457
+ * base), `defaultMaxTokens` 1000, `cache` an in-memory adapter,
458
+ * `nonRetryableStatus` `[400, 401, 403, 404, 422]`, `debug` false.
344
459
  */
345
460
  constructor(options) {
346
461
  this.client = options.client;
347
462
  this.model = options.model;
348
- const retryConfig = this.resolveRetryConfig(options);
349
- this.maxRetries = retryConfig.maxRetries;
350
- this.timeoutMs = retryConfig.timeoutMs;
351
- this.baseDelayMs = retryConfig.baseDelayMs;
352
- this.defaultMaxTokens = retryConfig.defaultMaxTokens;
463
+ this.maxRetries = options.maxRetries ?? 1;
464
+ this.timeoutMs = options.timeoutMs ?? 25e3;
465
+ this.baseDelayMs = options.baseDelayMs ?? 500;
466
+ this.defaultMaxTokens = options.defaultMaxTokens ?? 1e3;
353
467
  this.cache = options.cache ?? new InMemoryCacheAdapter();
354
468
  this.nonRetryableStatus = options.nonRetryableStatus ?? [
355
469
  400,
@@ -360,72 +474,40 @@ var VernLLM = class {
360
474
  ];
361
475
  this.parseJson = options.parseJson ?? defaultParseJson;
362
476
  this.onUsage = options.onUsage;
363
- this.logger = this.resolveLogger(options);
364
- this.breaker = this.resolveCircuitBreaker(options);
365
- }
366
- /**
367
- * Resolves retry/timeout/token defaults from the given options,
368
- * falling back to the librarys built-in defaults for anything unset
369
- */
370
- resolveRetryConfig(options) {
371
- return {
372
- maxRetries: options.maxRetries ?? 1,
373
- timeoutMs: options.timeoutMs ?? 25e3,
374
- baseDelayMs: options.baseDelayMs ?? 500,
375
- defaultMaxTokens: options.defaultMaxTokens ?? 1e3
376
- };
477
+ this.logger = options.logger ?? new ConsoleLogger(options.debug ?? false);
478
+ this.breaker = options.circuitBreaker ? new CircuitBreaker(options.circuitBreaker === true ? void 0 : options.circuitBreaker) : void 0;
377
479
  }
378
- /**
379
- * Returns the caller supplied logger, or a console-based logger whose
380
- * debug output is gated by the `debug` option (defaulting to off,
381
- * so response content isn't unintentionally written to logs)
382
- */
383
- resolveLogger(options) {
384
- return options.logger ?? new ConsoleLogger(options.debug ?? false);
480
+ /** Resolves a cache key through the adapter when it supports normalization. */
481
+ async resolveCacheKey(key) {
482
+ return this.cache.resolveKey ? await this.cache.resolveKey(key) : key;
385
483
  }
386
484
  /**
387
- * Builds a circuit breaker if `circuitBreaker` is truthy on the
388
- * options. Passing `true` uses default thresholds, passing an options
389
- * object tunes them. Returns undefined when the breaker is disabled
390
- */
391
- resolveCircuitBreaker(options) {
392
- if (!options.circuitBreaker) return void 0;
393
- return new CircuitBreaker(options.circuitBreaker === true ? void 0 : options.circuitBreaker);
394
- }
395
- /**
396
- * Makes a single logical LLM call, transparently retrying on failure
397
- * according to the configured retry policy
398
- *
399
- * Fails fast if the circuit breaker is open or the signal is already
400
- * aborted, before any request is dispatched. On exhausting all
401
- * retries, records a circuit breaker failure and rejects with a
402
- * normalized LLMError
485
+ * Makes a single logical LLM call, retrying on failure per the configured
486
+ * policy. Fails fast if the breaker is open or the signal is already
487
+ * aborted. On exhausting retries, records a breaker failure and rejects
488
+ * with a normalized LLMError.
403
489
  *
404
- * @param params : System/user content plus per call overrides
405
- * (model, temperature, jsonMode, schema, signal, etc)
406
- * @returns The parsed and optionally schema-validated response, or
407
- * the raw string content when jsonMode is disabled
490
+ * @param params - System/user content plus per-call overrides (model,
491
+ * temperature, jsonMode, schema, signal, etc). See `CallParams`.
492
+ * @returns The parsed (and optionally schema-validated) response, or the
493
+ * raw string content when `jsonMode` is false and no `jsonSchema` is set.
408
494
  */
409
495
  async call(params) {
410
496
  this.breaker?.assertClosed();
411
497
  if (params.signal?.aborted) throw new LLMError("LLM request aborted", "aborted");
412
498
  const requestId = params.requestId ?? (0, crypto.randomUUID)();
413
- return this.withReservedUsage(params, false, async () => {
499
+ return withReservedUsage(params, false, async () => {
414
500
  try {
415
501
  return await this.retryWithBackoff(() => this.executeCall(params, requestId), requestId, params.signal);
416
502
  } catch (error) {
417
- const normalized = this.normalizeError(error, params.signal);
503
+ const normalized = normalizeError(error, params.signal);
418
504
  if (normalized.type !== "validation" && normalized.type !== "parse" && normalized.type !== "aborted") this.breaker?.recordFailure();
419
505
  this.logger.debug(`[vern:${requestId}] error:\n${describeError(error)}`);
420
506
  throw normalized;
421
507
  }
422
- }, params.signal);
508
+ }, params.signal, (logMessage, error) => this.logRefundError(logMessage, error));
423
509
  }
424
- /**
425
- * Runs `fn`, retrying with backoff according to `shouldRetry` policy
426
- * Purely mechanical: knows nothing about LLM specifics beyond the retry
427
- * predicate, so its testable independent of request/response shaping
428
- */
510
+ /** Runs `fn`, retrying with backoff according to `shouldRetry`. */
429
511
  async retryWithBackoff(fn, requestId, signal) {
430
512
  let lastError;
431
513
  for (let attempt = 0; attempt <= this.maxRetries; attempt++) try {
@@ -438,24 +520,9 @@ var VernLLM = class {
438
520
  throw lastError;
439
521
  }
440
522
  /**
441
- * Converts any thrown value into a well-typed LLMError for the public
442
- * API surface. Preserves an existing LLMError as is, reports aborted
443
- * signals as such, classifies errors carrying an http status as
444
- * type api, and otherwise falls back to a generic unknown error.
445
- */
446
- normalizeError(error, signal) {
447
- if (signal?.aborted) return new LLMError("LLM request aborted", "aborted");
448
- if (error instanceof LLMError) return error;
449
- const status = extractStatus(error);
450
- const retryAfterMs = extractRetryAfterMs(error);
451
- if (status !== void 0) return new LLMError("LLM request failed", "api", status, void 0, error, retryAfterMs);
452
- return new LLMError("LLM request failed", "unknown", void 0, void 0, error, retryAfterMs);
453
- }
454
- /**
455
523
  * Performs a single attempt: builds the request, dispatches it with a
456
- * timeout, and shapes the response. Throws on an empty response so
457
- * the retry loop treats it like any other transient failure. Records
458
- * usage and a circuit breaker success before returning
524
+ * timeout, and shapes the response. Throws on an empty response so the
525
+ * retry loop treats it like any other transient failure.
459
526
  */
460
527
  async executeCall(params, requestId) {
461
528
  const { useJson, model, request } = this.buildRequestPayload(params);
@@ -473,12 +540,8 @@ var VernLLM = class {
473
540
  return result;
474
541
  }
475
542
  /**
476
- * Anthropic and Gemini both require strict user/assistant alternation
477
- * (and reject or silently mishandle two consecutive same-role turns), so
478
- * this validates `history` up front rather than letting a malformed
479
- * request surface as a confusing provider-side error. Thrown as a
480
- * validation LLMError, which `shouldRetry` never retries, since retrying
481
- * the same malformed input can't succeed
543
+ * Validates `history` alternates user/assistant turns, since providers
544
+ * like Anthropic/Gemini reject or mishandle consecutive same-role turns.
482
545
  */
483
546
  validateHistory(history) {
484
547
  let previousRole;
@@ -489,12 +552,7 @@ var VernLLM = class {
489
552
  }
490
553
  if (previousRole === "user") throw new LLMError("The last entry in history is a \"user\" turn, which would collide with the current userContent turn. history must end with an \"assistant\" turn (or be empty).", "validation");
491
554
  }
492
- /**
493
- * Applies per call defaults and shapes the params into the request
494
- * object expected by the underlying client, including the resolved
495
- * response format. Also returns whether JSON parsing should be
496
- * applied to the response and which model was ultimately used
497
- */
555
+ /** Applies per-call defaults and shapes params into the client's request object. */
498
556
  buildRequestPayload(params) {
499
557
  const { systemPrompt, userContent, history = [], temperature = .2, jsonMode = true, maxTokens = this.defaultMaxTokens, model = this.model, reasoningEffort, jsonSchema } = params;
500
558
  const useJson = jsonMode || Boolean(jsonSchema);
@@ -529,11 +587,10 @@ var VernLLM = class {
529
587
  };
530
588
  }
531
589
  /**
532
- * Chooses the response format to send to the provider. A provider
533
- * native json schema takes priority when supplied, constraining
534
- * generation directly, otherwise falls back to the looser json
535
- * object mode when JSON output is requested, or no format at all
536
- * for plain text responses
590
+ * Chooses the response format: a provider-native `jsonSchema` takes
591
+ * priority when supplied (constrains generation directly), otherwise
592
+ * falls back to the looser `json_object` mode when JSON output is
593
+ * requested, or no format at all for plain text responses.
537
594
  */
538
595
  buildResponseFormat(jsonSchema, useJson) {
539
596
  if (jsonSchema) return {
@@ -547,15 +604,7 @@ var VernLLM = class {
547
604
  };
548
605
  return useJson ? { type: "json_object" } : void 0;
549
606
  }
550
- /**
551
- * Reports token usage to the caller supplied onUsage callback, when
552
- * both a callback was configured and the provider actually returned
553
- * usage data on this response. A no op otherwise.
554
- *
555
- * A throwing onUsage callback is logged and swallowed rather than
556
- * propagated, so a broken billing/metrics hook can't fail or retrigger
557
- * retries on an otherwise-successful call.
558
- */
607
+ /** Reports token usage to `onUsage`, swallowing and logging any error it throws. */
559
608
  recordUsage(response, requestId, model) {
560
609
  if (!response.usage || !this.onUsage) return;
561
610
  try {
@@ -570,12 +619,7 @@ var VernLLM = class {
570
619
  this.logger.error("[VernLLM] onUsage failed", { message: error instanceof Error ? error.message : "unknown" });
571
620
  }
572
621
  }
573
- /**
574
- * Parses the raw response content as JSON and, when a schema is
575
- * supplied, validates the parsed value against it. Throws a parse
576
- * type LLMError on malformed JSON and a validation type LLMError,
577
- * carrying the schemas issues, on a failed validation
578
- */
622
+ /** Parses response content as JSON and validates it against `schema` when supplied. */
579
623
  parseAndValidate(content, schema) {
580
624
  let parsed;
581
625
  try {
@@ -590,12 +634,10 @@ var VernLLM = class {
590
634
  return result.data;
591
635
  }
592
636
  /**
593
- * Waits out the backoff delay for a given retry attempt, logging the
594
- * attempt for observability before the wait begins. Honors a
595
- * Retry-After header on the failed attempt's error when present
596
- * (capped at the same maxDelayMs as backoff), otherwise falls back to
597
- * exponential backoff exactly as before. Rejects early if the signal
598
- * aborts during the wait
637
+ * Waits out the backoff delay for a retry attempt, honoring a
638
+ * Retry-After header on the failed attempt's error when present.
639
+ * Both Retry-After and plain exponential backoff are capped at the same
640
+ * max delay (see `DEFAULT_MAX_DELAY_MS` in `vernLLM.utils.ts`).
599
641
  */
600
642
  async recoverDelay(requestId, attempt, error, signal) {
601
643
  const retryAfterMs = extractRetryAfterMs(error);
@@ -603,58 +645,56 @@ var VernLLM = class {
603
645
  this.logger.warn(`[vern:${requestId}] recovery attempt ${attempt}/${this.maxRetries}, waiting ${delay}ms` + (retryAfterMs !== void 0 ? " (honoring Retry-After)" : ""));
604
646
  await waitForRetry(delay, signal);
605
647
  }
606
- /**
607
- * Decides whether a failed attempt is worth retrying. Never retries
608
- * once the signal has aborted, never retries a parse or validation
609
- * failure since those stem from the response content rather than a
610
- * transient fault, and never retries a status code the caller has
611
- * marked as non retryable. Retries everything else
612
- */
648
+ /** Decides whether a failed attempt is worth retrying. */
613
649
  shouldRetry(error, signal) {
614
650
  if (signal?.aborted) return false;
615
651
  if (error instanceof LLMError && (error.type === "parse" || error.type === "validation")) return false;
616
652
  const status = extractStatus(error);
617
- if (status !== void 0 && this.nonRetryableStatus.includes(status)) return false;
618
- return true;
653
+ return !(status !== void 0 && this.nonRetryableStatus.includes(status));
619
654
  }
620
655
  /**
621
656
  * Removes a cached response by key when the configured cache adapter
622
- * supports deletion.
623
- *
624
- * Cache invalidation remains the responsibility of the caller because
657
+ * supports deletion. Cache invalidation is the caller's responsibility;
625
658
  * only the application knows when cached data is stale.
659
+ *
660
+ * @param key - The raw cache key (resolved through the adapter's
661
+ * `resolveKey`, if any, before deletion).
626
662
  */
627
663
  async deleteCache(key) {
628
664
  if (!this.cache.delete) return;
629
- await this.cache.delete(key);
665
+ await this.cache.delete(await this.resolveCacheKey(key));
630
666
  }
631
667
  /**
632
- * Cache wrapper around caller-supplied logic. `params.fn` should invoke
633
- * `this.call(...)` (see `cachedLLMCall`); retry/timeout handling is left
634
- * to the caller.
668
+ * Cache wrapper around caller-supplied logic. Concurrent misses for the
669
+ * same `cacheKey` share a single in-flight call, avoiding cache stampedes.
635
670
  *
636
- * Concurrent misses for the same `cacheKey` share a single in-flight call,
637
- * avoiding cache stampedes. Each caller still receives its own
638
- * `reserveUsage`/`refundUsage` callbacks with coalescing metadata.
671
+ * @param params - `cacheKey`, `ttl`, `fn` (the work to run on a cache
672
+ * miss, typically `() => this.call(...)`), and optional
673
+ * `reserveUsage`/`refundUsage`/`signal`. See `CachedCallParams`.
674
+ * @returns The cached value on a hit, or the result of `fn()` on a miss.
639
675
  */
640
676
  async cachedCall(params) {
641
- const cached = await this.cache.get(params.cacheKey);
677
+ const resolvedKey = await this.resolveCacheKey(params.cacheKey);
678
+ const resolvedParams = resolvedKey === params.cacheKey ? params : {
679
+ ...params,
680
+ cacheKey: resolvedKey
681
+ };
682
+ const cached = await this.cache.get(resolvedKey);
642
683
  if (cached.hit) return cached.value;
643
- const existing = this.inFlight.get(params.cacheKey);
644
- const coalesced = existing !== void 0;
645
- if (coalesced) return this.withReservedUsage(params, coalesced, () => existing, params.signal);
646
- return this.registerTrigger(params, coalesced);
684
+ const existing = this.inFlight.get(resolvedKey);
685
+ if (existing) return withReservedUsage(resolvedParams, true, () => existing, params.signal, (logMessage, error) => this.logRefundError(logMessage, error));
686
+ return this.registerTrigger(resolvedParams, false);
647
687
  }
648
- /** Starts the shared fn() call for a cache miss, reserving usage first, and registers it in the in-flight map until it settles */
688
+ /** Starts the shared fn() call for a cache miss and tracks it in the in-flight map until it settles. */
649
689
  registerTrigger(params, coalesced) {
650
- const resultPromise = this.withReservedUsage(params, coalesced, () => this.runAndCache(params), params.signal);
690
+ const resultPromise = withReservedUsage(params, coalesced, () => this.runAndCache(params), params.signal, (logMessage, error) => this.logRefundError(logMessage, error));
651
691
  this.inFlight.set(params.cacheKey, resultPromise);
652
692
  resultPromise.catch(() => {}).finally(() => {
653
693
  this.inFlight.delete(params.cacheKey);
654
694
  });
655
695
  return resultPromise;
656
696
  }
657
- /** Runs `fn` and writes its result to the cache. Only ever called once per cacheKey per in-flight window, from registerTrigger */
697
+ /** Runs `fn` and writes its result to the cache. */
658
698
  async runAndCache(params) {
659
699
  const result = await params.fn();
660
700
  try {
@@ -664,76 +704,31 @@ var VernLLM = class {
664
704
  }
665
705
  return result;
666
706
  }
667
- /**
668
- * Runs `getResult` after reserving usage, if a `reserveUsage` hook was
669
- * provided. `refundUsage` fires only if a reservation was actually made,
670
- * i.e. `reserveUsage` was provided and it resolved successfully. If
671
- * `reserveUsage` is omitted entirely, or if it throws, there is nothing to
672
- * refund, so `refundUsage` is not invoked in either case.
673
- *
674
- * Shared by `cachedCall()`/`registerTrigger()` and `call()`, so it only
675
- * depends on the usage hooks rather than LLM request concerns.
676
- */
677
- async withReservedUsage(params, coalesced, getResult, signal) {
678
- let reserved = false;
679
- try {
680
- if (params.reserveUsage) {
681
- await params.reserveUsage({
682
- coalesced,
683
- signal
684
- });
685
- reserved = true;
686
- }
687
- } catch (error) {
688
- throw new LLMError(error instanceof Error ? error.message : "Usage reservation failed", "quota_exceeded", void 0, void 0, error);
689
- }
690
- if (signal?.aborted) {
691
- if (reserved) try {
692
- await params.refundUsage?.({
693
- coalesced,
694
- signal
695
- });
696
- } catch (refundError) {
697
- this.logger.error("[VernLLM] refundUsage failed after abort", { message: refundError instanceof Error ? refundError.message : "unknown" });
698
- }
699
- throw new LLMError("LLM request aborted", "aborted");
700
- }
701
- try {
702
- return await getResult();
703
- } catch (error) {
704
- if (reserved) try {
705
- await params.refundUsage?.({
706
- coalesced,
707
- signal
708
- });
709
- } catch (refundError) {
710
- this.logger.error("[VernLLM] refundUsage failed", { message: refundError instanceof Error ? refundError.message : "unknown" });
711
- }
712
- throw error;
713
- }
707
+ /** Logs a failed refundUsage attempt via the configured logger. */
708
+ logRefundError(logMessage, error) {
709
+ this.logger.error(logMessage, { message: error instanceof Error ? error.message : "unknown" });
714
710
  }
715
711
  /**
716
712
  * Convenience wrapper composing `call` + `cachedCall`, so cached LLM calls
717
- * automatically get retry/timeout/circuit-breaker behavior without callers
718
- * having to remember to wire `fn: () => this.call(...)` themselves.
713
+ * automatically get retry/timeout/circuit-breaker behavior. `reserveUsage`/
714
+ * `refundUsage` are read from the top-level params only.
719
715
  *
720
- * `reserveUsage`/`refundUsage` are read from the top-level params only if
721
- * `call` also sets them, they're ignored, since `call()` now performs its
722
- * own reservation. Honoring both would reserve/refund twice per logical
723
- * cachedLLMCall (once via cachedCall's wrapping, once via the inner call()).
716
+ * @param params - `cachedCall` params (`cacheKey`, `ttl`, etc, minus `fn`)
717
+ * plus `call`, the `CallParams` to pass through to `this.call(...)`.
718
+ * @returns The cached value on a hit, or the freshly-called result on a miss.
724
719
  */
725
720
  async cachedLLMCall(params) {
726
721
  const { call: callParams,...cacheParams } = params;
727
- const { reserveUsage: _innerReserveUsage, refundUsage: _innerRefundUsage,...restCallParams } = callParams;
728
- if (_innerReserveUsage || _innerRefundUsage) this.logger.warn("[VernLLM] reserveUsage/refundUsage on `call` are ignored by cachedLLMCall; set them at the top level instead.");
722
+ const { reserveUsage: innerReserveUsage, refundUsage: innerRefundUsage,...restCallParams } = callParams;
723
+ if (innerReserveUsage || innerRefundUsage) this.logger.warn("[VernLLM] reserveUsage/refundUsage on `call` are ignored by cachedLLMCall; set them at the top level instead.");
729
724
  return this.cachedCall({
730
725
  ...cacheParams,
731
726
  fn: () => this.call(restCallParams)
732
727
  });
733
728
  }
734
729
  /**
735
- * Returns the current circuit breaker state, or undefined when no
736
- * circuit breaker was configured on this instance
730
+ * @returns The current circuit breaker state (`'closed' | 'open' |
731
+ * 'half-open'`), or undefined if no circuit breaker was configured.
737
732
  */
738
733
  getCircuitState() {
739
734
  return this.breaker?.getState();
@@ -790,15 +785,14 @@ function toAnthropicContent(blocks) {
790
785
  * Wraps an Anthropic SDK client so it satisfies the same `LLMClient`
791
786
  * interface VernLLM uses for OpenAI/Groq.
792
787
  *
793
- * `response_format: json_schema` is mapped to Anthropics forced tool-use:
788
+ * `response_format: json_schema` is mapped to Anthropic's forced tool-use:
794
789
  * a single tool is defined with `input_schema` set to the caller's schema,
795
- * and `tool_choice` forces the model to call it, so the output is
796
- * provider-constrained to match the schema rather than merely instructed
797
- * to via prompt text (the same guarantee OpenAIs native `json_schema` mode
798
- * gives, built on Anthropics tool-calling primitive instead).
790
+ * `description` forwarded when provided, and `strict` forwarded when set.
791
+ * `tool_choice` forces the model to call it. Provider-constrained schema
792
+ * matching applies only when `strict: true` is forwarded and supported.
799
793
  *
800
794
  * `response_format: json_object` (no schema to build a tool from) falls
801
- * back to a system-prompt instruction, since theres nothing to constrain
795
+ * back to a system-prompt instruction, since there's nothing to constrain
802
796
  * generation against.
803
797
  */
804
798
  function fromAnthropic(anthropicClient) {
@@ -809,11 +803,12 @@ function fromAnthropic(anthropicClient) {
809
803
  let jsonInstruction;
810
804
  let tools;
811
805
  if (params.response_format?.type === "json_schema" && toolName) {
812
- const { schema, description } = params.response_format.json_schema;
806
+ const { schema, description, strict } = params.response_format.json_schema;
813
807
  tools = [{
814
808
  name: toolName,
815
809
  description,
816
- input_schema: schema
810
+ input_schema: schema,
811
+ strict
817
812
  }];
818
813
  } else if (params.response_format?.type === "json_object") jsonInstruction = "Respond with valid JSON only, no prose or markdown fences.";
819
814
  const system = [systemMessage?.content, jsonInstruction].filter(Boolean).join("\n\n");
@@ -855,7 +850,7 @@ function fromAnthropic(anthropicClient) {
855
850
  /**
856
851
  * Translates a VernLLM `ContentBlock[]` into Gemini's native `parts` array:
857
852
  * text blocks become `{ text }`, image blocks become inline data parts
858
- * (`{ inlineData: { mimeType, data } }`), Geminis shape for embedding raw
853
+ * (`{ inlineData: { mimeType, data } }`), Gemini's shape for embedding raw
859
854
  * base64 image bytes directly in the request.
860
855
  */
861
856
  function toGeminiParts(blocks) {
@@ -866,14 +861,14 @@ function toGeminiParts(blocks) {
866
861
  }
867
862
  /**
868
863
  * Wraps a Gemini client so it satisfies the `LLMClient` interface VernLLM
869
- * uses for OpenAI/Groq. Geminis shape differs on nearly every axis: a
870
- * `contents` array instead of `messages`, a separate `systemInstruction`
871
- * field instead of a `system` role message, `generationConfig` instead of
872
- * top-level `temperature`/`max_tokens`, and native JSON Schema support via
873
- * `responseMimeType: 'application/json'` + `responseSchema` (so `jsonSchema`
874
- * is provider-enforced here, unlike the Anthropic adapters prompt-embedding
875
- * fallback). `reasoning_effort` has no equivalent. Geminis thinking models
876
- * use a token budget, not an effort tier, so its dropped, same as Anthropic.
864
+ * uses for OpenAI-compatible APIs. Gemini's shape differs on nearly every
865
+ * axis: a `contents` array instead of `messages`, a separate
866
+ * `systemInstruction` field instead of a `system` role message,
867
+ * `generationConfig` instead of top-level `temperature`/`max_tokens`, and
868
+ * native JSON Schema support via `responseMimeType: 'application/json'` +
869
+ * `responseSchema`. `reasoning_effort` has no equivalent. Gemini's thinking
870
+ * models use a token budget, not an effort tier, so it's dropped, same as
871
+ * Anthropic.
877
872
  */
878
873
  function fromGemini(geminiClient) {
879
874
  return { chat: { completions: { async create(params, options) {
@@ -885,7 +880,13 @@ function fromGemini(geminiClient) {
885
880
  maxOutputTokens: params.max_tokens
886
881
  };
887
882
  if (wantsJson) generationConfig.responseMimeType = "application/json";
888
- if (params.response_format?.type === "json_schema") generationConfig.responseSchema = params.response_format.json_schema.schema;
883
+ if (params.response_format?.type === "json_schema") {
884
+ const { schema, description } = params.response_format.json_schema;
885
+ generationConfig.responseSchema = {
886
+ ...schema,
887
+ ...description ? { description } : {}
888
+ };
889
+ }
889
890
  const response = await geminiClient.generateContent({
890
891
  model: params.model,
891
892
  contents: conversationMessages.map((m) => ({
@@ -942,39 +943,50 @@ function toBedrockContent(blocks) {
942
943
  /**
943
944
  * Wraps a Bedrock Converse-API client so it satisfies the `LLMClient`
944
945
  * interface VernLLM uses for OpenAI/Groq. The Converse API is unified
945
- * across Bedrocks model families (Anthropic, Titan, Llama, Mistral, etc.),
946
+ * across Bedrock's model families (Anthropic, Titan, Llama, Mistral, etc.),
946
947
  * so unlike raw per-model Bedrock invocation, this one adapter works
947
948
  * regardless of which underlying model `modelId` points at, as long as
948
949
  * that model supports Converse (most current-generation ones do)
949
950
  *
950
- * `response_format: json_schema` is mapped to Converses `toolConfig`: a
951
- * single tool is defined from the schema and `toolChoice` forces the model
952
- * to call it, constraining output at generation time rather than merely
953
- * instructing for it via prompt text. Native tool support varies by model
954
- * family (most current-generation ones support it via Converse; check your
955
- * specific `modelId` if a call fails with an unsupported-parameter error).
951
+ * `response_format: json_schema` is mapped to Converse's `toolConfig`: a
952
+ * single tool is defined from the schema, description, and strictness settings,
953
+ * and `toolChoice` forces the model to call it. Provider-constrained schema
954
+ * matching applies only when `strict: true` is forwarded and supported.
955
+ * Native tool support varies by model family; pass
956
+ * `toolUseSupportedModels` to preflight-check it (see
957
+ * `BedrockAdapterOptions`), otherwise a `jsonSchema` call to an
958
+ * unsupported model surfaces Bedrock's raw error unchanged.
959
+ *
956
960
  * `response_format: json_object` (no schema to build a tool from) and
957
961
  * `reasoning_effort` (no Converse equivalent) fall back to a system-prompt
958
962
  * instruction and are dropped respectively.
959
963
  */
960
- function fromBedrock(bedrockClient) {
961
- return { chat: { completions: { async create(params, options) {
964
+ function fromBedrock(bedrockClient, options) {
965
+ const toolUseSupportedModels = options?.toolUseSupportedModels;
966
+ return { chat: { completions: { async create(params, requestOptions) {
962
967
  const systemMessage = params.messages.find((m) => m.role === "system");
963
968
  const conversationMessages = params.messages.filter((m) => m.role === "user" || m.role === "assistant");
964
- const toolName = params.response_format?.type === "json_schema" ? params.response_format.json_schema.name : void 0;
969
+ const jsonSchema = params.response_format?.type === "json_schema" ? params.response_format.json_schema : void 0;
970
+ const toolName = jsonSchema?.name.trim();
971
+ if (jsonSchema && !toolName) throw new LLMError("json_schema.name must not be empty.", "validation");
965
972
  let jsonInstruction;
966
973
  let toolConfig;
967
- if (params.response_format?.type === "json_schema" && toolName) {
968
- const { schema, description } = params.response_format.json_schema;
974
+ if (jsonSchema) {
975
+ const { schema, description, strict } = jsonSchema;
969
976
  toolConfig = {
970
977
  tools: [{ toolSpec: {
971
978
  name: toolName,
972
979
  description,
973
- inputSchema: { json: schema }
980
+ inputSchema: { json: schema },
981
+ strict
974
982
  } }],
975
983
  toolChoice: { tool: { name: toolName } }
976
984
  };
977
985
  } else if (params.response_format?.type === "json_object") jsonInstruction = "Respond with valid JSON only, no prose or markdown fences.";
986
+ if (jsonSchema && toolUseSupportedModels) {
987
+ const isSupported = Array.isArray(toolUseSupportedModels) ? toolUseSupportedModels.includes(params.model) : toolUseSupportedModels(params.model);
988
+ if (!isSupported) throw new LLMError(`Bedrock model "${params.model}" is not listed in toolUseSupportedModels, but jsonSchema structured output requires Converse tool use.`, "validation");
989
+ }
978
990
  const systemParts = [systemMessage?.content, jsonInstruction].filter((s) => Boolean(s));
979
991
  const response = await bedrockClient.converse({
980
992
  modelId: params.model,
@@ -988,7 +1000,7 @@ function fromBedrock(bedrockClient) {
988
1000
  maxTokens: params.max_tokens
989
1001
  },
990
1002
  ...toolConfig ? { toolConfig } : {}
991
- }, options);
1003
+ }, requestOptions);
992
1004
  let text;
993
1005
  if (toolName) {
994
1006
  const toolUseBlock = response.output?.message?.content?.find((block) => block.toolUse?.name === toolName);
@@ -1201,6 +1213,8 @@ exports.CircuitBreaker = CircuitBreaker
1201
1213
  exports.ConsoleLogger = ConsoleLogger
1202
1214
  exports.InMemoryCacheAdapter = InMemoryCacheAdapter
1203
1215
  exports.LLMError = LLMError
1216
+ exports.NormalizedCacheAdapter = NormalizedCacheAdapter
1217
+ exports.TieredCacheAdapter = TieredCacheAdapter
1204
1218
  exports.VernLLM = VernLLM
1205
1219
  exports.from01AI = from01AI
1206
1220
  exports.fromAnthropic = fromAnthropic