vern-llm 1.3.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;
@@ -351,7 +398,7 @@ var VernLLM = class {
351
398
  async retryWithBackoff(fn, requestId, signal) {
352
399
  let lastError;
353
400
  for (let attempt = 0; attempt <= this.maxRetries; attempt++) try {
354
- if (attempt > 0) await this.recoverDelay(requestId, attempt, signal);
401
+ if (attempt > 0) await this.recoverDelay(requestId, attempt, lastError, signal);
355
402
  return await fn();
356
403
  } catch (error) {
357
404
  lastError = error;
@@ -499,12 +546,16 @@ var VernLLM = class {
499
546
  }
500
547
  /**
501
548
  * Waits out the backoff delay for a given retry attempt, logging the
502
- * attempt for observability before the wait begins. Rejects early if
503
- * 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
504
554
  */
505
- async recoverDelay(requestId, attempt, signal) {
506
- const delay = getBackoffDelay(this.baseDelayMs, attempt);
507
- 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)" : ""));
508
559
  await waitForRetry(delay, signal);
509
560
  }
510
561
  /**
@@ -533,26 +584,55 @@ var VernLLM = class {
533
584
  await this.cache.delete(key);
534
585
  }
535
586
  /**
536
- * Thin cache wrapper around caller supplied logic. `params.fn` is expected
537
- * to be a call that itself invokes `this.call(...)` (see `cachedLLMCall`
538
- * below for a convenience wrapper that wires this up automatically),
539
- * `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.
540
594
  */
541
595
  async cachedCall(params) {
542
596
  const cached = await this.cache.get(params.cacheKey);
543
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();
544
622
  try {
545
- await params.reserveUsage?.();
546
- const result = await params.fn();
547
- try {
548
- await this.cache.set(params.cacheKey, result, params.ttl);
549
- } catch (error) {
550
- this.logger.error("[VernLLM] cache write failed", { message: error instanceof Error ? error.message : "unknown" });
551
- }
552
- 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();
553
633
  } catch (error) {
554
634
  try {
555
- await params.refundUsage?.();
635
+ await params.refundUsage?.({ coalesced });
556
636
  } catch (refundError) {
557
637
  this.logger.error("[VernLLM] refundUsage failed", { message: refundError instanceof Error ? refundError.message : "unknown" });
558
638
  }
@@ -580,9 +660,53 @@ var VernLLM = class {
580
660
  }
581
661
  };
582
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
+
583
688
  //#endregion
584
689
  //#region src/adapters/anthropic.ts
585
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
+ /**
586
710
  * Wraps an Anthropic SDK client so it satisfies the same `LLMClient`
587
711
  * interface VernLLM uses for OpenAI/Groq.
588
712
  *
@@ -620,7 +744,7 @@ function fromAnthropic(anthropicClient) {
620
744
  system: system || void 0,
621
745
  messages: conversationMessages.map((m) => ({
622
746
  role: m.role,
623
- content: m.content
747
+ content: Array.isArray(m.content) ? toAnthropicContent(m.content) : m.content
624
748
  })),
625
749
  ...tools ? {
626
750
  tools,
@@ -649,6 +773,18 @@ function fromAnthropic(anthropicClient) {
649
773
  //#endregion
650
774
  //#region src/adapters/gemini.ts
651
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
+ /**
652
788
  * Wraps a Gemini client so it satisfies the `LLMClient` interface VernLLM
653
789
  * uses for OpenAI/Groq. Geminis shape differs on nearly every axis: a
654
790
  * `contents` array instead of `messages`, a separate `systemInstruction`
@@ -674,7 +810,7 @@ function fromGemini(geminiClient) {
674
810
  model: params.model,
675
811
  contents: conversationMessages.map((m) => ({
676
812
  role: m.role === "assistant" ? "model" : "user",
677
- parts: [{ text: m.content }]
813
+ parts: Array.isArray(m.content) ? toGeminiParts(m.content) : [{ text: m.content }]
678
814
  })),
679
815
  systemInstruction: systemMessage ? { parts: [{ text: systemMessage.content }] } : void 0,
680
816
  generationConfig
@@ -693,6 +829,36 @@ function fromGemini(geminiClient) {
693
829
 
694
830
  //#endregion
695
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
+ }
696
862
  /**
697
863
  * Wraps a Bedrock Converse-API client so it satisfies the `LLMClient`
698
864
  * interface VernLLM uses for OpenAI/Groq. The Converse API is unified
@@ -734,7 +900,7 @@ function fromBedrock(bedrockClient) {
734
900
  modelId: params.model,
735
901
  messages: conversationMessages.map((m) => ({
736
902
  role: m.role,
737
- content: [{ text: m.content }]
903
+ content: Array.isArray(m.content) ? toBedrockContent(m.content) : [{ text: m.content }]
738
904
  })),
739
905
  system: systemParts.length ? systemParts.map((text$1) => ({ text: text$1 })) : void 0,
740
906
  inferenceConfig: {
@@ -776,19 +942,23 @@ function fromFetch(config) {
776
942
  return { chat: { completions: { async create(params, options) {
777
943
  const url = typeof config.url === "function" ? config.url(params) : config.url;
778
944
  const headers = typeof config.headers === "function" ? await config.headers() : config.headers;
779
- const res = await fetch(url, {
780
- method: config.method ?? "POST",
781
- 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 ? {
782
951
  "Content-Type": "application/json",
783
952
  ...headers
784
- },
785
- body: JSON.stringify(config.mapRequest(params)),
953
+ } : { ...headers },
954
+ ...supportsBody ? { body: JSON.stringify(config.mapRequest(params)) } : {},
786
955
  signal: options.signal
787
956
  });
788
957
  if (!res.ok) {
789
958
  const body = await res.text().catch(() => "");
790
959
  const err = new Error(`Fetch adapter request failed (${res.status}): ${body.slice(0, 500)}`);
791
960
  err.status = res.status;
961
+ err.headers = res.headers;
792
962
  throw err;
793
963
  }
794
964
  const json = await res.json();
@@ -807,13 +977,36 @@ function fromFetch(config) {
807
977
  //#endregion
808
978
  //#region src/adapters/openaiCompatible.ts
809
979
  /**
810
- * Passthrough adapter for any SDK/client whose `chat.completions.create`
811
- * already matches the OpenAI wire format 1:1 : this covers most hosted
812
- * inference providers, since "OpenAI-compatible" is a de facto standard for
813
- * chat completion APIs. No transformation happens here, this exists purely
814
- * so call sites read clearly (`fromMistral(client)` vs handing a Mistral
815
- * client to something typed for OpenAI) and so a real transformation could
816
- * 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.
817
1010
  *
818
1011
  * Not every SDKs own TypeScript types line up exactly with `LLMClient`
819
1012
  * (extra fields, stricter unions, etc.), so this takes `unknown` and casts:
@@ -821,7 +1014,17 @@ function fromFetch(config) {
821
1014
  * receives over the wire, not the SDKs TS types.
822
1015
  */
823
1016
  function fromOpenAICompatible(client) {
824
- 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
+ } } } };
825
1028
  }
826
1029
  /** Groqs SDK matches the OpenAI wire format */
827
1030
  const fromGroq = fromOpenAICompatible;