Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
203 changes: 203 additions & 0 deletions js/src/logger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,209 @@ test("renderMessage with file content parts", () => {
]);
});

test("renderMessage expands image_url array from JSON string", () => {
const message = {
role: "user" as const,
content: [
{
type: "text" as const,
text: "Look at these images:",
},
{
type: "image_url" as const,
image_url: {
url: "{{images}}",
},
},
],
};

const rendered = renderMessage(
(template) =>
template.replace(
"{{images}}",
'["https://example.com/img1.jpg","https://example.com/img2.jpg"]',
),
message,
);

expect(rendered.content).toEqual([
{
type: "text",
text: "Look at these images:",
},
{
type: "image_url",
image_url: {
url: "https://example.com/img1.jpg",
},
},
{
type: "image_url",
image_url: {
url: "https://example.com/img2.jpg",
},
},
]);
});

test("renderMessage expands file array from JSON string (file_data)", () => {
const message = {
role: "user" as const,
content: [
{
type: "file" as const,
file: {
file_data: "{{files}}",
filename: "{{filenames}}",
},
},
],
};

const rendered = renderMessage(
(template) =>
template
.replace(
"{{files}}",
'["data:text/plain;base64,SGVsbG8=","data:text/plain;base64,V29ybGQ="]',
)
.replace("{{filenames}}", '["hello.txt","world.txt"]'),
message,
);

expect(rendered.content).toEqual([
{
type: "file",
file: {
file_data: "data:text/plain;base64,SGVsbG8=",
filename: "hello.txt",
},
},
{
type: "file",
file: {
file_data: "data:text/plain;base64,V29ybGQ=",
filename: "world.txt",
},
},
]);
});

test("renderMessage expands file array from JSON string (file_id)", () => {
const message = {
role: "user" as const,
content: [
{
type: "file" as const,
file: {
file_id: "{{fileIds}}",
filename: "{{filenames}}",
},
},
],
};

const rendered = renderMessage(
(template) =>
template
.replace("{{fileIds}}", '["file-abc123","file-def456"]')
.replace("{{filenames}}", '["document1.pdf","document2.pdf"]'),
message,
);

expect(rendered.content).toEqual([
{
type: "file",
file: {
file_id: "file-abc123",
filename: "document1.pdf",
},
},
{
type: "file",
file: {
file_id: "file-def456",
filename: "document2.pdf",
},
},
]);
});

test("renderMessage handles single image_url (no array)", () => {
const message = {
role: "user" as const,
content: [
{
type: "image_url" as const,
image_url: {
url: "{{image}}",
},
},
],
};

const rendered = renderMessage(
(template) =>
template.replace("{{image}}", "https://example.com/single.jpg"),
message,
);

expect(rendered.content).toEqual([
{
type: "image_url",
image_url: {
url: "https://example.com/single.jpg",
},
},
]);
});

test("renderMessage handles mixed content with array expansion", () => {
const message = {
role: "user" as const,
content: [
{
type: "text" as const,
text: "Check out:",
},
{
type: "image_url" as const,
image_url: {
url: "{{images}}",
},
},
{
type: "text" as const,
text: "And these files:",
},
{
type: "file" as const,
file: {
file_id: "{{files}}",
},
},
],
};

const rendered = renderMessage(
(template) =>
template
.replace("{{images}}", '["url1.jpg","url2.jpg"]')
.replace("{{files}}", '["file1","file2"]'),
message,
);

expect(rendered.content).toEqual([
{ type: "text", text: "Check out:" },
{ type: "image_url", image_url: { url: "url1.jpg" } },
{ type: "image_url", image_url: { url: "url2.jpg" } },
{ type: "text", text: "And these files:" },
{ type: "file", file: { file_id: "file1" } },
{ type: "file", file: { file_id: "file2" } },
]);
});

test("verify MemoryBackgroundLogger intercepts logs", async () => {
// Log to memory for the tests.
_exportsForTestingOnly.simulateLoginForTests();
Expand Down
160 changes: 126 additions & 34 deletions js/src/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6302,6 +6302,26 @@ export type DefaultPromptArgs = Partial<
CompiledPromptParams & AnyModelParam & ChatPrompt & CompletionPrompt
>;

/**
* Try to parse a string as a JSON array.
* This handles cases where template engines stringify arrays when rendering template variables.
* @param value The value to parse
* @returns The parsed array, or null if not a valid JSON array
*/
function tryParseStringAsArray(value: string): unknown[] | null {
const trimmed = value.trim();
if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) {
return null;
}

try {
const parsed = JSON.parse(trimmed);
return Array.isArray(parsed) ? parsed : null;
} catch {
return null;
}
}

export function renderMessage<T extends Message>(
render: (template: string) => string,
message: T,
Expand All @@ -6314,41 +6334,113 @@ export function renderMessage<T extends Message>(
? undefined
: typeof message.content === "string"
? render(message.content)
: message.content.map((c) => {
switch (c.type) {
case "text":
return { ...c, text: render(c.text) };
case "image_url":
if (isObject(c.image_url.url)) {
throw new Error(
"Attachments must be replaced with URLs before calling `build()`",
: message.content
.map((c) => {
switch (c.type) {
case "text":
return [{ ...c, text: render(c.text) }];
case "image_url":
if (isObject(c.image_url.url)) {
throw new Error(
"Attachments must be replaced with URLs before calling `build()`",
);
}
const renderedUrl = render(c.image_url.url);

// Check if the rendered URL is a stringified array
// This happens when template variables contain arrays of URLs
// e.g., {{images}} where images = ["url1", "url2"]
const parsedUrls = tryParseStringAsArray(renderedUrl);

if (parsedUrls !== null) {
// Expand array into multiple image_url blocks
return parsedUrls.map((url) => ({
type: "image_url" as const,
image_url: { url: String(url) },
}));
}

return [
{
...c,
image_url: {
...c.image_url,
url: renderedUrl,
},
},
];
case "file":
const fileData = render(c.file.file_data || "");
const fileId = c.file.file_id
? render(c.file.file_id)
: undefined;
const filename = c.file.filename
? render(c.file.filename)
: undefined;

const dataArr =
tryParseStringAsArray(fileData)?.map(String) ?? null;
const idArr = fileId
? tryParseStringAsArray(fileId)?.map(String) ?? null
: null;
const nameArr = filename
? tryParseStringAsArray(filename)?.map(String) ?? null
: null;

const arrays = [dataArr, idArr, nameArr].filter(
(a): a is string[] => a !== null,
);
}
return {
...c,
image_url: {
...c.image_url,
url: render(c.image_url.url),
},
};
case "file":
return {
...c,
file: {
file_data: render(c.file.file_data || ""),
...(c.file.file_id && {
file_id: render(c.file.file_id),
}),
...(c.file.filename && {
filename: render(c.file.filename),
}),
},
};
default:
const _exhaustiveCheck: never = c;
return _exhaustiveCheck;
}
}),

if (arrays.length === 0) {
return [
{
...c,
file: {
...(c.file.file_data && {
file_data: fileData,
}),
...(c.file.file_id && { file_id: fileId }),
...(c.file.filename && { filename }),
},
},
];
}

const len = arrays[0].length;
if (!arrays.every((a) => a.length === len)) {
throw new Error(
`file field array lengths must match (expected ${len})`,
);
}

return Array.from({ length: len }, (_, i) => ({
type: "file" as const,
file: {
...(dataArr
? { file_data: dataArr[i] }
: c.file.file_data
? { file_data: fileData }
: {}),

...(idArr
? { file_id: idArr[i] }
: c.file.file_id
? { file_id: fileId! }
: {}),

...(nameArr
? { filename: nameArr[i] }
: c.file.filename
? { filename }
: {}),
},
}));
default:
const _exhaustiveCheck: never = c;
return _exhaustiveCheck;
}
})
.flat(),
}
: {}),
...("tool_calls" in message
Expand Down
Loading
Loading