vern-llm 1.2.0 → 1.4.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
@@ -52,26 +52,43 @@ var CircuitBreaker = class {
52
52
  openedAt = 0;
53
53
  threshold;
54
54
  cooldownMs;
55
+ /**
56
+ * True while a single half-open trial call is in flight. Guards against
57
+ * multiple concurrent callers all treating themselves as "the" trial once
58
+ * the cooldown elapses
59
+ */
60
+ trialInFlight = false;
55
61
  constructor(options = {}) {
56
62
  this.threshold = options.threshold ?? 5;
57
63
  this.cooldownMs = options.cooldownMs ?? 3e4;
58
64
  }
59
- /** Throws if the circuit is open and the cooldown hasnt elapsed */
65
+ /**
66
+ * Throws if the circuit is open and the cooldown hasn't elapsed, or if
67
+ * the circuit is half-open and a trial call is already in flight.
68
+ * Otherwise, if the circuit just became eligible for a trial (cooldown
69
+ * elapsed, or half-open with no trial currently running), this call
70
+ * becomes that trial
71
+ */
60
72
  assertClosed() {
61
- if (this.state !== "open") return;
62
- const elapsed = Date.now() - this.openedAt;
63
- if (elapsed >= this.cooldownMs) {
73
+ if (this.state === "closed") return;
74
+ if (this.state === "open") {
75
+ const elapsed = Date.now() - this.openedAt;
76
+ 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");
64
77
  this.state = "half-open";
78
+ this.trialInFlight = true;
65
79
  return;
66
80
  }
67
- 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");
81
+ if (this.trialInFlight) throw new LLMError("Circuit half-open. A trial request is already in flight. Try again shortly.", "circuit_open");
82
+ this.trialInFlight = true;
68
83
  }
69
84
  recordSuccess() {
70
85
  this.consecutiveFailures = 0;
71
86
  this.state = "closed";
87
+ this.trialInFlight = false;
72
88
  }
73
89
  recordFailure() {
74
90
  this.consecutiveFailures += 1;
91
+ this.trialInFlight = false;
75
92
  if (this.state === "half-open") {
76
93
  this.state = "open";
77
94
  this.openedAt = Date.now();
@@ -136,11 +153,40 @@ async function withTimeout(fn, timeoutMs, externalSignal) {
136
153
  }
137
154
  }
138
155
  /**
156
+ * Default cap (ms) for both exponential backoff and honored Retry-After
157
+ * values, so a misbehaving/adversarial Retry-After can't stall a caller
158
+ * indefinitely
159
+ */
160
+ const DEFAULT_MAX_DELAY_MS = 1e4;
161
+ /**
162
+ * Looks inside an unknown error value for a Retry-After header and
163
+ * converts it to milliseconds. Checks `.headers` first (fetch-style,
164
+ * Headers-like with `.get()`), then `.response.headers` (axios-style,
165
+ * plain object) since different client libraries surface headers
166
+ * differently. Supports both the delta-seconds form ("30") and the
167
+ * HTTP-date form ("Wed, 21 Oct 2015 07:28:00 GMT"). The result is capped
168
+ * at maxDelayMs. Returns undefined when no usable Retry-After is present
169
+ */
170
+ function extractRetryAfterMs(err, maxDelayMs = DEFAULT_MAX_DELAY_MS) {
171
+ if (!err || typeof err !== "object") return void 0;
172
+ const error = err;
173
+ const headers = error.headers ?? error.response?.headers;
174
+ if (!headers || typeof headers !== "object") return void 0;
175
+ const getter = headers;
176
+ const raw = typeof getter.get === "function" ? getter.get("Retry-After") : Object.entries(headers).find(([name]) => name.toLowerCase() === "retry-after")?.at(1);
177
+ if (typeof raw !== "string" || raw.trim() === "") return void 0;
178
+ const trimmed = raw.trim();
179
+ if (/^\d+$/.test(trimmed)) return Math.max(0, Math.min(Number(trimmed) * 1e3, maxDelayMs));
180
+ const dateMs = Date.parse(trimmed);
181
+ if (!Number.isNaN(dateMs)) return Math.max(0, Math.min(dateMs - Date.now(), maxDelayMs));
182
+ return void 0;
183
+ }
184
+ /**
139
185
  * Exponential backoff with jitter, capped at maxDelayMs.
140
186
  * Jitter avoids thundering-herd retries when many callers back off in lockstep,
141
187
  * the cap prevents unbounded delays when maxRetries is high
142
188
  */
143
- function getBackoffDelay(baseDelayMs, attempt, maxDelayMs = 1e4) {
189
+ function getBackoffDelay(baseDelayMs, attempt, maxDelayMs = DEFAULT_MAX_DELAY_MS) {
144
190
  const exp = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
145
191
  return exp / 2 + Math.random() * (exp / 2);
146
192
  }
@@ -258,6 +304,7 @@ var VernLLM = class {
258
304
  defaultMaxTokens;
259
305
  cache;
260
306
  nonRetryableStatus;
307
+ inFlight = new Map();
261
308
  parseJson;
262
309
  onUsage;
263
310
  logger;
@@ -279,7 +326,9 @@ var VernLLM = class {
279
326
  this.nonRetryableStatus = options.nonRetryableStatus ?? [
280
327
  400,
281
328
  401,
282
- 403
329
+ 403,
330
+ 404,
331
+ 422
283
332
  ];
284
333
  this.parseJson = options.parseJson ?? defaultParseJson;
285
334
  this.onUsage = options.onUsage;
@@ -300,11 +349,11 @@ var VernLLM = class {
300
349
  }
301
350
  /**
302
351
  * Returns the caller supplied logger, or a console-based logger whose
303
- * debug output is gated by the `debug` option (defaulting to on
304
- * outside production)
352
+ * debug output is gated by the `debug` option (defaulting to off,
353
+ * so response content isn't unintentionally written to logs)
305
354
  */
306
355
  resolveLogger(options) {
307
- return options.logger ?? new ConsoleLogger(options.debug ?? process.env.NODE_ENV !== "production");
356
+ return options.logger ?? new ConsoleLogger(options.debug ?? false);
308
357
  }
309
358
  /**
310
359
  * Builds a circuit breaker if `circuitBreaker` is truthy on the
@@ -349,7 +398,7 @@ var VernLLM = class {
349
398
  async retryWithBackoff(fn, requestId, signal) {
350
399
  let lastError;
351
400
  for (let attempt = 0; attempt <= this.maxRetries; attempt++) try {
352
- if (attempt > 0) await this.recoverDelay(requestId, attempt, signal);
401
+ if (attempt > 0) await this.recoverDelay(requestId, attempt, lastError, signal);
353
402
  return await fn();
354
403
  } catch (error) {
355
404
  lastError = error;
@@ -497,12 +546,16 @@ var VernLLM = class {
497
546
  }
498
547
  /**
499
548
  * Waits out the backoff delay for a given retry attempt, logging the
500
- * attempt for observability before the wait begins. Rejects early if
501
- * the signal aborts during the wait
549
+ * attempt for observability before the wait begins. Honors a
550
+ * Retry-After header on the failed attempt's error when present
551
+ * (capped at the same maxDelayMs as backoff), otherwise falls back to
552
+ * exponential backoff exactly as before. Rejects early if the signal
553
+ * aborts during the wait
502
554
  */
503
- async recoverDelay(requestId, attempt, signal) {
504
- const delay = getBackoffDelay(this.baseDelayMs, attempt);
505
- this.logger.warn(`[vern:${requestId}] recovery attempt ${attempt}/${this.maxRetries}, waiting ${delay}ms`);
555
+ async recoverDelay(requestId, attempt, error, signal) {
556
+ const retryAfterMs = extractRetryAfterMs(error);
557
+ const delay = retryAfterMs ?? getBackoffDelay(this.baseDelayMs, attempt);
558
+ this.logger.warn(`[vern:${requestId}] recovery attempt ${attempt}/${this.maxRetries}, waiting ${delay}ms` + (retryAfterMs !== void 0 ? " (honoring Retry-After)" : ""));
506
559
  await waitForRetry(delay, signal);
507
560
  }
508
561
  /**
@@ -531,26 +584,55 @@ var VernLLM = class {
531
584
  await this.cache.delete(key);
532
585
  }
533
586
  /**
534
- * Thin cache wrapper around caller supplied logic. `params.fn` is expected
535
- * to be a call that itself invokes `this.call(...)` (see `cachedLLMCall`
536
- * below for a convenience wrapper that wires this up automatically),
537
- * `cachedCall` does not itself apply retry/timeout policy.
587
+ * Cache wrapper around caller-supplied logic. `params.fn` should invoke
588
+ * `this.call(...)` (see `cachedLLMCall`); retry/timeout handling is left
589
+ * to the caller.
590
+ *
591
+ * Concurrent misses for the same `cacheKey` share a single in-flight call,
592
+ * avoiding cache stampedes. Each caller still receives its own
593
+ * `reserveUsage`/`refundUsage` callbacks with coalescing metadata.
538
594
  */
539
595
  async cachedCall(params) {
540
596
  const cached = await this.cache.get(params.cacheKey);
541
597
  if (cached.hit) return cached.value;
598
+ const existing = this.inFlight.get(params.cacheKey);
599
+ 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);
606
+ }
607
+ /** Starts the shared fn() call for a cache miss, reserving usage first, and registers it in the in-flight map until it settles */
608
+ registerTrigger(params, coalesced) {
609
+ const resultPromise = (async () => {
610
+ await params.reserveUsage?.({ coalesced });
611
+ return this.runAndCache(params);
612
+ })();
613
+ this.inFlight.set(params.cacheKey, resultPromise);
614
+ resultPromise.catch(() => {}).finally(() => {
615
+ this.inFlight.delete(params.cacheKey);
616
+ });
617
+ return resultPromise;
618
+ }
619
+ /** Runs `fn` and writes its result to the cache. Only ever called once per cacheKey per in-flight window, from registerTrigger */
620
+ async runAndCache(params) {
621
+ const result = await params.fn();
542
622
  try {
543
- await params.reserveUsage?.();
544
- const result = await params.fn();
545
- try {
546
- await this.cache.set(params.cacheKey, result, params.ttl);
547
- } catch (error) {
548
- this.logger.error("[VernLLM] cache write failed", { message: error instanceof Error ? error.message : "unknown" });
549
- }
550
- return result;
623
+ await this.cache.set(params.cacheKey, result, params.ttl);
624
+ } catch (error) {
625
+ this.logger.error("[VernLLM] cache write failed", { message: error instanceof Error ? error.message : "unknown" });
626
+ }
627
+ return result;
628
+ }
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) {
631
+ try {
632
+ return await run();
551
633
  } catch (error) {
552
634
  try {
553
- await params.refundUsage?.();
635
+ await params.refundUsage?.({ coalesced });
554
636
  } catch (refundError) {
555
637
  this.logger.error("[VernLLM] refundUsage failed", { message: refundError instanceof Error ? refundError.message : "unknown" });
556
638
  }
@@ -578,9 +660,53 @@ var VernLLM = class {
578
660
  }
579
661
  };
580
662
 
663
+ //#endregion
664
+ //#region src/internal/imageFormat.ts
665
+ /**
666
+ * MIME types accepted for `ImageBlock.mimeType` across all adapters. This is
667
+ * the intersection of what Anthropic, Gemini, OpenAI-compatible, and Bedrock
668
+ * Converse all natively support, so a `ContentBlock[]` that validates for
669
+ * one provider validates for all of them.
670
+ */
671
+ const SUPPORTED_IMAGE_MIME_TYPES = [
672
+ "image/png",
673
+ "image/jpeg",
674
+ "image/gif",
675
+ "image/webp"
676
+ ];
677
+ /**
678
+ * Validates an `ImageBlock.mimeType` against the shared supported set.
679
+ * Throws a non-retryable `LLMError('validation')`, since an unsupported
680
+ * mimeType is a permanent failure, retrying the same input can't fix it,
681
+ * the same way a schema-validation or JSON-parse failure isn't retried.
682
+ */
683
+ function assertSupportedImageMimeType(mimeType) {
684
+ if (SUPPORTED_IMAGE_MIME_TYPES.includes(mimeType)) return mimeType;
685
+ throw new LLMError(`Unsupported image mimeType "${mimeType}": expected one of ${SUPPORTED_IMAGE_MIME_TYPES.join(", ")}`, "validation");
686
+ }
687
+
581
688
  //#endregion
582
689
  //#region src/adapters/anthropic.ts
583
690
  /**
691
+ * Translates a VernLLM `ContentBlock[]` (our provider-agnostic multimodal
692
+ * shape) into Anthropic's native content-block array: text blocks pass
693
+ * through as-is, image blocks become `{ type: 'image', source: { type:
694
+ * 'base64', media_type, data } }`.
695
+ */
696
+ function toAnthropicContent(blocks) {
697
+ return blocks.map((block) => block.type === "image" ? {
698
+ type: "image",
699
+ source: {
700
+ type: "base64",
701
+ media_type: assertSupportedImageMimeType(block.mimeType),
702
+ data: block.data
703
+ }
704
+ } : {
705
+ type: "text",
706
+ text: block.text
707
+ });
708
+ }
709
+ /**
584
710
  * Wraps an Anthropic SDK client so it satisfies the same `LLMClient`
585
711
  * interface VernLLM uses for OpenAI/Groq.
586
712
  *
@@ -618,7 +744,7 @@ function fromAnthropic(anthropicClient) {
618
744
  system: system || void 0,
619
745
  messages: conversationMessages.map((m) => ({
620
746
  role: m.role,
621
- content: m.content
747
+ content: Array.isArray(m.content) ? toAnthropicContent(m.content) : m.content
622
748
  })),
623
749
  ...tools ? {
624
750
  tools,
@@ -647,6 +773,18 @@ function fromAnthropic(anthropicClient) {
647
773
  //#endregion
648
774
  //#region src/adapters/gemini.ts
649
775
  /**
776
+ * Translates a VernLLM `ContentBlock[]` into Gemini's native `parts` array:
777
+ * text blocks become `{ text }`, image blocks become inline data parts
778
+ * (`{ inlineData: { mimeType, data } }`), Geminis shape for embedding raw
779
+ * base64 image bytes directly in the request.
780
+ */
781
+ function toGeminiParts(blocks) {
782
+ return blocks.map((block) => block.type === "image" ? { inlineData: {
783
+ mimeType: assertSupportedImageMimeType(block.mimeType),
784
+ data: block.data
785
+ } } : { text: block.text });
786
+ }
787
+ /**
650
788
  * Wraps a Gemini client so it satisfies the `LLMClient` interface VernLLM
651
789
  * uses for OpenAI/Groq. Geminis shape differs on nearly every axis: a
652
790
  * `contents` array instead of `messages`, a separate `systemInstruction`
@@ -672,7 +810,7 @@ function fromGemini(geminiClient) {
672
810
  model: params.model,
673
811
  contents: conversationMessages.map((m) => ({
674
812
  role: m.role === "assistant" ? "model" : "user",
675
- parts: [{ text: m.content }]
813
+ parts: Array.isArray(m.content) ? toGeminiParts(m.content) : [{ text: m.content }]
676
814
  })),
677
815
  systemInstruction: systemMessage ? { parts: [{ text: systemMessage.content }] } : void 0,
678
816
  generationConfig
@@ -691,6 +829,36 @@ function fromGemini(geminiClient) {
691
829
 
692
830
  //#endregion
693
831
  //#region src/adapters/bedrock.ts
832
+ /** Maps a `ContentBlock` image MIME type, already validated, to Converse's `format` enum. */
833
+ function toBedrockImageFormat(mimeType) {
834
+ switch (assertSupportedImageMimeType(mimeType)) {
835
+ case "image/png": return "png";
836
+ case "image/jpeg": return "jpeg";
837
+ case "image/gif": return "gif";
838
+ case "image/webp": return "webp";
839
+ }
840
+ }
841
+ /**
842
+ * Decodes base64 image data into the raw `Uint8Array` bytes Converse's
843
+ * `image.source.bytes` expects (unlike Anthropic/Gemini/OpenAI, which all
844
+ * take base64 strings directly). Uses `Buffer`, since this adapter, like
845
+ * the rest of the package, targets Node.
846
+ */
847
+ function decodeBase64(data) {
848
+ return new Uint8Array(Buffer.from(data, "base64"));
849
+ }
850
+ /**
851
+ * Translates a VernLLM `ContentBlock[]` into Converse's native content-block
852
+ * array: text blocks pass through as `{ text }`, image blocks become
853
+ * `{ image: { format, source: { bytes } } }` with the base64 payload decoded
854
+ * to raw bytes, since Converse doesn't accept base64 strings directly.
855
+ */
856
+ function toBedrockContent(blocks) {
857
+ return blocks.map((block) => block.type === "image" ? { image: {
858
+ format: toBedrockImageFormat(block.mimeType),
859
+ source: { bytes: decodeBase64(block.data) }
860
+ } } : { text: block.text });
861
+ }
694
862
  /**
695
863
  * Wraps a Bedrock Converse-API client so it satisfies the `LLMClient`
696
864
  * interface VernLLM uses for OpenAI/Groq. The Converse API is unified
@@ -732,7 +900,7 @@ function fromBedrock(bedrockClient) {
732
900
  modelId: params.model,
733
901
  messages: conversationMessages.map((m) => ({
734
902
  role: m.role,
735
- content: [{ text: m.content }]
903
+ content: Array.isArray(m.content) ? toBedrockContent(m.content) : [{ text: m.content }]
736
904
  })),
737
905
  system: systemParts.length ? systemParts.map((text$1) => ({ text: text$1 })) : void 0,
738
906
  inferenceConfig: {
@@ -774,19 +942,23 @@ function fromFetch(config) {
774
942
  return { chat: { completions: { async create(params, options) {
775
943
  const url = typeof config.url === "function" ? config.url(params) : config.url;
776
944
  const headers = typeof config.headers === "function" ? await config.headers() : config.headers;
777
- const res = await fetch(url, {
778
- method: config.method ?? "POST",
779
- headers: {
945
+ const method = config.method ?? "POST";
946
+ const request = config.request ?? fetch;
947
+ const supportsBody = !["GET", "HEAD"].includes(method.toUpperCase());
948
+ const res = await request(url, {
949
+ method,
950
+ headers: supportsBody ? {
780
951
  "Content-Type": "application/json",
781
952
  ...headers
782
- },
783
- body: JSON.stringify(config.mapRequest(params)),
953
+ } : { ...headers },
954
+ ...supportsBody ? { body: JSON.stringify(config.mapRequest(params)) } : {},
784
955
  signal: options.signal
785
956
  });
786
957
  if (!res.ok) {
787
958
  const body = await res.text().catch(() => "");
788
959
  const err = new Error(`Fetch adapter request failed (${res.status}): ${body.slice(0, 500)}`);
789
960
  err.status = res.status;
961
+ err.headers = res.headers;
790
962
  throw err;
791
963
  }
792
964
  const json = await res.json();
@@ -805,13 +977,36 @@ function fromFetch(config) {
805
977
  //#endregion
806
978
  //#region src/adapters/openaiCompatible.ts
807
979
  /**
808
- * Passthrough adapter for any SDK/client whose `chat.completions.create`
809
- * already matches the OpenAI wire format 1:1 : this covers most hosted
810
- * inference providers, since "OpenAI-compatible" is a de facto standard for
811
- * chat completion APIs. No transformation happens here, this exists purely
812
- * so call sites read clearly (`fromMistral(client)` vs handing a Mistral
813
- * client to something typed for OpenAI) and so a real transformation could
814
- * be added later, per-provider, without a breaking change.
980
+ * Translates a VernLLM `ContentBlock[]` into OpenAI's wire-level content
981
+ * array. Text blocks become `{ type: 'text', text }`; image blocks become
982
+ * `{ type: 'image_url', image_url: { url } }` with the base64 payload
983
+ * inlined as a `data:` URL, since our `ContentBlock` shape (`{ type:
984
+ * 'image', data, mimeType }`) is provider-agnostic and doesn't itself match
985
+ * OpenAI's wire format.
986
+ */
987
+ function toOpenAIContent(blocks) {
988
+ return blocks.map((block) => block.type === "image" ? {
989
+ type: "image_url",
990
+ image_url: { url: `data:${assertSupportedImageMimeType(block.mimeType)};base64,${block.data}` }
991
+ } : {
992
+ type: "text",
993
+ text: block.text
994
+ });
995
+ }
996
+ /**
997
+ * Adapter for any SDK/client whose `chat.completions.create` already
998
+ * matches the OpenAI wire format: this covers most hosted inference
999
+ * providers, since "OpenAI-compatible" is a de facto standard for chat
1000
+ * completion APIs. Almost everything passes straight through untouched,
1001
+ * this exists purely so call sites read clearly (`fromMistral(client)` vs
1002
+ * handing a Mistral client to something typed for OpenAI) and so a real
1003
+ * transformation could be added later, per-provider, without a breaking
1004
+ * change.
1005
+ *
1006
+ * The one thing that isn't a pure passthrough: a `ContentBlock[]`
1007
+ * `userContent` is translated into OpenAI's native `image_url` content-part
1008
+ * shape, since VernLLM's `ContentBlock` is intentionally provider-agnostic
1009
+ * rather than a copy of any one provider's wire format.
815
1010
  *
816
1011
  * Not every SDKs own TypeScript types line up exactly with `LLMClient`
817
1012
  * (extra fields, stricter unions, etc.), so this takes `unknown` and casts:
@@ -819,7 +1014,17 @@ function fromFetch(config) {
819
1014
  * receives over the wire, not the SDKs TS types.
820
1015
  */
821
1016
  function fromOpenAICompatible(client) {
822
- return client;
1017
+ const raw = client;
1018
+ return { chat: { completions: { async create(params, options) {
1019
+ const messages = params.messages.map((m) => m.role === "user" && Array.isArray(m.content) ? {
1020
+ ...m,
1021
+ content: toOpenAIContent(m.content)
1022
+ } : m);
1023
+ return raw.chat.completions.create({
1024
+ ...params,
1025
+ messages
1026
+ }, options);
1027
+ } } } };
823
1028
  }
824
1029
  /** Groqs SDK matches the OpenAI wire format */
825
1030
  const fromGroq = fromOpenAICompatible;