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/dist/index.js CHANGED
@@ -2,11 +2,13 @@ import { randomUUID } from "crypto";
2
2
 
3
3
  //#region src/types/errors.ts
4
4
  var LLMError = class extends Error {
5
- constructor(message, type, status, issues) {
5
+ constructor(message, type, status, issues, cause, retryAfterMs) {
6
6
  super(message);
7
7
  this.type = type;
8
8
  this.status = status;
9
9
  this.issues = issues;
10
+ this.cause = cause;
11
+ this.retryAfterMs = retryAfterMs;
10
12
  this.name = "LLMError";
11
13
  }
12
14
  };
@@ -28,26 +30,43 @@ var CircuitBreaker = class {
28
30
  openedAt = 0;
29
31
  threshold;
30
32
  cooldownMs;
33
+ /**
34
+ * True while a single half-open trial call is in flight. Guards against
35
+ * multiple concurrent callers all treating themselves as "the" trial once
36
+ * the cooldown elapses
37
+ */
38
+ trialInFlight = false;
31
39
  constructor(options = {}) {
32
40
  this.threshold = options.threshold ?? 5;
33
41
  this.cooldownMs = options.cooldownMs ?? 3e4;
34
42
  }
35
- /** Throws if the circuit is open and the cooldown hasnt elapsed */
43
+ /**
44
+ * Throws if the circuit is open and the cooldown hasn't elapsed, or if
45
+ * the circuit is half-open and a trial call is already in flight.
46
+ * Otherwise, if the circuit just became eligible for a trial (cooldown
47
+ * elapsed, or half-open with no trial currently running), this call
48
+ * becomes that trial
49
+ */
36
50
  assertClosed() {
37
- if (this.state !== "open") return;
38
- const elapsed = Date.now() - this.openedAt;
39
- if (elapsed >= this.cooldownMs) {
51
+ if (this.state === "closed") return;
52
+ if (this.state === "open") {
53
+ const elapsed = Date.now() - this.openedAt;
54
+ 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");
40
55
  this.state = "half-open";
56
+ this.trialInFlight = true;
41
57
  return;
42
58
  }
43
- 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");
59
+ if (this.trialInFlight) throw new LLMError("Circuit half-open. A trial request is already in flight. Try again shortly.", "circuit_open");
60
+ this.trialInFlight = true;
44
61
  }
45
62
  recordSuccess() {
46
63
  this.consecutiveFailures = 0;
47
64
  this.state = "closed";
65
+ this.trialInFlight = false;
48
66
  }
49
67
  recordFailure() {
50
68
  this.consecutiveFailures += 1;
69
+ this.trialInFlight = false;
51
70
  if (this.state === "half-open") {
52
71
  this.state = "open";
53
72
  this.openedAt = Date.now();
@@ -85,6 +104,32 @@ function extractStatus(err) {
85
104
  if (typeof error.statusCode === "number") return error.statusCode;
86
105
  return void 0;
87
106
  }
107
+ function formatSafely(value) {
108
+ try {
109
+ return JSON.stringify(value, null, 2) ?? String(value);
110
+ } catch {
111
+ try {
112
+ return String(value);
113
+ } catch {
114
+ return "[unprintable error]";
115
+ }
116
+ }
117
+ }
118
+ /**
119
+ * Looks inside an unknown thrown value and pulls out a human-readable
120
+ * description of it. Checks the `error` field first (the provider's raw
121
+ * rejection body, JSON-stringified if possible) then falls back to the
122
+ * message` field. Always returns a safe string, even when the thrown value
123
+ * has hostile properties or cannot be serialized normally.
124
+ */
125
+ function describeError(err) {
126
+ if (err && typeof err === "object") try {
127
+ const error = err;
128
+ if (error.error !== void 0) return formatSafely(error.error);
129
+ if (typeof error.message === "string") return error.message;
130
+ } catch {}
131
+ return formatSafely(err);
132
+ }
88
133
  /**
89
134
  * Runs an async function and cancels it if it takes longer than the given
90
135
  * timeout. Creates an internal abort controller that fires after the
@@ -112,11 +157,40 @@ async function withTimeout(fn, timeoutMs, externalSignal) {
112
157
  }
113
158
  }
114
159
  /**
160
+ * Default cap (ms) for both exponential backoff and honored Retry-After
161
+ * values, so a misbehaving/adversarial Retry-After can't stall a caller
162
+ * indefinitely
163
+ */
164
+ const DEFAULT_MAX_DELAY_MS = 1e4;
165
+ /**
166
+ * Looks inside an unknown error value for a Retry-After header and
167
+ * converts it to milliseconds. Checks `.headers` first (fetch-style,
168
+ * Headers-like with `.get()`), then `.response.headers` (axios-style,
169
+ * plain object) since different client libraries surface headers
170
+ * differently. Supports both the delta-seconds form ("30") and the
171
+ * HTTP-date form ("Wed, 21 Oct 2015 07:28:00 GMT"). The result is capped
172
+ * at maxDelayMs. Returns undefined when no usable Retry-After is present
173
+ */
174
+ function extractRetryAfterMs(err, maxDelayMs = DEFAULT_MAX_DELAY_MS) {
175
+ if (!err || typeof err !== "object") return void 0;
176
+ const error = err;
177
+ const headers = error.headers ?? error.response?.headers;
178
+ if (!headers || typeof headers !== "object") return void 0;
179
+ const getter = headers;
180
+ const raw = typeof getter.get === "function" ? getter.get("Retry-After") : Object.entries(headers).find(([name]) => name.toLowerCase() === "retry-after")?.at(1);
181
+ if (typeof raw !== "string" || raw.trim() === "") return void 0;
182
+ const trimmed = raw.trim();
183
+ if (/^\d+$/.test(trimmed)) return Math.max(0, Math.min(Number(trimmed) * 1e3, maxDelayMs));
184
+ const dateMs = Date.parse(trimmed);
185
+ if (!Number.isNaN(dateMs)) return Math.max(0, Math.min(dateMs - Date.now(), maxDelayMs));
186
+ return void 0;
187
+ }
188
+ /**
115
189
  * Exponential backoff with jitter, capped at maxDelayMs.
116
190
  * Jitter avoids thundering-herd retries when many callers back off in lockstep,
117
191
  * the cap prevents unbounded delays when maxRetries is high
118
192
  */
119
- function getBackoffDelay(baseDelayMs, attempt, maxDelayMs = 1e4) {
193
+ function getBackoffDelay(baseDelayMs, attempt, maxDelayMs = DEFAULT_MAX_DELAY_MS) {
120
194
  const exp = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
121
195
  return exp / 2 + Math.random() * (exp / 2);
122
196
  }
@@ -234,6 +308,7 @@ var VernLLM = class {
234
308
  defaultMaxTokens;
235
309
  cache;
236
310
  nonRetryableStatus;
311
+ inFlight = new Map();
237
312
  parseJson;
238
313
  onUsage;
239
314
  logger;
@@ -316,7 +391,9 @@ var VernLLM = class {
316
391
  return result;
317
392
  } catch (error) {
318
393
  this.breaker?.recordFailure();
319
- throw this.normalizeError(error, params.signal);
394
+ const normalized = this.normalizeError(error, params.signal);
395
+ this.logger.debug(`[vern:${requestId}] error:\n${describeError(error)}`);
396
+ throw normalized;
320
397
  }
321
398
  }
322
399
  /**
@@ -327,7 +404,7 @@ var VernLLM = class {
327
404
  async retryWithBackoff(fn, requestId, signal) {
328
405
  let lastError;
329
406
  for (let attempt = 0; attempt <= this.maxRetries; attempt++) try {
330
- if (attempt > 0) await this.recoverDelay(requestId, attempt, signal);
407
+ if (attempt > 0) await this.recoverDelay(requestId, attempt, lastError, signal);
331
408
  return await fn();
332
409
  } catch (error) {
333
410
  lastError = error;
@@ -345,8 +422,9 @@ var VernLLM = class {
345
422
  if (signal?.aborted) return new LLMError("LLM request aborted", "aborted");
346
423
  if (error instanceof LLMError) return error;
347
424
  const status = extractStatus(error);
348
- if (status !== void 0) return new LLMError("LLM request failed", "api", status);
349
- return new LLMError("LLM request failed", "unknown");
425
+ const retryAfterMs = extractRetryAfterMs(error);
426
+ if (status !== void 0) return new LLMError("LLM request failed", "api", status, void 0, error, retryAfterMs);
427
+ return new LLMError("LLM request failed", "unknown", void 0, void 0, error, retryAfterMs);
350
428
  }
351
429
  /**
352
430
  * Performs a single attempt: builds the request, dispatches it with a
@@ -391,6 +469,7 @@ var VernLLM = class {
391
469
  buildRequestPayload(params) {
392
470
  const { systemPrompt, userContent, history = [], temperature = .2, jsonMode = true, maxTokens = this.defaultMaxTokens, model = this.model, reasoningEffort, jsonSchema } = params;
393
471
  const useJson = jsonMode || Boolean(jsonSchema);
472
+ 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");
394
473
  const responseFormat = this.buildResponseFormat(jsonSchema, useJson);
395
474
  this.validateHistory(history);
396
475
  const request = {
@@ -475,12 +554,16 @@ var VernLLM = class {
475
554
  }
476
555
  /**
477
556
  * Waits out the backoff delay for a given retry attempt, logging the
478
- * attempt for observability before the wait begins. Rejects early if
479
- * the signal aborts during the wait
557
+ * attempt for observability before the wait begins. Honors a
558
+ * Retry-After header on the failed attempt's error when present
559
+ * (capped at the same maxDelayMs as backoff), otherwise falls back to
560
+ * exponential backoff exactly as before. Rejects early if the signal
561
+ * aborts during the wait
480
562
  */
481
- async recoverDelay(requestId, attempt, signal) {
482
- const delay = getBackoffDelay(this.baseDelayMs, attempt);
483
- this.logger.warn(`[vern:${requestId}] recovery attempt ${attempt}/${this.maxRetries}, waiting ${delay}ms`);
563
+ async recoverDelay(requestId, attempt, error, signal) {
564
+ const retryAfterMs = extractRetryAfterMs(error);
565
+ const delay = retryAfterMs ?? getBackoffDelay(this.baseDelayMs, attempt);
566
+ this.logger.warn(`[vern:${requestId}] recovery attempt ${attempt}/${this.maxRetries}, waiting ${delay}ms` + (retryAfterMs !== void 0 ? " (honoring Retry-After)" : ""));
484
567
  await waitForRetry(delay, signal);
485
568
  }
486
569
  /**
@@ -509,26 +592,55 @@ var VernLLM = class {
509
592
  await this.cache.delete(key);
510
593
  }
511
594
  /**
512
- * Thin cache wrapper around caller supplied logic. `params.fn` is expected
513
- * to be a call that itself invokes `this.call(...)` (see `cachedLLMCall`
514
- * below for a convenience wrapper that wires this up automatically),
515
- * `cachedCall` does not itself apply retry/timeout policy.
595
+ * Cache wrapper around caller-supplied logic. `params.fn` should invoke
596
+ * `this.call(...)` (see `cachedLLMCall`); retry/timeout handling is left
597
+ * to the caller.
598
+ *
599
+ * Concurrent misses for the same `cacheKey` share a single in-flight call,
600
+ * avoiding cache stampedes. Each caller still receives its own
601
+ * `reserveUsage`/`refundUsage` callbacks with coalescing metadata.
516
602
  */
517
603
  async cachedCall(params) {
518
604
  const cached = await this.cache.get(params.cacheKey);
519
605
  if (cached.hit) return cached.value;
606
+ const existing = this.inFlight.get(params.cacheKey);
607
+ const coalesced = existing !== void 0;
608
+ const resultPromise = existing ?? this.registerTrigger(params, coalesced);
609
+ if (coalesced) return this.withRefundOnFailure(params, coalesced, async () => {
610
+ await params.reserveUsage?.({ coalesced });
611
+ return resultPromise;
612
+ });
613
+ return this.withRefundOnFailure(params, coalesced, () => resultPromise);
614
+ }
615
+ /** Starts the shared fn() call for a cache miss, reserving usage first, and registers it in the in-flight map until it settles */
616
+ registerTrigger(params, coalesced) {
617
+ const resultPromise = (async () => {
618
+ await params.reserveUsage?.({ coalesced });
619
+ return this.runAndCache(params);
620
+ })();
621
+ this.inFlight.set(params.cacheKey, resultPromise);
622
+ resultPromise.catch(() => {}).finally(() => {
623
+ this.inFlight.delete(params.cacheKey);
624
+ });
625
+ return resultPromise;
626
+ }
627
+ /** Runs `fn` and writes its result to the cache. Only ever called once per cacheKey per in-flight window, from registerTrigger */
628
+ async runAndCache(params) {
629
+ const result = await params.fn();
520
630
  try {
521
- await params.reserveUsage?.();
522
- const result = await params.fn();
523
- try {
524
- await this.cache.set(params.cacheKey, result, params.ttl);
525
- } catch (error) {
526
- this.logger.error("[VernLLM] cache write failed", { message: error instanceof Error ? error.message : "unknown" });
527
- }
528
- return result;
631
+ await this.cache.set(params.cacheKey, result, params.ttl);
632
+ } catch (error) {
633
+ this.logger.error("[VernLLM] cache write failed", { message: error instanceof Error ? error.message : "unknown" });
634
+ }
635
+ return result;
636
+ }
637
+ /** Awaits `run`, calling this caller's own refundUsage (tagged with whether it was coalesced) if it rejects, then rethrows the original error */
638
+ async withRefundOnFailure(params, coalesced, run) {
639
+ try {
640
+ return await run();
529
641
  } catch (error) {
530
642
  try {
531
- await params.refundUsage?.();
643
+ await params.refundUsage?.({ coalesced });
532
644
  } catch (refundError) {
533
645
  this.logger.error("[VernLLM] refundUsage failed", { message: refundError instanceof Error ? refundError.message : "unknown" });
534
646
  }
@@ -556,9 +668,53 @@ var VernLLM = class {
556
668
  }
557
669
  };
558
670
 
671
+ //#endregion
672
+ //#region src/internal/imageFormat.ts
673
+ /**
674
+ * MIME types accepted for `ImageBlock.mimeType` across all adapters. This is
675
+ * the intersection of what Anthropic, Gemini, OpenAI-compatible, and Bedrock
676
+ * Converse all natively support, so a `ContentBlock[]` that validates for
677
+ * one provider validates for all of them.
678
+ */
679
+ const SUPPORTED_IMAGE_MIME_TYPES = [
680
+ "image/png",
681
+ "image/jpeg",
682
+ "image/gif",
683
+ "image/webp"
684
+ ];
685
+ /**
686
+ * Validates an `ImageBlock.mimeType` against the shared supported set.
687
+ * Throws a non-retryable `LLMError('validation')`, since an unsupported
688
+ * mimeType is a permanent failure, retrying the same input can't fix it,
689
+ * the same way a schema-validation or JSON-parse failure isn't retried.
690
+ */
691
+ function assertSupportedImageMimeType(mimeType) {
692
+ if (SUPPORTED_IMAGE_MIME_TYPES.includes(mimeType)) return mimeType;
693
+ throw new LLMError(`Unsupported image mimeType "${mimeType}": expected one of ${SUPPORTED_IMAGE_MIME_TYPES.join(", ")}`, "validation");
694
+ }
695
+
559
696
  //#endregion
560
697
  //#region src/adapters/anthropic.ts
561
698
  /**
699
+ * Translates a VernLLM `ContentBlock[]` (our provider-agnostic multimodal
700
+ * shape) into Anthropic's native content-block array: text blocks pass
701
+ * through as-is, image blocks become `{ type: 'image', source: { type:
702
+ * 'base64', media_type, data } }`.
703
+ */
704
+ function toAnthropicContent(blocks) {
705
+ return blocks.map((block) => block.type === "image" ? {
706
+ type: "image",
707
+ source: {
708
+ type: "base64",
709
+ media_type: assertSupportedImageMimeType(block.mimeType),
710
+ data: block.data
711
+ }
712
+ } : {
713
+ type: "text",
714
+ text: block.text
715
+ });
716
+ }
717
+ /**
562
718
  * Wraps an Anthropic SDK client so it satisfies the same `LLMClient`
563
719
  * interface VernLLM uses for OpenAI/Groq.
564
720
  *
@@ -596,7 +752,7 @@ function fromAnthropic(anthropicClient) {
596
752
  system: system || void 0,
597
753
  messages: conversationMessages.map((m) => ({
598
754
  role: m.role,
599
- content: m.content
755
+ content: Array.isArray(m.content) ? toAnthropicContent(m.content) : m.content
600
756
  })),
601
757
  ...tools ? {
602
758
  tools,
@@ -625,6 +781,18 @@ function fromAnthropic(anthropicClient) {
625
781
  //#endregion
626
782
  //#region src/adapters/gemini.ts
627
783
  /**
784
+ * Translates a VernLLM `ContentBlock[]` into Gemini's native `parts` array:
785
+ * text blocks become `{ text }`, image blocks become inline data parts
786
+ * (`{ inlineData: { mimeType, data } }`), Geminis shape for embedding raw
787
+ * base64 image bytes directly in the request.
788
+ */
789
+ function toGeminiParts(blocks) {
790
+ return blocks.map((block) => block.type === "image" ? { inlineData: {
791
+ mimeType: assertSupportedImageMimeType(block.mimeType),
792
+ data: block.data
793
+ } } : { text: block.text });
794
+ }
795
+ /**
628
796
  * Wraps a Gemini client so it satisfies the `LLMClient` interface VernLLM
629
797
  * uses for OpenAI/Groq. Geminis shape differs on nearly every axis: a
630
798
  * `contents` array instead of `messages`, a separate `systemInstruction`
@@ -650,7 +818,7 @@ function fromGemini(geminiClient) {
650
818
  model: params.model,
651
819
  contents: conversationMessages.map((m) => ({
652
820
  role: m.role === "assistant" ? "model" : "user",
653
- parts: [{ text: m.content }]
821
+ parts: Array.isArray(m.content) ? toGeminiParts(m.content) : [{ text: m.content }]
654
822
  })),
655
823
  systemInstruction: systemMessage ? { parts: [{ text: systemMessage.content }] } : void 0,
656
824
  generationConfig
@@ -669,6 +837,36 @@ function fromGemini(geminiClient) {
669
837
 
670
838
  //#endregion
671
839
  //#region src/adapters/bedrock.ts
840
+ /** Maps a `ContentBlock` image MIME type, already validated, to Converse's `format` enum. */
841
+ function toBedrockImageFormat(mimeType) {
842
+ switch (assertSupportedImageMimeType(mimeType)) {
843
+ case "image/png": return "png";
844
+ case "image/jpeg": return "jpeg";
845
+ case "image/gif": return "gif";
846
+ case "image/webp": return "webp";
847
+ }
848
+ }
849
+ /**
850
+ * Decodes base64 image data into the raw `Uint8Array` bytes Converse's
851
+ * `image.source.bytes` expects (unlike Anthropic/Gemini/OpenAI, which all
852
+ * take base64 strings directly). Uses `Buffer`, since this adapter, like
853
+ * the rest of the package, targets Node.
854
+ */
855
+ function decodeBase64(data) {
856
+ return new Uint8Array(Buffer.from(data, "base64"));
857
+ }
858
+ /**
859
+ * Translates a VernLLM `ContentBlock[]` into Converse's native content-block
860
+ * array: text blocks pass through as `{ text }`, image blocks become
861
+ * `{ image: { format, source: { bytes } } }` with the base64 payload decoded
862
+ * to raw bytes, since Converse doesn't accept base64 strings directly.
863
+ */
864
+ function toBedrockContent(blocks) {
865
+ return blocks.map((block) => block.type === "image" ? { image: {
866
+ format: toBedrockImageFormat(block.mimeType),
867
+ source: { bytes: decodeBase64(block.data) }
868
+ } } : { text: block.text });
869
+ }
672
870
  /**
673
871
  * Wraps a Bedrock Converse-API client so it satisfies the `LLMClient`
674
872
  * interface VernLLM uses for OpenAI/Groq. The Converse API is unified
@@ -710,7 +908,7 @@ function fromBedrock(bedrockClient) {
710
908
  modelId: params.model,
711
909
  messages: conversationMessages.map((m) => ({
712
910
  role: m.role,
713
- content: [{ text: m.content }]
911
+ content: Array.isArray(m.content) ? toBedrockContent(m.content) : [{ text: m.content }]
714
912
  })),
715
913
  system: systemParts.length ? systemParts.map((text$1) => ({ text: text$1 })) : void 0,
716
914
  inferenceConfig: {
@@ -752,19 +950,23 @@ function fromFetch(config) {
752
950
  return { chat: { completions: { async create(params, options) {
753
951
  const url = typeof config.url === "function" ? config.url(params) : config.url;
754
952
  const headers = typeof config.headers === "function" ? await config.headers() : config.headers;
755
- const res = await fetch(url, {
756
- method: config.method ?? "POST",
757
- headers: {
953
+ const method = config.method ?? "POST";
954
+ const request = config.request ?? fetch;
955
+ const supportsBody = !["GET", "HEAD"].includes(method.toUpperCase());
956
+ const res = await request(url, {
957
+ method,
958
+ headers: supportsBody ? {
758
959
  "Content-Type": "application/json",
759
960
  ...headers
760
- },
761
- body: JSON.stringify(config.mapRequest(params)),
961
+ } : { ...headers },
962
+ ...supportsBody ? { body: JSON.stringify(config.mapRequest(params)) } : {},
762
963
  signal: options.signal
763
964
  });
764
965
  if (!res.ok) {
765
966
  const body = await res.text().catch(() => "");
766
967
  const err = new Error(`Fetch adapter request failed (${res.status}): ${body.slice(0, 500)}`);
767
968
  err.status = res.status;
969
+ err.headers = res.headers;
768
970
  throw err;
769
971
  }
770
972
  const json = await res.json();
@@ -783,13 +985,36 @@ function fromFetch(config) {
783
985
  //#endregion
784
986
  //#region src/adapters/openaiCompatible.ts
785
987
  /**
786
- * Passthrough adapter for any SDK/client whose `chat.completions.create`
787
- * already matches the OpenAI wire format 1:1 : this covers most hosted
788
- * inference providers, since "OpenAI-compatible" is a de facto standard for
789
- * chat completion APIs. No transformation happens here, this exists purely
790
- * so call sites read clearly (`fromMistral(client)` vs handing a Mistral
791
- * client to something typed for OpenAI) and so a real transformation could
792
- * be added later, per-provider, without a breaking change.
988
+ * Translates a VernLLM `ContentBlock[]` into OpenAI's wire-level content
989
+ * array. Text blocks become `{ type: 'text', text }`; image blocks become
990
+ * `{ type: 'image_url', image_url: { url } }` with the base64 payload
991
+ * inlined as a `data:` URL, since our `ContentBlock` shape (`{ type:
992
+ * 'image', data, mimeType }`) is provider-agnostic and doesn't itself match
993
+ * OpenAI's wire format.
994
+ */
995
+ function toOpenAIContent(blocks) {
996
+ return blocks.map((block) => block.type === "image" ? {
997
+ type: "image_url",
998
+ image_url: { url: `data:${assertSupportedImageMimeType(block.mimeType)};base64,${block.data}` }
999
+ } : {
1000
+ type: "text",
1001
+ text: block.text
1002
+ });
1003
+ }
1004
+ /**
1005
+ * Adapter for any SDK/client whose `chat.completions.create` already
1006
+ * matches the OpenAI wire format: this covers most hosted inference
1007
+ * providers, since "OpenAI-compatible" is a de facto standard for chat
1008
+ * completion APIs. Almost everything passes straight through untouched,
1009
+ * this exists purely so call sites read clearly (`fromMistral(client)` vs
1010
+ * handing a Mistral client to something typed for OpenAI) and so a real
1011
+ * transformation could be added later, per-provider, without a breaking
1012
+ * change.
1013
+ *
1014
+ * The one thing that isn't a pure passthrough: a `ContentBlock[]`
1015
+ * `userContent` is translated into OpenAI's native `image_url` content-part
1016
+ * shape, since VernLLM's `ContentBlock` is intentionally provider-agnostic
1017
+ * rather than a copy of any one provider's wire format.
793
1018
  *
794
1019
  * Not every SDKs own TypeScript types line up exactly with `LLMClient`
795
1020
  * (extra fields, stricter unions, etc.), so this takes `unknown` and casts:
@@ -797,7 +1022,17 @@ function fromFetch(config) {
797
1022
  * receives over the wire, not the SDKs TS types.
798
1023
  */
799
1024
  function fromOpenAICompatible(client) {
800
- return client;
1025
+ const raw = client;
1026
+ return { chat: { completions: { async create(params, options) {
1027
+ const messages = params.messages.map((m) => m.role === "user" && Array.isArray(m.content) ? {
1028
+ ...m,
1029
+ content: toOpenAIContent(m.content)
1030
+ } : m);
1031
+ return raw.chat.completions.create({
1032
+ ...params,
1033
+ messages
1034
+ }, options);
1035
+ } } } };
801
1036
  }
802
1037
  /** Groqs SDK matches the OpenAI wire format */
803
1038
  const fromGroq = fromOpenAICompatible;