Skip to content

Commit 72795fe

Browse files
ddewaeledavy-dewaelechristian-bromannhntrl
authored
fixes filename / base64 conversions in openai completions converters (#9512) (#9570)
Co-authored-by: Davy De Waele <[email protected]> Co-authored-by: Christian Bromann <[email protected]> Co-authored-by: Hunter Lovell <[email protected]> Co-authored-by: Hunter Lovell <[email protected]>
1 parent bc8e90f commit 72795fe

File tree

6 files changed

+257
-14
lines changed

6 files changed

+257
-14
lines changed

.changeset/red-boats-doubt.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@langchain/openai": patch
3+
---
4+
5+
fixes filename / base64 conversions in openai completions converters (#9512)
6+

libs/providers/langchain-openai/src/converters/completions.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,11 @@ import type {
3434
} from "openai/resources/chat/completions";
3535
import { OpenAI as OpenAIClient } from "openai";
3636
import { handleMultiModalOutput } from "../utils/output.js";
37-
import { isReasoningModel, messageToOpenAIRole } from "../utils/misc.js";
37+
import {
38+
getRequiredFilenameFromMetadata,
39+
isReasoningModel,
40+
messageToOpenAIRole,
41+
} from "../utils/misc.js";
3842

3943
/**
4044
* @deprecated This converter is an internal detail of the OpenAI provider. Do not use it directly. This will be revisited in a future release.
@@ -157,6 +161,9 @@ export const completionsApiContentBlockConverter: StandardContentBlockConverter<
157161
fromStandardFileBlock(block): ChatCompletionContentPart.File {
158162
if (block.source_type === "url") {
159163
const data = parseBase64DataUrl({ dataUrl: block.url });
164+
165+
const filename = getRequiredFilenameFromMetadata(block);
166+
160167
if (!data) {
161168
throw new Error(
162169
`URL file blocks with source_type ${block.source_type} must be formatted as a data URL for ChatOpenAI`
@@ -169,15 +176,16 @@ export const completionsApiContentBlockConverter: StandardContentBlockConverter<
169176
file_data: block.url, // formatted as base64 data URL
170177
...(block.metadata?.filename || block.metadata?.name
171178
? {
172-
filename: (block.metadata?.filename ||
173-
block.metadata?.name) as string,
179+
filename,
174180
}
175181
: {}),
176182
},
177183
};
178184
}
179185

180186
if (block.source_type === "base64") {
187+
const filename = getRequiredFilenameFromMetadata(block);
188+
181189
return {
182190
type: "file",
183191
file: {
@@ -186,9 +194,7 @@ export const completionsApiContentBlockConverter: StandardContentBlockConverter<
186194
block.metadata?.name ||
187195
block.metadata?.title
188196
? {
189-
filename: (block.metadata?.filename ||
190-
block.metadata?.name ||
191-
block.metadata?.title) as string,
197+
filename,
192198
}
193199
: {}),
194200
},
@@ -550,10 +556,13 @@ export const convertStandardContentBlockToCompletionsContentPart: Converter<
550556
}
551557
if (block.type === "file") {
552558
if (block.data) {
559+
const filename = getRequiredFilenameFromMetadata(block);
560+
553561
return {
554562
type: "file",
555563
file: {
556-
file_data: block.data.toString(),
564+
file_data: `data:${block.mimeType};base64,${block.data}`,
565+
filename: filename,
557566
},
558567
};
559568
}

libs/providers/langchain-openai/src/converters/responses.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,12 @@ import type {
2525
import { ResponseInputMessageContentList } from "openai/resources/responses/responses.js";
2626
import { ChatOpenAIReasoningSummary } from "../types.js";
2727
import { isCustomToolCall, parseCustomToolCall } from "../utils/tools.js";
28-
import { iife, isReasoningModel, messageToOpenAIRole } from "../utils/misc.js";
28+
import {
29+
getRequiredFilenameFromMetadata,
30+
iife,
31+
isReasoningModel,
32+
messageToOpenAIRole,
33+
} from "../utils/misc.js";
2934
import { Converter } from "@langchain/core/utils/format";
3035
import { completionsApiContentBlockConverter } from "./completions.js";
3136

@@ -755,10 +760,8 @@ export const convertStandardContentMessageToResponsesInput: Converter<
755760
const resolveFileItem = (
756761
block: ContentBlock.Multimodal.File | ContentBlock.Multimodal.Video
757762
): OpenAIClient.Responses.ResponseInputFile | undefined => {
758-
const filename =
759-
block.metadata?.filename ??
760-
block.metadata?.name ??
761-
block.metadata?.title;
763+
const filename = getRequiredFilenameFromMetadata(block);
764+
762765
if (block.fileId && typeof filename === "string") {
763766
return {
764767
type: "input_file",

libs/providers/langchain-openai/src/converters/tests/completions.test.ts

Lines changed: 155 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
/* eslint-disable @typescript-eslint/no-explicit-any */
22
import { describe, it, expect } from "vitest";
33
import { ChatCompletionMessage } from "openai/resources";
4-
import { convertCompletionsMessageToBaseMessage } from "../completions.js";
4+
import {
5+
completionsApiContentBlockConverter,
6+
convertCompletionsMessageToBaseMessage,
7+
convertStandardContentBlockToCompletionsContentPart,
8+
} from "../completions.js";
59

610
describe("convertCompletionsMessageToBaseMessage", () => {
711
describe("OpenRouter image response handling", () => {
@@ -124,4 +128,154 @@ describe("convertCompletionsMessageToBaseMessage", () => {
124128
]);
125129
});
126130
});
131+
132+
describe("convertStandardContentBlockToCompletionsContentPart", () => {
133+
it("can convert image block with base64 data to image_url data URL", () => {
134+
const block = {
135+
type: "image",
136+
data: "iVBORw0KGgoAAAANSUhEUgAAAAE",
137+
mimeType: "image/png",
138+
} as any;
139+
140+
const result = convertStandardContentBlockToCompletionsContentPart(block);
141+
expect(result).toEqual({
142+
type: "image_url",
143+
image_url: {
144+
url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAE",
145+
},
146+
});
147+
});
148+
149+
it("can convert image block with url to image_url", () => {
150+
const block = {
151+
type: "image",
152+
url: "https://example.com/cat.png",
153+
} as any;
154+
155+
const result = convertStandardContentBlockToCompletionsContentPart(block);
156+
expect(result).toEqual({
157+
type: "image_url",
158+
image_url: {
159+
url: "https://example.com/cat.png",
160+
},
161+
});
162+
});
163+
164+
it("will throw an error when when no filename is providing to a base64 file block", () => {
165+
const block = {
166+
type: "file",
167+
data: "iVBORw0KGgoAAAANSUhEUgAAAAE",
168+
mimeType: "application/pdf",
169+
} as any;
170+
171+
expect(() =>
172+
convertStandardContentBlockToCompletionsContentPart(block)
173+
).toThrowError(
174+
"a filename or name or title is needed via meta-data for OpenAI when working with multimodal blocks"
175+
);
176+
});
177+
178+
it("will convert a file block to an openai file payload when a filename is provided", () => {
179+
const block = {
180+
type: "file",
181+
data: "iVBORw0KGgoAAAANSUhEUgAAAAE",
182+
mimeType: "application/pdf",
183+
metadata: { filename: "cat.pdf" },
184+
} as any;
185+
186+
const result = convertStandardContentBlockToCompletionsContentPart(block);
187+
expect(result).toEqual({
188+
type: "file",
189+
file: {
190+
file_data: "data:application/pdf;base64,iVBORw0KGgoAAAANSUhEUgAAAAE",
191+
filename: "cat.pdf",
192+
},
193+
});
194+
});
195+
});
196+
197+
describe("completionsApiContentBlockConverter.fromStandardFileBlock", () => {
198+
it("throws when base64 file block is missing filename/name/title metadata", () => {
199+
const block = {
200+
source_type: "base64",
201+
mime_type: "application/pdf",
202+
data: "AAABBB",
203+
// metadata intentionally missing
204+
} as any;
205+
206+
expect(() =>
207+
completionsApiContentBlockConverter.fromStandardFileBlock!(block)
208+
).toThrowError(
209+
"a filename or name or title is needed via meta-data for OpenAI when working with multimodal blocks"
210+
);
211+
});
212+
213+
it("converts base64 file block to file with data URL and filename from metadata.filename", () => {
214+
const block = {
215+
source_type: "base64",
216+
mime_type: "application/pdf",
217+
data: "AAABBB",
218+
metadata: { filename: "doc.pdf" },
219+
} as any;
220+
221+
const result =
222+
completionsApiContentBlockConverter.fromStandardFileBlock!(block);
223+
expect(result).toEqual({
224+
type: "file",
225+
file: {
226+
file_data: "data:application/pdf;base64,AAABBB",
227+
filename: "doc.pdf",
228+
},
229+
});
230+
});
231+
232+
it("converts url data-url file block to file with file_data equal to url and includes filename from metadata.name", () => {
233+
const dataUrl = "data:application/pdf;base64,AAABBB";
234+
const block = {
235+
source_type: "url",
236+
url: dataUrl,
237+
metadata: { name: "report.pdf" },
238+
} as any;
239+
240+
const result =
241+
completionsApiContentBlockConverter.fromStandardFileBlock!(block);
242+
expect(result).toEqual({
243+
type: "file",
244+
file: {
245+
file_data: dataUrl,
246+
filename: "report.pdf",
247+
},
248+
});
249+
});
250+
251+
it("returns file_id for id source_type", () => {
252+
const block = {
253+
source_type: "id",
254+
id: "file_123",
255+
} as any;
256+
257+
const result =
258+
completionsApiContentBlockConverter.fromStandardFileBlock!(block);
259+
expect(result).toEqual({
260+
type: "file",
261+
file: {
262+
file_id: "file_123",
263+
},
264+
});
265+
});
266+
267+
it("throws when url is not a data URL", () => {
268+
const block = {
269+
source_type: "url",
270+
url: "https://example.com/file.pdf",
271+
metadata: { filename: "file.pdf" },
272+
} as any;
273+
274+
expect(() =>
275+
completionsApiContentBlockConverter.fromStandardFileBlock!(block)
276+
).toThrowError(
277+
`URL file blocks with source_type url must be formatted as a data URL for ChatOpenAI`
278+
);
279+
});
280+
});
127281
});

libs/providers/langchain-openai/src/converters/tests/responses.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,4 +310,52 @@ describe("convertStandardContentMessageToResponsesInput", () => {
310310

311311
expect(result).toEqual([{ type: "custom", payload: "data" }]);
312312
});
313+
314+
it("converts file payloads when filename is provided", () => {
315+
const message = new HumanMessage({
316+
contentBlocks: [
317+
{
318+
type: "file",
319+
mimeType: "application/pdf",
320+
data: "iVBORw0KGgoAAAANSUhEUgAAAAE",
321+
metadata: { filename: "sample.pdf" },
322+
},
323+
],
324+
});
325+
326+
const result = convertStandardContentMessageToResponsesInput(message);
327+
328+
expect(result).toEqual([
329+
{
330+
role: "user",
331+
type: "message",
332+
content: [
333+
{
334+
type: "input_file",
335+
file_data:
336+
"data:application/pdf;base64,iVBORw0KGgoAAAANSUhEUgAAAAE",
337+
filename: "sample.pdf",
338+
},
339+
],
340+
},
341+
]);
342+
});
343+
344+
it("throws error when file payload does not contain filename", () => {
345+
const message = new HumanMessage({
346+
contentBlocks: [
347+
{
348+
type: "file",
349+
mimeType: "application/pdf",
350+
data: "iVBORw0KGgoAAAANSUhEUgAAAAE",
351+
},
352+
],
353+
});
354+
355+
expect(() =>
356+
convertStandardContentMessageToResponsesInput(message)
357+
).toThrowError(
358+
`a filename or name or title is needed via meta-data for OpenAI when working with multimodal blocks`
359+
);
360+
});
313361
});

libs/providers/langchain-openai/src/utils/misc.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import type { OpenAI as OpenAIClient } from "openai";
2-
import { BaseMessage, ChatMessage } from "@langchain/core/messages";
2+
import {
3+
BaseMessage,
4+
ChatMessage,
5+
ContentBlock,
6+
Data,
7+
} from "@langchain/core/messages";
38

49
export const iife = <T>(fn: () => T) => fn();
510

@@ -25,6 +30,24 @@ export function extractGenericMessageCustomRole(message: ChatMessage) {
2530
return message.role as OpenAIClient.ChatCompletionRole;
2631
}
2732

33+
export function getRequiredFilenameFromMetadata(
34+
block:
35+
| ContentBlock.Multimodal.File
36+
| ContentBlock.Multimodal.Video
37+
| Data.StandardFileBlock
38+
): string {
39+
const filename = (block.metadata?.filename ??
40+
block.metadata?.name ??
41+
block.metadata?.title) as string;
42+
43+
if (!filename) {
44+
throw new Error(
45+
"a filename or name or title is needed via meta-data for OpenAI when working with multimodal blocks"
46+
);
47+
}
48+
49+
return filename;
50+
}
2851
export function messageToOpenAIRole(
2952
message: BaseMessage
3053
): OpenAIClient.ChatCompletionRole {

0 commit comments

Comments
 (0)