Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

Notable additions, fixes, or breaking changes to the Freeplay SDK.

## [0.6.2] - 2026-03-13

### Fixed

- Media content in recorded messages now uses provider-formatted objects instead of internal content_part_type objects, fixing validation errors when recording prompts with media inputs.

## [0.6.1] - 2026-03-10

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "freeplay",
"version": "0.6.1",
"version": "0.6.2",
"description": "Node.js/Typescript SDK for Freeplay AI",
"type": "module",
"typesVersions": {
Expand Down
24 changes: 23 additions & 1 deletion src/resources/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -477,12 +477,34 @@ export class BoundPrompt {
provider: llmAdapter.provider(),
}
: this.promptInfo;
const prepared = prepareMessages(
let prepared = prepareMessages(
this.messages,
llmAdapter.roleSupport,
finalFlavor,
);
const llmFormat = llmAdapter.toLLMSyntax(prepared);

// Kind of a hack: bound messages may contain internal content objects
// (content_part_type: "text", "media_base64", etc.) that aren't in any
// provider format. Ideally bind() wouldn't produce these internal objects.
// For now, re-run each media message through the adapter individually to
// get provider-formatted content while preserving messages the adapter
// would otherwise drop (e.g. system for Responses API).
prepared = prepared.map((msg) => {
if (
Array.isArray(msg.content) &&
msg.content.some(
(part: Record<string, any>) => "content_part_type" in part,
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
) {
const formatted = llmAdapter.toLLMSyntax([msg]);
if (Array.isArray(formatted) && formatted.length > 0) {
return formatted[0];
}
return msg;
}
return msg;
});
const llmFormatText = typeof llmFormat === "string" ? llmFormat : undefined;
const formattedToolSchema = this.toolSchema
? this.formatToolSchema(this.toolSchema, finalFlavor)
Expand Down
71 changes: 71 additions & 0 deletions test/resources/prompts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,77 @@ describe("prompts", () => {
expect(formattedPrompt.outputSchema).toEqual(outputSchema);
});

test("media messages in allMessages have provider format, not internal content_part_type", () => {
const promptInfo: PromptInfo = {
promptTemplateId: "test-id",
promptTemplateVersionId: "test-version-id",
templateName: "test-template",
modelParameters: {},
provider: "openai",
model: "gpt-4o",
flavorName: "openai_responses",
};

const messages: TemplateMessage[] = [
{ role: "system", content: "You are a helpful assistant." },
{
role: "user",
content: "Describe this image: {{description}}",
media_slots: [{ type: "image", placeholder_name: "photo" }],
},
];

const media: MediaInputMap = {
photo: {
type: "base64",
content_type: "image/png",
data: "iVBORw0KGgoAAAANSUhEUg==",
},
};

const templatePrompt = new TemplatePrompt(promptInfo, messages);
const boundPrompt = templatePrompt.bind(
{ description: "a sunset" },
undefined,
media,
);
const formattedPrompt = boundPrompt.format("openai_responses");

const completionOutput = {
role: "assistant",
content: "It shows a beautiful sunset.",
};
const allMsgs = formattedPrompt.allMessages(completionOutput);

// allMessages should be JSON-serializable (JS objects always are, but verify no cycles etc.)
expect(() => JSON.stringify(allMsgs)).not.toThrow();

// No content block should have the internal `content_part_type` field
for (const msg of allMsgs) {
if (Array.isArray(msg.content)) {
for (const block of msg.content) {
expect(block).not.toHaveProperty("content_part_type");
expect(block).not.toHaveProperty("slot_name");
expect(block).not.toHaveProperty("slot_type");
}
}
}

// System message should be preserved in allMessages
const systemMsg = allMsgs.find((m) => m.role === "system");
expect(systemMsg).toBeDefined();
expect(systemMsg!.content).toBe("You are a helpful assistant.");

// The user message should have provider-formatted content blocks with `type`, not `content_part_type`
const userMsg = allMsgs.find(
(m) => m.role === "user" && Array.isArray(m.content),
);
expect(userMsg).toBeDefined();
for (const block of userMsg!.content as Array<Record<string, any>>) {
expect(block).toHaveProperty("type");
}
});

test("output schema with unsupported provider throws error", () => {
const outputSchema = {
type: "object",
Expand Down
17 changes: 7 additions & 10 deletions test/sdk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1634,21 +1634,18 @@ describe("Chat Completions", function () {
role: "user",
content: [
{
content_part_type: "text",
type: "text",
text: "Answer this question: How do these images look?",
},
{
content_part_type: "media_url",
url: "http://localhost/bird.png",
slot_name: "image-1",
slot_type: "image",
type: "image_url",
image_url: { url: "http://localhost/bird.png" },
},
{
content_part_type: "media_base64",
content_type: "image/png",
data: onePixelPng,
slot_name: "image-2",
slot_type: "image",
type: "image_url",
image_url: {
url: `data:image/png;base64,${onePixelPng}`,
},
},
],
},
Expand Down
Loading