@frockbot/provider-openai-compatible 0.3.15 → 0.3.17

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/provider-openai-compatible",
3
- "version": "0.3.15",
3
+ "version": "0.3.17",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -11,8 +11,8 @@
11
11
  "typecheck": "tsc --noEmit -p tsconfig.json"
12
12
  },
13
13
  "dependencies": {
14
- "@frockbot/kernel-contracts": "0.3.15",
15
- "@frockbot/plugin-models": "0.3.15",
14
+ "@frockbot/kernel-contracts": "0.3.17",
15
+ "@frockbot/plugin-models": "0.3.17",
16
16
  "cordis": "4.0.0-rc.8"
17
17
  },
18
18
  "devDependencies": {
package/src/index.test.ts CHANGED
@@ -7,8 +7,10 @@ import { LlmRegistry } from "@frockbot/plugin-models";
7
7
  import { Context } from "cordis";
8
8
  import {
9
9
  OpenAICompatibleProvider,
10
+ planOpenAICompatibleRequestV1,
10
11
  requestToWire,
11
12
  retryAfterMillisecondsV1,
13
+ usageFromPayloadV1,
12
14
  } from "./index.js";
13
15
 
14
16
  const request: NormalizedModelRequest = {
@@ -41,10 +43,82 @@ const request: NormalizedModelRequest = {
41
43
  };
42
44
 
43
45
  describe("OpenAICompatibleProvider", () => {
46
+ test("maps schemas for OpenAI and OpenRouter-compatible endpoints", () => {
47
+ const structured = {
48
+ ...request,
49
+ responseFormat: {
50
+ type: "json_schema" as const,
51
+ name: "answer",
52
+ schema: {
53
+ type: "object" as const,
54
+ properties: { answer: { type: "string" as const } },
55
+ required: ["answer"],
56
+ additionalProperties: false,
57
+ },
58
+ },
59
+ };
60
+ const plan = planOpenAICompatibleRequestV1(structured, {
61
+ structuredOutput: "json_schema",
62
+ responseFormatDialect: "openai",
63
+ });
64
+ expect(plan.note).toBeUndefined();
65
+ expect(plan.body.response_format).toEqual({
66
+ type: "json_schema",
67
+ json_schema: {
68
+ name: "answer",
69
+ strict: true,
70
+ schema: structured.responseFormat.schema,
71
+ },
72
+ });
73
+ expect(plan.body.stream).toBe(true);
74
+ });
75
+
76
+ test("maps the direct Workers AI schema and disables streaming", () => {
77
+ const structured: NormalizedModelRequest = {
78
+ ...request,
79
+ responseFormat: {
80
+ type: "json_schema",
81
+ name: "answer",
82
+ schema: { type: "string" },
83
+ },
84
+ };
85
+ const plan = planOpenAICompatibleRequestV1(structured, {
86
+ structuredOutput: "json_schema",
87
+ responseFormatDialect: "workers-ai",
88
+ });
89
+ expect(plan.body.response_format).toEqual({
90
+ type: "json_schema",
91
+ json_schema: { type: "string" },
92
+ });
93
+ expect(plan.body.stream).toBe(false);
94
+ expect(plan.body.stream_options).toBeUndefined();
95
+ });
96
+
97
+ test("downgrades unsupported schemas explicitly and adds prompt guidance", () => {
98
+ const plan = planOpenAICompatibleRequestV1(
99
+ {
100
+ ...request,
101
+ responseFormat: {
102
+ type: "json_schema",
103
+ name: "answer",
104
+ schema: { type: "string" },
105
+ },
106
+ },
107
+ { structuredOutput: "none" },
108
+ );
109
+ expect(plan.note).toMatchObject({
110
+ code: "structured-output-downgraded",
111
+ effective: "prompt",
112
+ });
113
+ expect(JSON.stringify(plan.body.messages)).toContain("Return only JSON");
114
+ expect(plan.body.response_format).toBeUndefined();
115
+ });
116
+
44
117
  test("normalizes FrockBot messages and tools to the wire format", () => {
45
118
  expect(requestToWire(request)).toMatchObject({
46
119
  model: "test-model",
47
120
  stream: true,
121
+ stream_options: { include_usage: true },
48
122
  messages: [
49
123
  { role: "system", content: "Be useful." },
50
124
  { role: "user", content: "What time is it?" },
@@ -69,12 +143,49 @@ describe("OpenAICompatibleProvider", () => {
69
143
  });
70
144
  });
71
145
 
146
+ test("normalizes OpenAI token details", () => {
147
+ expect(
148
+ usageFromPayloadV1({
149
+ usage: {
150
+ prompt_tokens: 80,
151
+ completion_tokens: 20,
152
+ prompt_tokens_details: { cached_tokens: 32 },
153
+ completion_tokens_details: { reasoning_tokens: 7 },
154
+ },
155
+ }),
156
+ ).toEqual({
157
+ type: "usage",
158
+ usage: {
159
+ inputTokens: 80,
160
+ outputTokens: 20,
161
+ cachedInputTokens: 32,
162
+ reasoningTokens: 7,
163
+ },
164
+ });
165
+ });
166
+
167
+ test("normalizes Workers AI and Ollama token fields", () => {
168
+ expect(
169
+ usageFromPayloadV1({ usage: { input_tokens: 12, output_tokens: 4 } }),
170
+ ).toEqual({
171
+ type: "usage",
172
+ usage: { inputTokens: 12, outputTokens: 4 },
173
+ });
174
+ expect(
175
+ usageFromPayloadV1({ prompt_eval_count: 18, eval_count: 6 }),
176
+ ).toEqual({
177
+ type: "usage",
178
+ usage: { inputTokens: 18, outputTokens: 6 },
179
+ });
180
+ });
181
+
72
182
  test("streams text and assembles fragmented tool calls", async () => {
73
183
  const encoder = new TextEncoder();
74
184
  const payloads = [
75
185
  'data: {"choices":[{"delta":{"content":"Checking "}}]}\n\n',
76
186
  'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call-1","function":{"name":"current_","arguments":"{\\"zone\\":"}}]}}]}\n\n',
77
187
  'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"time","arguments":"\\"UTC\\"}"}}]},"finish_reason":"tool_calls"}]}\n\n',
188
+ 'data: {"choices":[],"usage":{"prompt_tokens":40,"completion_tokens":9,"prompt_tokens_details":{"cached_tokens":10}}}\n\n',
78
189
  "data: [DONE]\n\n",
79
190
  ];
80
191
  let capturedUrl = "";
@@ -120,6 +231,10 @@ describe("OpenAICompatibleProvider", () => {
120
231
  expect(capturedIdempotencyKey).toBeNull();
121
232
  expect(events).toEqual([
122
233
  { type: "text-delta", text: "Checking " },
234
+ {
235
+ type: "usage",
236
+ usage: { inputTokens: 40, outputTokens: 9, cachedInputTokens: 10 },
237
+ },
123
238
  {
124
239
  type: "tool-call",
125
240
  call: {
@@ -132,6 +247,48 @@ describe("OpenAICompatibleProvider", () => {
132
247
  ]);
133
248
  });
134
249
 
250
+ test("normalizes a non-streaming structured response", async () => {
251
+ const provider = new OpenAICompatibleProvider({
252
+ baseUrl: "https://models.example/v1",
253
+ structuredOutput: "json_schema",
254
+ responseFormatDialect: "workers-ai",
255
+ fetch: () =>
256
+ Promise.resolve(
257
+ Response.json({
258
+ usage: { input_tokens: 14, output_tokens: 5 },
259
+ choices: [
260
+ {
261
+ message: { content: '{"answer":"yes"}' },
262
+ finish_reason: "stop",
263
+ },
264
+ ],
265
+ }),
266
+ ),
267
+ });
268
+ const events = [];
269
+ for await (const event of provider.stream(
270
+ {
271
+ ...request,
272
+ responseFormat: {
273
+ type: "json_schema",
274
+ name: "answer",
275
+ schema: { type: "object", additionalProperties: true },
276
+ },
277
+ },
278
+ new AbortController().signal,
279
+ )) {
280
+ events.push(event);
281
+ }
282
+ expect(events).toEqual([
283
+ {
284
+ type: "usage",
285
+ usage: { inputTokens: 14, outputTokens: 5 },
286
+ },
287
+ { type: "text-delta", text: '{"answer":"yes"}' },
288
+ { type: "finish", reason: "completed" },
289
+ ]);
290
+ });
291
+
135
292
  test("rejects a truncated stream without a terminal marker", async () => {
136
293
  const provider = new OpenAICompatibleProvider({
137
294
  baseUrl: "https://models.example/v1",
package/src/index.ts CHANGED
@@ -9,6 +9,8 @@ import {
9
9
  ModelProviderFailureError,
10
10
  type ModelProviderFailureClassV1,
11
11
  type NormalizedModelRequest,
12
+ type ResponseFormatNoteV1,
13
+ type StructuredOutputSupportV1,
12
14
  } from "@frockbot/kernel-contracts";
13
15
  import type { Plugin } from "cordis";
14
16
 
@@ -137,6 +139,10 @@ export interface OpenAICompatibleConfig {
137
139
  * model id decides through {@link modelAcceptsImagesV1}.
138
140
  */
139
141
  acceptsImages?: boolean;
142
+ /** The strongest structured-output mode this endpoint accepts. */
143
+ structuredOutput?: StructuredOutputSupportV1;
144
+ /** Workers AI takes the schema directly; OpenAI/OpenRouter wrap it by name. */
145
+ responseFormatDialect?: "openai" | "workers-ai";
140
146
  /** Overrides {@link MODEL_REQUEST_DEADLINES_V1}. */
141
147
  deadlines?: Partial<ModelRequestDeadlinesV1>;
142
148
  /**
@@ -341,8 +347,27 @@ function messageToWire(
341
347
 
342
348
  export function requestToWire(
343
349
  request: NormalizedModelRequest,
344
- options: { acceptsImages?: boolean } = {},
350
+ options: OpenAIRequestOptionsV1 = {},
345
351
  ): Record<string, unknown> {
352
+ return planOpenAICompatibleRequestV1(request, options).body;
353
+ }
354
+
355
+ export interface OpenAIRequestOptionsV1 {
356
+ acceptsImages?: boolean;
357
+ structuredOutput?: StructuredOutputSupportV1;
358
+ responseFormatDialect?: "openai" | "workers-ai";
359
+ }
360
+
361
+ export interface OpenAIRequestPlanV1 {
362
+ body: Record<string, unknown>;
363
+ note?: ResponseFormatNoteV1;
364
+ }
365
+
366
+ /** Maps the provider-neutral format and records any fidelity downgrade. */
367
+ export function planOpenAICompatibleRequestV1(
368
+ request: NormalizedModelRequest,
369
+ options: OpenAIRequestOptionsV1 = {},
370
+ ): OpenAIRequestPlanV1 {
346
371
  const acceptsImages =
347
372
  options.acceptsImages ?? modelAcceptsImagesV1(request.model);
348
373
  const messages: Record<string, unknown>[] = [];
@@ -351,22 +376,82 @@ export function requestToWire(
351
376
  for (const message of request.messages) {
352
377
  messages.push(...messageToWire(message, acceptsImages));
353
378
  }
354
- return {
355
- model: request.model,
356
- stream: true,
357
- messages,
358
- ...(request.tools.length > 0
359
- ? {
360
- tools: request.tools.map((tool) => ({
361
- type: "function",
362
- function: {
363
- name: tool.name,
364
- description: tool.description,
365
- parameters: tool.inputSchema,
379
+ const support = options.structuredOutput ?? "none";
380
+ const format = request.responseFormat;
381
+ let responseFormat: Record<string, unknown> | undefined;
382
+ let note: ResponseFormatNoteV1 | undefined;
383
+ if (format?.type === "json_schema" && support === "json_schema") {
384
+ responseFormat =
385
+ options.responseFormatDialect === "workers-ai"
386
+ ? { type: "json_schema", json_schema: format.schema }
387
+ : {
388
+ type: "json_schema",
389
+ json_schema: {
390
+ name: format.name,
391
+ strict: true,
392
+ schema: format.schema,
366
393
  },
367
- })),
368
- }
369
- : {}),
394
+ };
395
+ } else if (format && support !== "none") {
396
+ responseFormat = { type: "json_object" };
397
+ if (format.type === "json_schema") {
398
+ note = {
399
+ code: "structured-output-downgraded",
400
+ requested: "json_schema",
401
+ effective: "json",
402
+ message: `Provider ${request.provider} supports JSON mode but not JSON Schema; the shared validator remains authoritative`,
403
+ };
404
+ }
405
+ } else if (format) {
406
+ note = {
407
+ code: "structured-output-downgraded",
408
+ requested: format.type,
409
+ effective: "prompt",
410
+ message: `Provider ${request.provider} has no native structured-output mode; the request uses prompt guidance and shared validation`,
411
+ };
412
+ }
413
+ if (format) {
414
+ const instruction =
415
+ format.type === "json_schema"
416
+ ? `Return only JSON matching this schema exactly: ${JSON.stringify(format.schema)}`
417
+ : "Return only one valid JSON value, with no Markdown or commentary.";
418
+ if (request.system) {
419
+ messages[0] = {
420
+ role: "system",
421
+ content: `${request.system}\n\n${instruction}`,
422
+ };
423
+ } else {
424
+ messages.unshift({ role: "system", content: instruction });
425
+ }
426
+ }
427
+ const stream = !(
428
+ format &&
429
+ support !== "none" &&
430
+ options.responseFormatDialect === "workers-ai"
431
+ );
432
+ return {
433
+ body: {
434
+ model: request.model,
435
+ // Workers AI documents JSON mode as non-streaming. The decoder accepts
436
+ // both response shapes while the contract remains an event stream.
437
+ stream,
438
+ ...(stream ? { stream_options: { include_usage: true } } : {}),
439
+ messages,
440
+ ...(responseFormat ? { response_format: responseFormat } : {}),
441
+ ...(request.tools.length > 0
442
+ ? {
443
+ tools: request.tools.map((tool) => ({
444
+ type: "function",
445
+ function: {
446
+ name: tool.name,
447
+ description: tool.description,
448
+ parameters: tool.inputSchema,
449
+ },
450
+ })),
451
+ }
452
+ : {}),
453
+ },
454
+ ...(note ? { note } : {}),
370
455
  };
371
456
  }
372
457
 
@@ -433,6 +518,76 @@ function asRecord(value: unknown): Record<string, unknown> | undefined {
433
518
  : undefined;
434
519
  }
435
520
 
521
+ function usageIntegerV1(value: unknown, label: string): number | undefined {
522
+ if (value === undefined) return undefined;
523
+ if (!Number.isSafeInteger(value) || (value as number) < 0) {
524
+ throw new Error(`Model returned invalid ${label}`);
525
+ }
526
+ return value as number;
527
+ }
528
+
529
+ /**
530
+ * Normalizes token accounting returned by OpenAI-shaped, Workers AI/Gateway,
531
+ * and Ollama streams. Unknown payloads are ignored so the Agent loop can use
532
+ * its durable byte-size estimate.
533
+ */
534
+ export function usageFromPayloadV1(
535
+ value: unknown,
536
+ ): Extract<LlmStreamEvent, { type: "usage" }> | undefined {
537
+ const payload = asRecord(value);
538
+ if (!payload) return undefined;
539
+ const usage = asRecord(payload.usage) ?? payload;
540
+ const inputTokens = usageIntegerV1(
541
+ usage.prompt_tokens ??
542
+ usage.input_tokens ??
543
+ usage.prompt_eval_count ??
544
+ payload.prompt_eval_count,
545
+ "input token count",
546
+ );
547
+ const outputTokens = usageIntegerV1(
548
+ usage.completion_tokens ??
549
+ usage.output_tokens ??
550
+ usage.eval_count ??
551
+ payload.eval_count,
552
+ "output token count",
553
+ );
554
+ if (inputTokens === undefined || outputTokens === undefined) return undefined;
555
+
556
+ const inputDetails =
557
+ asRecord(usage.prompt_tokens_details) ??
558
+ asRecord(usage.input_tokens_details);
559
+ const outputDetails =
560
+ asRecord(usage.completion_tokens_details) ??
561
+ asRecord(usage.output_tokens_details);
562
+ const cachedInputTokens = usageIntegerV1(
563
+ inputDetails?.cached_tokens ?? usage.cached_input_tokens,
564
+ "cached input token count",
565
+ );
566
+ const reasoningTokens = usageIntegerV1(
567
+ outputDetails?.reasoning_tokens ?? usage.reasoning_tokens,
568
+ "reasoning token count",
569
+ );
570
+ if (cachedInputTokens !== undefined && cachedInputTokens > inputTokens) {
571
+ throw new Error(
572
+ "Model returned cached input tokens above total input tokens",
573
+ );
574
+ }
575
+ if (reasoningTokens !== undefined && reasoningTokens > outputTokens) {
576
+ throw new Error(
577
+ "Model returned reasoning tokens above total output tokens",
578
+ );
579
+ }
580
+ return {
581
+ type: "usage",
582
+ usage: {
583
+ inputTokens,
584
+ outputTokens,
585
+ ...(cachedInputTokens === undefined ? {} : { cachedInputTokens }),
586
+ ...(reasoningTokens === undefined ? {} : { reasoningTokens }),
587
+ },
588
+ };
589
+ }
590
+
436
591
  function applyToolDeltas(
437
592
  value: unknown,
438
593
  tools: Map<number, ToolAccumulator>,
@@ -475,11 +630,39 @@ export async function* streamOpenAICompatibleBody(
475
630
  body: ReadableStream<Uint8Array>,
476
631
  signal: AbortSignal,
477
632
  ): AsyncIterable<LlmStreamEvent> {
633
+ const [probeBody, replayBody] = body.tee();
634
+ const probeReader = probeBody.getReader();
635
+ const probeDecoder = new TextDecoder();
636
+ let prefix = "";
637
+ const cancelProbe = (): void => {
638
+ void probeReader.cancel(signal.reason).catch(() => undefined);
639
+ if (!replayBody.locked) {
640
+ void replayBody.cancel(signal.reason).catch(() => undefined);
641
+ }
642
+ };
643
+ signal.addEventListener("abort", cancelProbe, { once: true });
644
+ try {
645
+ signal.throwIfAborted();
646
+ while (!prefix.trimStart() && prefix.length < 4_096) {
647
+ const { done, value } = await probeReader.read();
648
+ signal.throwIfAborted();
649
+ if (done) break;
650
+ prefix += probeDecoder.decode(value, { stream: true });
651
+ }
652
+ } finally {
653
+ signal.removeEventListener("abort", cancelProbe);
654
+ void probeReader.cancel().catch(() => undefined);
655
+ probeReader.releaseLock();
656
+ }
657
+ if (prefix.trimStart().startsWith("{")) {
658
+ yield* readOpenAICompatibleJsonV1(replayBody, signal);
659
+ return;
660
+ }
478
661
  const tools = new Map<number, ToolAccumulator>();
479
662
  let finishReason: string | undefined;
480
663
  let terminal = false;
481
664
  let sawChoice = false;
482
- for await (const data of readSseData(body, signal)) {
665
+ for await (const data of readSseData(replayBody, signal)) {
483
666
  if (data === "[DONE]") {
484
667
  terminal = true;
485
668
  break;
@@ -487,6 +670,8 @@ export async function* streamOpenAICompatibleBody(
487
670
  const payload = asRecord(
488
671
  parseJson(data, "Model returned an invalid stream event"),
489
672
  );
673
+ const usage = usageFromPayloadV1(payload);
674
+ if (usage) yield usage;
490
675
  const choices = payload?.choices;
491
676
  const choice = Array.isArray(choices) ? asRecord(choices[0]) : undefined;
492
677
  const delta = asRecord(choice?.delta);
@@ -534,6 +719,83 @@ export async function* streamOpenAICompatibleBody(
534
719
  };
535
720
  }
536
721
 
722
+ async function* readOpenAICompatibleJsonV1(
723
+ body: ReadableStream<Uint8Array>,
724
+ signal: AbortSignal,
725
+ ): AsyncIterable<LlmStreamEvent> {
726
+ const reader = body.getReader();
727
+ const chunks: Uint8Array[] = [];
728
+ let bytes = 0;
729
+ try {
730
+ while (true) {
731
+ signal.throwIfAborted();
732
+ const { done, value } = await reader.read();
733
+ if (done) break;
734
+ bytes += value.byteLength;
735
+ if (bytes > MAX_SSE_RESPONSE_BYTES) await rejectOversizedSse(reader);
736
+ chunks.push(value);
737
+ }
738
+ } finally {
739
+ reader.releaseLock();
740
+ }
741
+ const combined = new Uint8Array(bytes);
742
+ let offset = 0;
743
+ for (const chunk of chunks) {
744
+ combined.set(chunk, offset);
745
+ offset += chunk.byteLength;
746
+ }
747
+ const payload = asRecord(
748
+ parseJson(
749
+ new TextDecoder().decode(combined),
750
+ "Model returned an invalid response",
751
+ ),
752
+ );
753
+ const choices = payload?.choices;
754
+ const choice = Array.isArray(choices) ? asRecord(choices[0]) : undefined;
755
+ const message = asRecord(choice?.message);
756
+ if (!choice || !message) {
757
+ throw new Error("Model response did not include a valid choice");
758
+ }
759
+ const usage = usageFromPayloadV1(payload);
760
+ if (usage) yield usage;
761
+ if (typeof message.content === "string" && message.content) {
762
+ yield { type: "text-delta", text: message.content };
763
+ }
764
+ const tools = new Map<number, ToolAccumulator>();
765
+ if (Array.isArray(message.tool_calls)) {
766
+ applyToolDeltas(
767
+ message.tool_calls.map((candidate, index) => ({
768
+ ...(asRecord(candidate) ?? {}),
769
+ index,
770
+ })),
771
+ tools,
772
+ );
773
+ }
774
+ for (const tool of [...tools.values()].sort(
775
+ (left, right) => left.index - right.index,
776
+ )) {
777
+ if (!tool.name)
778
+ throw new Error("Model returned a tool call without a name");
779
+ yield {
780
+ type: "tool-call",
781
+ call: {
782
+ id: tool.id || crypto.randomUUID(),
783
+ name: tool.name,
784
+ input: parseToolInput(tool.arguments),
785
+ },
786
+ };
787
+ }
788
+ yield {
789
+ type: "finish",
790
+ reason:
791
+ tools.size > 0 || choice.finish_reason === "tool_calls"
792
+ ? "tool-calls"
793
+ : choice.finish_reason === "length"
794
+ ? "max-tokens"
795
+ : "completed",
796
+ };
797
+ }
798
+
537
799
  /**
538
800
  * Wait for `opening`, but give up when the deadline clock does.
539
801
  *
@@ -621,12 +883,16 @@ export async function* streamWithModelRequestDeadlinesV1(
621
883
 
622
884
  export class OpenAICompatibleProvider implements LlmProvider {
623
885
  readonly id: string;
886
+ readonly supports;
624
887
  private config: OpenAICompatibleConfig;
625
888
 
626
889
  constructor(config: OpenAICompatibleConfig) {
627
890
  if (!config.baseUrl.trim())
628
891
  throw new Error("OpenAI-compatible baseUrl is required");
629
892
  this.id = config.providerId ?? "openai-compatible";
893
+ this.supports = {
894
+ structuredOutput: config.structuredOutput ?? "none",
895
+ } as const;
630
896
  this.config = { ...config, baseUrl: config.baseUrl.replace(/\/$/, "") };
631
897
  }
632
898
 
@@ -634,6 +900,16 @@ export class OpenAICompatibleProvider implements LlmProvider {
634
900
  request: NormalizedModelRequest,
635
901
  signal: AbortSignal,
636
902
  ): AsyncIterable<LlmStreamEvent> {
903
+ const plan = planOpenAICompatibleRequestV1(request, {
904
+ ...(this.config.acceptsImages === undefined
905
+ ? {}
906
+ : { acceptsImages: this.config.acceptsImages }),
907
+ structuredOutput: this.supports.structuredOutput,
908
+ ...(this.config.responseFormatDialect
909
+ ? { responseFormatDialect: this.config.responseFormatDialect }
910
+ : {}),
911
+ });
912
+ if (plan.note) yield { type: "response-format-note", note: plan.note };
637
913
  // Workerd rejects a detached global `fetch` ("Illegal invocation"), so the
638
914
  // default fetcher forwards through a closure rather than aliasing it.
639
915
  const fetcher =
@@ -658,13 +934,7 @@ export class OpenAICompatibleProvider implements LlmProvider {
658
934
  {
659
935
  method: "POST",
660
936
  headers,
661
- body: JSON.stringify(
662
- requestToWire(request, {
663
- ...(this.config.acceptsImages === undefined
664
- ? {}
665
- : { acceptsImages: this.config.acceptsImages }),
666
- }),
667
- ),
937
+ body: JSON.stringify(plan.body),
668
938
  signal: deadlineSignal,
669
939
  },
670
940
  );