vern-llm 1.3.0 → 1.5.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
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
  };
@@ -52,26 +54,43 @@ var CircuitBreaker = class {
52
54
  openedAt = 0;
53
55
  threshold;
54
56
  cooldownMs;
57
+ /**
58
+ * True while a single half-open trial call is in flight. Guards against
59
+ * multiple concurrent callers all treating themselves as "the" trial once
60
+ * the cooldown elapses
61
+ */
62
+ trialInFlight = false;
55
63
  constructor(options = {}) {
56
64
  this.threshold = options.threshold ?? 5;
57
65
  this.cooldownMs = options.cooldownMs ?? 3e4;
58
66
  }
59
- /** Throws if the circuit is open and the cooldown hasnt elapsed */
67
+ /**
68
+ * Throws if the circuit is open and the cooldown hasn't elapsed, or if
69
+ * the circuit is half-open and a trial call is already in flight.
70
+ * Otherwise, if the circuit just became eligible for a trial (cooldown
71
+ * elapsed, or half-open with no trial currently running), this call
72
+ * becomes that trial
73
+ */
60
74
  assertClosed() {
61
- if (this.state !== "open") return;
62
- const elapsed = Date.now() - this.openedAt;
63
- if (elapsed >= this.cooldownMs) {
75
+ if (this.state === "closed") return;
76
+ if (this.state === "open") {
77
+ const elapsed = Date.now() - this.openedAt;
78
+ if (elapsed < this.cooldownMs) throw new LLMError(`Circuit open — provider has failed ${this.consecutiveFailures} times in a row. Retry in ${Math.ceil((this.cooldownMs - elapsed) / 1e3)}s.`, "circuit_open");
64
79
  this.state = "half-open";
80
+ this.trialInFlight = true;
65
81
  return;
66
82
  }
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");
83
+ if (this.trialInFlight) throw new LLMError("Circuit half-open. A trial request is already in flight. Try again shortly.", "circuit_open");
84
+ this.trialInFlight = true;
68
85
  }
69
86
  recordSuccess() {
70
87
  this.consecutiveFailures = 0;
71
88
  this.state = "closed";
89
+ this.trialInFlight = false;
72
90
  }
73
91
  recordFailure() {
74
92
  this.consecutiveFailures += 1;
93
+ this.trialInFlight = false;
75
94
  if (this.state === "half-open") {
76
95
  this.state = "open";
77
96
  this.openedAt = Date.now();
@@ -109,6 +128,32 @@ function extractStatus(err) {
109
128
  if (typeof error.statusCode === "number") return error.statusCode;
110
129
  return void 0;
111
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
+ }
112
157
  /**
113
158
  * Runs an async function and cancels it if it takes longer than the given
114
159
  * timeout. Creates an internal abort controller that fires after the
@@ -136,11 +181,40 @@ async function withTimeout(fn, timeoutMs, externalSignal) {
136
181
  }
137
182
  }
138
183
  /**
184
+ * Default cap (ms) for both exponential backoff and honored Retry-After
185
+ * values, so a misbehaving/adversarial Retry-After can't stall a caller
186
+ * indefinitely
187
+ */
188
+ const DEFAULT_MAX_DELAY_MS = 1e4;
189
+ /**
190
+ * Looks inside an unknown error value for a Retry-After header and
191
+ * converts it to milliseconds. Checks `.headers` first (fetch-style,
192
+ * Headers-like with `.get()`), then `.response.headers` (axios-style,
193
+ * plain object) since different client libraries surface headers
194
+ * differently. Supports both the delta-seconds form ("30") and the
195
+ * HTTP-date form ("Wed, 21 Oct 2015 07:28:00 GMT"). The result is capped
196
+ * at maxDelayMs. Returns undefined when no usable Retry-After is present
197
+ */
198
+ function extractRetryAfterMs(err, maxDelayMs = DEFAULT_MAX_DELAY_MS) {
199
+ if (!err || typeof err !== "object") return void 0;
200
+ const error = err;
201
+ const headers = error.headers ?? error.response?.headers;
202
+ if (!headers || typeof headers !== "object") return void 0;
203
+ const getter = headers;
204
+ const raw = typeof getter.get === "function" ? getter.get("Retry-After") : Object.entries(headers).find(([name]) => name.toLowerCase() === "retry-after")?.at(1);
205
+ if (typeof raw !== "string" || raw.trim() === "") return void 0;
206
+ const trimmed = raw.trim();
207
+ if (/^\d+$/.test(trimmed)) return Math.max(0, Math.min(Number(trimmed) * 1e3, maxDelayMs));
208
+ const dateMs = Date.parse(trimmed);
209
+ if (!Number.isNaN(dateMs)) return Math.max(0, Math.min(dateMs - Date.now(), maxDelayMs));
210
+ return void 0;
211
+ }
212
+ /**
139
213
  * Exponential backoff with jitter, capped at maxDelayMs.
140
214
  * Jitter avoids thundering-herd retries when many callers back off in lockstep,
141
215
  * the cap prevents unbounded delays when maxRetries is high
142
216
  */
143
- function getBackoffDelay(baseDelayMs, attempt, maxDelayMs = 1e4) {
217
+ function getBackoffDelay(baseDelayMs, attempt, maxDelayMs = DEFAULT_MAX_DELAY_MS) {
144
218
  const exp = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
145
219
  return exp / 2 + Math.random() * (exp / 2);
146
220
  }
@@ -258,6 +332,7 @@ var VernLLM = class {
258
332
  defaultMaxTokens;
259
333
  cache;
260
334
  nonRetryableStatus;
335
+ inFlight = new Map();
261
336
  parseJson;
262
337
  onUsage;
263
338
  logger;
@@ -340,7 +415,9 @@ var VernLLM = class {
340
415
  return result;
341
416
  } catch (error) {
342
417
  this.breaker?.recordFailure();
343
- throw this.normalizeError(error, params.signal);
418
+ const normalized = this.normalizeError(error, params.signal);
419
+ this.logger.debug(`[vern:${requestId}] error:\n${describeError(error)}`);
420
+ throw normalized;
344
421
  }
345
422
  }
346
423
  /**
@@ -351,7 +428,7 @@ var VernLLM = class {
351
428
  async retryWithBackoff(fn, requestId, signal) {
352
429
  let lastError;
353
430
  for (let attempt = 0; attempt <= this.maxRetries; attempt++) try {
354
- if (attempt > 0) await this.recoverDelay(requestId, attempt, signal);
431
+ if (attempt > 0) await this.recoverDelay(requestId, attempt, lastError, signal);
355
432
  return await fn();
356
433
  } catch (error) {
357
434
  lastError = error;
@@ -369,8 +446,9 @@ var VernLLM = class {
369
446
  if (signal?.aborted) return new LLMError("LLM request aborted", "aborted");
370
447
  if (error instanceof LLMError) return error;
371
448
  const status = extractStatus(error);
372
- if (status !== void 0) return new LLMError("LLM request failed", "api", status);
373
- return new LLMError("LLM request failed", "unknown");
449
+ const retryAfterMs = extractRetryAfterMs(error);
450
+ if (status !== void 0) return new LLMError("LLM request failed", "api", status, void 0, error, retryAfterMs);
451
+ return new LLMError("LLM request failed", "unknown", void 0, void 0, error, retryAfterMs);
374
452
  }
375
453
  /**
376
454
  * Performs a single attempt: builds the request, dispatches it with a
@@ -415,6 +493,7 @@ var VernLLM = class {
415
493
  buildRequestPayload(params) {
416
494
  const { systemPrompt, userContent, history = [], temperature = .2, jsonMode = true, maxTokens = this.defaultMaxTokens, model = this.model, reasoningEffort, jsonSchema } = params;
417
495
  const useJson = jsonMode || Boolean(jsonSchema);
496
+ 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");
418
497
  const responseFormat = this.buildResponseFormat(jsonSchema, useJson);
419
498
  this.validateHistory(history);
420
499
  const request = {
@@ -499,12 +578,16 @@ var VernLLM = class {
499
578
  }
500
579
  /**
501
580
  * 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
581
+ * attempt for observability before the wait begins. Honors a
582
+ * Retry-After header on the failed attempt's error when present
583
+ * (capped at the same maxDelayMs as backoff), otherwise falls back to
584
+ * exponential backoff exactly as before. Rejects early if the signal
585
+ * aborts during the wait
504
586
  */
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`);
587
+ async recoverDelay(requestId, attempt, error, signal) {
588
+ const retryAfterMs = extractRetryAfterMs(error);
589
+ const delay = retryAfterMs ?? getBackoffDelay(this.baseDelayMs, attempt);
590
+ this.logger.warn(`[vern:${requestId}] recovery attempt ${attempt}/${this.maxRetries}, waiting ${delay}ms` + (retryAfterMs !== void 0 ? " (honoring Retry-After)" : ""));
508
591
  await waitForRetry(delay, signal);
509
592
  }
510
593
  /**
@@ -533,26 +616,55 @@ var VernLLM = class {
533
616
  await this.cache.delete(key);
534
617
  }
535
618
  /**
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.
619
+ * Cache wrapper around caller-supplied logic. `params.fn` should invoke
620
+ * `this.call(...)` (see `cachedLLMCall`); retry/timeout handling is left
621
+ * to the caller.
622
+ *
623
+ * Concurrent misses for the same `cacheKey` share a single in-flight call,
624
+ * avoiding cache stampedes. Each caller still receives its own
625
+ * `reserveUsage`/`refundUsage` callbacks with coalescing metadata.
540
626
  */
541
627
  async cachedCall(params) {
542
628
  const cached = await this.cache.get(params.cacheKey);
543
629
  if (cached.hit) return cached.value;
630
+ const existing = this.inFlight.get(params.cacheKey);
631
+ const coalesced = existing !== void 0;
632
+ const resultPromise = existing ?? this.registerTrigger(params, coalesced);
633
+ if (coalesced) return this.withRefundOnFailure(params, coalesced, async () => {
634
+ await params.reserveUsage?.({ coalesced });
635
+ return resultPromise;
636
+ });
637
+ return this.withRefundOnFailure(params, coalesced, () => resultPromise);
638
+ }
639
+ /** Starts the shared fn() call for a cache miss, reserving usage first, and registers it in the in-flight map until it settles */
640
+ registerTrigger(params, coalesced) {
641
+ const resultPromise = (async () => {
642
+ await params.reserveUsage?.({ coalesced });
643
+ return this.runAndCache(params);
644
+ })();
645
+ this.inFlight.set(params.cacheKey, resultPromise);
646
+ resultPromise.catch(() => {}).finally(() => {
647
+ this.inFlight.delete(params.cacheKey);
648
+ });
649
+ return resultPromise;
650
+ }
651
+ /** Runs `fn` and writes its result to the cache. Only ever called once per cacheKey per in-flight window, from registerTrigger */
652
+ async runAndCache(params) {
653
+ const result = await params.fn();
544
654
  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;
655
+ await this.cache.set(params.cacheKey, result, params.ttl);
656
+ } catch (error) {
657
+ this.logger.error("[VernLLM] cache write failed", { message: error instanceof Error ? error.message : "unknown" });
658
+ }
659
+ return result;
660
+ }
661
+ /** Awaits `run`, calling this caller's own refundUsage (tagged with whether it was coalesced) if it rejects, then rethrows the original error */
662
+ async withRefundOnFailure(params, coalesced, run) {
663
+ try {
664
+ return await run();
553
665
  } catch (error) {
554
666
  try {
555
- await params.refundUsage?.();
667
+ await params.refundUsage?.({ coalesced });
556
668
  } catch (refundError) {
557
669
  this.logger.error("[VernLLM] refundUsage failed", { message: refundError instanceof Error ? refundError.message : "unknown" });
558
670
  }
@@ -580,9 +692,53 @@ var VernLLM = class {
580
692
  }
581
693
  };
582
694
 
695
+ //#endregion
696
+ //#region src/internal/imageFormat.ts
697
+ /**
698
+ * MIME types accepted for `ImageBlock.mimeType` across all adapters. This is
699
+ * the intersection of what Anthropic, Gemini, OpenAI-compatible, and Bedrock
700
+ * Converse all natively support, so a `ContentBlock[]` that validates for
701
+ * one provider validates for all of them.
702
+ */
703
+ const SUPPORTED_IMAGE_MIME_TYPES = [
704
+ "image/png",
705
+ "image/jpeg",
706
+ "image/gif",
707
+ "image/webp"
708
+ ];
709
+ /**
710
+ * Validates an `ImageBlock.mimeType` against the shared supported set.
711
+ * Throws a non-retryable `LLMError('validation')`, since an unsupported
712
+ * mimeType is a permanent failure, retrying the same input can't fix it,
713
+ * the same way a schema-validation or JSON-parse failure isn't retried.
714
+ */
715
+ function assertSupportedImageMimeType(mimeType) {
716
+ if (SUPPORTED_IMAGE_MIME_TYPES.includes(mimeType)) return mimeType;
717
+ throw new LLMError(`Unsupported image mimeType "${mimeType}": expected one of ${SUPPORTED_IMAGE_MIME_TYPES.join(", ")}`, "validation");
718
+ }
719
+
583
720
  //#endregion
584
721
  //#region src/adapters/anthropic.ts
585
722
  /**
723
+ * Translates a VernLLM `ContentBlock[]` (our provider-agnostic multimodal
724
+ * shape) into Anthropic's native content-block array: text blocks pass
725
+ * through as-is, image blocks become `{ type: 'image', source: { type:
726
+ * 'base64', media_type, data } }`.
727
+ */
728
+ function toAnthropicContent(blocks) {
729
+ return blocks.map((block) => block.type === "image" ? {
730
+ type: "image",
731
+ source: {
732
+ type: "base64",
733
+ media_type: assertSupportedImageMimeType(block.mimeType),
734
+ data: block.data
735
+ }
736
+ } : {
737
+ type: "text",
738
+ text: block.text
739
+ });
740
+ }
741
+ /**
586
742
  * Wraps an Anthropic SDK client so it satisfies the same `LLMClient`
587
743
  * interface VernLLM uses for OpenAI/Groq.
588
744
  *
@@ -620,7 +776,7 @@ function fromAnthropic(anthropicClient) {
620
776
  system: system || void 0,
621
777
  messages: conversationMessages.map((m) => ({
622
778
  role: m.role,
623
- content: m.content
779
+ content: Array.isArray(m.content) ? toAnthropicContent(m.content) : m.content
624
780
  })),
625
781
  ...tools ? {
626
782
  tools,
@@ -649,6 +805,18 @@ function fromAnthropic(anthropicClient) {
649
805
  //#endregion
650
806
  //#region src/adapters/gemini.ts
651
807
  /**
808
+ * Translates a VernLLM `ContentBlock[]` into Gemini's native `parts` array:
809
+ * text blocks become `{ text }`, image blocks become inline data parts
810
+ * (`{ inlineData: { mimeType, data } }`), Geminis shape for embedding raw
811
+ * base64 image bytes directly in the request.
812
+ */
813
+ function toGeminiParts(blocks) {
814
+ return blocks.map((block) => block.type === "image" ? { inlineData: {
815
+ mimeType: assertSupportedImageMimeType(block.mimeType),
816
+ data: block.data
817
+ } } : { text: block.text });
818
+ }
819
+ /**
652
820
  * Wraps a Gemini client so it satisfies the `LLMClient` interface VernLLM
653
821
  * uses for OpenAI/Groq. Geminis shape differs on nearly every axis: a
654
822
  * `contents` array instead of `messages`, a separate `systemInstruction`
@@ -674,7 +842,7 @@ function fromGemini(geminiClient) {
674
842
  model: params.model,
675
843
  contents: conversationMessages.map((m) => ({
676
844
  role: m.role === "assistant" ? "model" : "user",
677
- parts: [{ text: m.content }]
845
+ parts: Array.isArray(m.content) ? toGeminiParts(m.content) : [{ text: m.content }]
678
846
  })),
679
847
  systemInstruction: systemMessage ? { parts: [{ text: systemMessage.content }] } : void 0,
680
848
  generationConfig
@@ -693,6 +861,36 @@ function fromGemini(geminiClient) {
693
861
 
694
862
  //#endregion
695
863
  //#region src/adapters/bedrock.ts
864
+ /** Maps a `ContentBlock` image MIME type, already validated, to Converse's `format` enum. */
865
+ function toBedrockImageFormat(mimeType) {
866
+ switch (assertSupportedImageMimeType(mimeType)) {
867
+ case "image/png": return "png";
868
+ case "image/jpeg": return "jpeg";
869
+ case "image/gif": return "gif";
870
+ case "image/webp": return "webp";
871
+ }
872
+ }
873
+ /**
874
+ * Decodes base64 image data into the raw `Uint8Array` bytes Converse's
875
+ * `image.source.bytes` expects (unlike Anthropic/Gemini/OpenAI, which all
876
+ * take base64 strings directly). Uses `Buffer`, since this adapter, like
877
+ * the rest of the package, targets Node.
878
+ */
879
+ function decodeBase64(data) {
880
+ return new Uint8Array(Buffer.from(data, "base64"));
881
+ }
882
+ /**
883
+ * Translates a VernLLM `ContentBlock[]` into Converse's native content-block
884
+ * array: text blocks pass through as `{ text }`, image blocks become
885
+ * `{ image: { format, source: { bytes } } }` with the base64 payload decoded
886
+ * to raw bytes, since Converse doesn't accept base64 strings directly.
887
+ */
888
+ function toBedrockContent(blocks) {
889
+ return blocks.map((block) => block.type === "image" ? { image: {
890
+ format: toBedrockImageFormat(block.mimeType),
891
+ source: { bytes: decodeBase64(block.data) }
892
+ } } : { text: block.text });
893
+ }
696
894
  /**
697
895
  * Wraps a Bedrock Converse-API client so it satisfies the `LLMClient`
698
896
  * interface VernLLM uses for OpenAI/Groq. The Converse API is unified
@@ -734,7 +932,7 @@ function fromBedrock(bedrockClient) {
734
932
  modelId: params.model,
735
933
  messages: conversationMessages.map((m) => ({
736
934
  role: m.role,
737
- content: [{ text: m.content }]
935
+ content: Array.isArray(m.content) ? toBedrockContent(m.content) : [{ text: m.content }]
738
936
  })),
739
937
  system: systemParts.length ? systemParts.map((text$1) => ({ text: text$1 })) : void 0,
740
938
  inferenceConfig: {
@@ -776,19 +974,23 @@ function fromFetch(config) {
776
974
  return { chat: { completions: { async create(params, options) {
777
975
  const url = typeof config.url === "function" ? config.url(params) : config.url;
778
976
  const headers = typeof config.headers === "function" ? await config.headers() : config.headers;
779
- const res = await fetch(url, {
780
- method: config.method ?? "POST",
781
- headers: {
977
+ const method = config.method ?? "POST";
978
+ const request = config.request ?? fetch;
979
+ const supportsBody = !["GET", "HEAD"].includes(method.toUpperCase());
980
+ const res = await request(url, {
981
+ method,
982
+ headers: supportsBody ? {
782
983
  "Content-Type": "application/json",
783
984
  ...headers
784
- },
785
- body: JSON.stringify(config.mapRequest(params)),
985
+ } : { ...headers },
986
+ ...supportsBody ? { body: JSON.stringify(config.mapRequest(params)) } : {},
786
987
  signal: options.signal
787
988
  });
788
989
  if (!res.ok) {
789
990
  const body = await res.text().catch(() => "");
790
991
  const err = new Error(`Fetch adapter request failed (${res.status}): ${body.slice(0, 500)}`);
791
992
  err.status = res.status;
993
+ err.headers = res.headers;
792
994
  throw err;
793
995
  }
794
996
  const json = await res.json();
@@ -807,13 +1009,36 @@ function fromFetch(config) {
807
1009
  //#endregion
808
1010
  //#region src/adapters/openaiCompatible.ts
809
1011
  /**
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.
1012
+ * Translates a VernLLM `ContentBlock[]` into OpenAI's wire-level content
1013
+ * array. Text blocks become `{ type: 'text', text }`; image blocks become
1014
+ * `{ type: 'image_url', image_url: { url } }` with the base64 payload
1015
+ * inlined as a `data:` URL, since our `ContentBlock` shape (`{ type:
1016
+ * 'image', data, mimeType }`) is provider-agnostic and doesn't itself match
1017
+ * OpenAI's wire format.
1018
+ */
1019
+ function toOpenAIContent(blocks) {
1020
+ return blocks.map((block) => block.type === "image" ? {
1021
+ type: "image_url",
1022
+ image_url: { url: `data:${assertSupportedImageMimeType(block.mimeType)};base64,${block.data}` }
1023
+ } : {
1024
+ type: "text",
1025
+ text: block.text
1026
+ });
1027
+ }
1028
+ /**
1029
+ * Adapter for any SDK/client whose `chat.completions.create` already
1030
+ * matches the OpenAI wire format: this covers most hosted inference
1031
+ * providers, since "OpenAI-compatible" is a de facto standard for chat
1032
+ * completion APIs. Almost everything passes straight through untouched,
1033
+ * this exists purely so call sites read clearly (`fromMistral(client)` vs
1034
+ * handing a Mistral client to something typed for OpenAI) and so a real
1035
+ * transformation could be added later, per-provider, without a breaking
1036
+ * change.
1037
+ *
1038
+ * The one thing that isn't a pure passthrough: a `ContentBlock[]`
1039
+ * `userContent` is translated into OpenAI's native `image_url` content-part
1040
+ * shape, since VernLLM's `ContentBlock` is intentionally provider-agnostic
1041
+ * rather than a copy of any one provider's wire format.
817
1042
  *
818
1043
  * Not every SDKs own TypeScript types line up exactly with `LLMClient`
819
1044
  * (extra fields, stricter unions, etc.), so this takes `unknown` and casts:
@@ -821,7 +1046,17 @@ function fromFetch(config) {
821
1046
  * receives over the wire, not the SDKs TS types.
822
1047
  */
823
1048
  function fromOpenAICompatible(client) {
824
- return client;
1049
+ const raw = client;
1050
+ return { chat: { completions: { async create(params, options) {
1051
+ const messages = params.messages.map((m) => m.role === "user" && Array.isArray(m.content) ? {
1052
+ ...m,
1053
+ content: toOpenAIContent(m.content)
1054
+ } : m);
1055
+ return raw.chat.completions.create({
1056
+ ...params,
1057
+ messages
1058
+ }, options);
1059
+ } } } };
825
1060
  }
826
1061
  /** Groqs SDK matches the OpenAI wire format */
827
1062
  const fromGroq = fromOpenAICompatible;