vern-llm 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,14 +20,16 @@
20
20
  <img src="https://img.shields.io/badge/TypeScript-strict-3178C6?logo=typescript&logoColor=white" alt="TypeScript" />
21
21
  </p>
22
22
 
23
- Production-ready resilience for LLM calls: retries, timeouts, caching, and circuit breaking behind one typed interface, with adapters for OpenAI-compatible APIs (OpenAI, Groq, and more), Anthropic, Gemini, and Bedrock.
23
+ <p align="center">Production-ready resilience for LLM calls</p>
24
+
25
+ Retries, timeouts, caching, and circuit breaking behind one typed interface, with adapters for OpenAI-compatible APIs (OpenAI, Groq, and more), Anthropic, Gemini, and Bedrock.
24
26
 
25
27
  **Full documentation: [vernllm.vercel.app](https://vernllm.vercel.app)** — installation, structured output, caching, circuit breaker, every adapter, and the complete API reference all live there and are kept up to date. This README is a quick pitch, not the manual.
26
28
 
27
29
  ## Install
28
30
 
29
31
  ```bash
30
- pnpm add vern-llm openai
32
+ pnpm add vern-llm
31
33
  ```
32
34
 
33
35
  ## Quick start
@@ -62,19 +64,6 @@ const result = await llm.call({
62
64
 
63
65
  See the [docs](https://vernllm.vercel.app) for adapter setup, caching, the circuit breaker, and structured output in depth.
64
66
 
65
- ## Development
66
-
67
- ```bash
68
- pnpm install
69
- pnpm run build # tsdown → dist (ESM + CJS + types)
70
- pnpm run typecheck # tsc --noEmit on src, since tsdown doesn't fully type-check
71
- pnpm run test # vitest run
72
- pnpm run test:coverage # vitest run --coverage (v8 provider)
73
- pnpm run changeset # record a change for the next release
74
- ```
75
-
76
- Tests live in `tests/`, mirroring `src/`, and cover retry/backoff/timeout/abort/schema/model-override/usage behavior, the circuit breaker (unit + integration), caching, the injectable logger, and every provider adapter's request/response translation against a fake client — no real API calls anywhere in the suite.
77
-
78
67
  ## License
79
68
 
80
69
  [MIT](https://github.com/LakBud/vernLLM/blob/main/LICENSE.md) © LakBud
package/dist/index.cjs CHANGED
@@ -26,11 +26,13 @@ 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) {
29
+ constructor(message, type, status, issues, cause, retryAfterMs) {
30
30
  super(message);
31
31
  this.type = type;
32
32
  this.status = status;
33
33
  this.issues = issues;
34
+ this.cause = cause;
35
+ this.retryAfterMs = retryAfterMs;
34
36
  this.name = "LLMError";
35
37
  }
36
38
  };
@@ -126,6 +128,32 @@ function extractStatus(err) {
126
128
  if (typeof error.statusCode === "number") return error.statusCode;
127
129
  return void 0;
128
130
  }
131
+ function formatSafely(value) {
132
+ try {
133
+ return JSON.stringify(value, null, 2) ?? String(value);
134
+ } catch {
135
+ try {
136
+ return String(value);
137
+ } catch {
138
+ return "[unprintable error]";
139
+ }
140
+ }
141
+ }
142
+ /**
143
+ * Looks inside an unknown thrown value and pulls out a human-readable
144
+ * description of it. Checks the `error` field first (the provider's raw
145
+ * rejection body, JSON-stringified if possible) then falls back to the
146
+ * message` field. Always returns a safe string, even when the thrown value
147
+ * has hostile properties or cannot be serialized normally.
148
+ */
149
+ function describeError(err) {
150
+ if (err && typeof err === "object") try {
151
+ const error = err;
152
+ if (error.error !== void 0) return formatSafely(error.error);
153
+ if (typeof error.message === "string") return error.message;
154
+ } catch {}
155
+ return formatSafely(err);
156
+ }
129
157
  /**
130
158
  * Runs an async function and cancels it if it takes longer than the given
131
159
  * timeout. Creates an internal abort controller that fires after the
@@ -382,13 +410,16 @@ var VernLLM = class {
382
410
  this.breaker?.assertClosed();
383
411
  if (params.signal?.aborted) throw new LLMError("LLM request aborted", "aborted");
384
412
  const requestId = params.requestId ?? (0, crypto.randomUUID)();
385
- try {
386
- const result = await this.retryWithBackoff(() => this.executeCall(params, requestId), requestId, params.signal);
387
- return result;
388
- } catch (error) {
389
- this.breaker?.recordFailure();
390
- throw this.normalizeError(error, params.signal);
391
- }
413
+ return this.withReservedUsage(params, false, async () => {
414
+ try {
415
+ return await this.retryWithBackoff(() => this.executeCall(params, requestId), requestId, params.signal);
416
+ } catch (error) {
417
+ const normalized = this.normalizeError(error, params.signal);
418
+ if (normalized.type !== "validation" && normalized.type !== "parse" && normalized.type !== "aborted") this.breaker?.recordFailure();
419
+ this.logger.debug(`[vern:${requestId}] error:\n${describeError(error)}`);
420
+ throw normalized;
421
+ }
422
+ }, params.signal);
392
423
  }
393
424
  /**
394
425
  * Runs `fn`, retrying with backoff according to `shouldRetry` policy
@@ -416,8 +447,9 @@ var VernLLM = class {
416
447
  if (signal?.aborted) return new LLMError("LLM request aborted", "aborted");
417
448
  if (error instanceof LLMError) return error;
418
449
  const status = extractStatus(error);
419
- if (status !== void 0) return new LLMError("LLM request failed", "api", status);
420
- return new LLMError("LLM request failed", "unknown");
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);
421
453
  }
422
454
  /**
423
455
  * Performs a single attempt: builds the request, dispatches it with a
@@ -432,9 +464,13 @@ var VernLLM = class {
432
464
  if (!content) throw new LLMError("Empty LLM response", "api");
433
465
  this.logger.debug(`[vern:${requestId}] output:\n${content.slice(0, 800)}`);
434
466
  this.recordUsage(response, requestId, model);
467
+ if (!useJson) {
468
+ this.breaker?.recordSuccess();
469
+ return content;
470
+ }
471
+ const result = this.parseAndValidate(content, params.schema);
435
472
  this.breaker?.recordSuccess();
436
- if (!useJson) return content;
437
- return this.parseAndValidate(content, params.schema);
473
+ return result;
438
474
  }
439
475
  /**
440
476
  * Anthropic and Gemini both require strict user/assistant alternation
@@ -462,6 +498,7 @@ var VernLLM = class {
462
498
  buildRequestPayload(params) {
463
499
  const { systemPrompt, userContent, history = [], temperature = .2, jsonMode = true, maxTokens = this.defaultMaxTokens, model = this.model, reasoningEffort, jsonSchema } = params;
464
500
  const useJson = jsonMode || Boolean(jsonSchema);
501
+ 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");
465
502
  const responseFormat = this.buildResponseFormat(jsonSchema, useJson);
466
503
  this.validateHistory(history);
467
504
  const request = {
@@ -514,16 +551,24 @@ var VernLLM = class {
514
551
  * Reports token usage to the caller supplied onUsage callback, when
515
552
  * both a callback was configured and the provider actually returned
516
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.
517
558
  */
518
559
  recordUsage(response, requestId, model) {
519
560
  if (!response.usage || !this.onUsage) return;
520
- this.onUsage({
521
- promptTokens: response.usage.prompt_tokens ?? 0,
522
- completionTokens: response.usage.completion_tokens ?? 0,
523
- totalTokens: response.usage.total_tokens ?? 0,
524
- requestId,
525
- model
526
- });
561
+ try {
562
+ this.onUsage({
563
+ promptTokens: response.usage.prompt_tokens ?? 0,
564
+ completionTokens: response.usage.completion_tokens ?? 0,
565
+ totalTokens: response.usage.total_tokens ?? 0,
566
+ requestId,
567
+ model
568
+ });
569
+ } catch (error) {
570
+ this.logger.error("[VernLLM] onUsage failed", { message: error instanceof Error ? error.message : "unknown" });
571
+ }
527
572
  }
528
573
  /**
529
574
  * Parses the raw response content as JSON and, when a schema is
@@ -597,19 +642,12 @@ var VernLLM = class {
597
642
  if (cached.hit) return cached.value;
598
643
  const existing = this.inFlight.get(params.cacheKey);
599
644
  const coalesced = existing !== void 0;
600
- const resultPromise = existing ?? this.registerTrigger(params, coalesced);
601
- if (coalesced) return this.withRefundOnFailure(params, coalesced, async () => {
602
- await params.reserveUsage?.({ coalesced });
603
- return resultPromise;
604
- });
605
- return this.withRefundOnFailure(params, coalesced, () => resultPromise);
645
+ if (coalesced) return this.withReservedUsage(params, coalesced, () => existing, params.signal);
646
+ return this.registerTrigger(params, coalesced);
606
647
  }
607
648
  /** Starts the shared fn() call for a cache miss, reserving usage first, and registers it in the in-flight map until it settles */
608
649
  registerTrigger(params, coalesced) {
609
- const resultPromise = (async () => {
610
- await params.reserveUsage?.({ coalesced });
611
- return this.runAndCache(params);
612
- })();
650
+ const resultPromise = this.withReservedUsage(params, coalesced, () => this.runAndCache(params), params.signal);
613
651
  this.inFlight.set(params.cacheKey, resultPromise);
614
652
  resultPromise.catch(() => {}).finally(() => {
615
653
  this.inFlight.delete(params.cacheKey);
@@ -626,13 +664,48 @@ var VernLLM = class {
626
664
  }
627
665
  return result;
628
666
  }
629
- /** Awaits `run`, calling this caller's own refundUsage (tagged with whether it was coalesced) if it rejects, then rethrows the original error */
630
- async withRefundOnFailure(params, coalesced, run) {
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;
631
679
  try {
632
- return await run();
680
+ if (params.reserveUsage) {
681
+ await params.reserveUsage({
682
+ coalesced,
683
+ signal
684
+ });
685
+ reserved = true;
686
+ }
633
687
  } catch (error) {
634
- try {
635
- await params.refundUsage?.({ coalesced });
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
+ });
636
709
  } catch (refundError) {
637
710
  this.logger.error("[VernLLM] refundUsage failed", { message: refundError instanceof Error ? refundError.message : "unknown" });
638
711
  }
@@ -642,13 +715,20 @@ var VernLLM = class {
642
715
  /**
643
716
  * Convenience wrapper composing `call` + `cachedCall`, so cached LLM calls
644
717
  * automatically get retry/timeout/circuit-breaker behavior without callers
645
- * having to remember to wire `fn: () => this.call(...)` themselves
718
+ * having to remember to wire `fn: () => this.call(...)` themselves.
719
+ *
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()).
646
724
  */
647
725
  async cachedLLMCall(params) {
648
726
  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.");
649
729
  return this.cachedCall({
650
730
  ...cacheParams,
651
- fn: () => this.call(callParams)
731
+ fn: () => this.call(restCallParams)
652
732
  });
653
733
  }
654
734
  /**