Skip to content

Commit c07101c

Browse files
sragotclaude
andauthored
fix(llm): normalize structured message content from OpenAI-compatible providers (#479)
* fix(openai): normalize array-shaped message.content before .trim() GLM-5.3 hosted on Mistral's platform returns message.content as a structured array of content parts (e.g. [{type:"text", text:"..."}]) instead of a plain string. fromResponse() assumed a string and passed it straight to summarize.js's res.text.trim(), crashing with "text.trim is not a function" on every deep-build call. contentToText() now extracts text from array-of-parts content, and throws explicitly (instead of silently coercing to "" or "[object Object]") for any other unrecognized shape, so a future provider format change surfaces loudly rather than corrupting the graph silently. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * docs: soften comment wording per review feedback Avoid asserting the array form is "outside the OpenAI spec" — the SDK's own types allow array content for some message roles; this path just didn't handle it for assistant responses. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * test: cover contentToText array/null/unrecognized-shape handling The graft-blast-radius bot flagged that neither contentToText nor fromResponse was reached by any test. Adds four cases to the existing OpenAI adapter suite: array-of-parts concatenation, non-text parts dropped, null content, and the explicit throw on an unrecognized shape. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Code <noreply@anthropic.com>
1 parent ff56c78 commit c07101c

2 files changed

Lines changed: 77 additions & 2 deletions

File tree

‎src/ai/llm/openai.ts‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,28 @@ export class OpenAIChatModel implements ChatModel {
219219
return this.client.chat.completions.create(attempt);
220220
}
221221

222+
/**
223+
* Normalize provider message content to the plain text representation
224+
* expected by the rest of Graft. Some OpenAI-compatible providers
225+
* (observed with GLM-5.3 hosted on Mistral's platform) return content as
226+
* structured content parts, e.g. `[{ type: "text", text: "..." }]`,
227+
* instead of a plain string — this path in Graft assumed the latter.
228+
*/
229+
private static contentToText(content: unknown): string {
230+
if (typeof content === "string") return content;
231+
if (content == null) return "";
232+
if (Array.isArray(content)) {
233+
return content
234+
.filter((part) => part?.type === "text" || typeof part === "string")
235+
.map((part) => (typeof part === "string" ? part : (part.text ?? "")))
236+
.join("");
237+
}
238+
// An unrecognized shape is a signal worth surfacing loudly rather than
239+
// silently coercing to "" or "[object Object]" — that hides a future
240+
// provider format change instead of flagging it.
241+
throw new Error(`Unsupported message.content type from provider: ${typeof content}`);
242+
}
243+
222244
private fromResponse(
223245
resp: OpenAI.Chat.Completions.ChatCompletion,
224246
format: "text" | "json" | "tool",
@@ -237,7 +259,7 @@ export class OpenAIChatModel implements ChatModel {
237259
}
238260
};
239261

240-
let text = msg?.content ?? "";
262+
let text = OpenAIChatModel.contentToText(msg?.content);
241263
let toolCalls: ToolCall[] = rawCalls.map((c) => ({
242264
id: c.id,
243265
name: c.function.name,
@@ -258,7 +280,7 @@ export class OpenAIChatModel implements ChatModel {
258280
stopReason: choice?.finish_reason ?? null,
259281
assistant: {
260282
role: "assistant",
261-
content: msg?.content ?? "",
283+
content: OpenAIChatModel.contentToText(msg?.content),
262284
toolCalls: toolCalls.length ? toolCalls : undefined,
263285
providerRaw: { provider: PROVIDER, raw: msg },
264286
},

‎test/llm-adapters.test.ts‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,59 @@ test("openai: does NOT paper over a rejected object tool_choice when multiple to
179179
assert.equal(callCount, 1); // no ambiguous retry — the caller asked for "a" specifically
180180
});
181181

182+
test("openai: array-of-parts message.content is concatenated to plain text", async () => {
183+
const { client } = fakeOpenAI(
184+
openAiResp({
185+
choices: [
186+
{
187+
message: { content: [{ type: "text", text: "hello " }, { type: "text", text: "there" }], tool_calls: [] },
188+
finish_reason: "stop",
189+
},
190+
],
191+
}),
192+
);
193+
const m = new OpenAIChatModel({ apiKey: "x", model: "gpt-x", client });
194+
const res = await m.create({ messages: [{ role: "user", content: "hi" }] });
195+
assert.equal(res.text, "hello there");
196+
assert.equal(res.assistant.content, "hello there");
197+
});
198+
199+
test("openai: non-text parts in an array content are dropped, not stringified", async () => {
200+
const { client } = fakeOpenAI(
201+
openAiResp({
202+
choices: [
203+
{
204+
message: {
205+
content: [{ type: "image_url", image_url: { url: "x" } }, { type: "text", text: "caption" }],
206+
tool_calls: [],
207+
},
208+
finish_reason: "stop",
209+
},
210+
],
211+
}),
212+
);
213+
const m = new OpenAIChatModel({ apiKey: "x", model: "gpt-x", client });
214+
const res = await m.create({ messages: [{ role: "user", content: "hi" }] });
215+
assert.equal(res.text, "caption");
216+
});
217+
218+
test("openai: null message.content becomes an empty string", async () => {
219+
const { client } = fakeOpenAI(
220+
openAiResp({ choices: [{ message: { content: null, tool_calls: [] }, finish_reason: "stop" }] }),
221+
);
222+
const m = new OpenAIChatModel({ apiKey: "x", model: "gpt-x", client });
223+
const res = await m.create({ messages: [{ role: "user", content: "hi" }] });
224+
assert.equal(res.text, "");
225+
});
226+
227+
test("openai: an unrecognized message.content shape throws instead of silently stringifying", async () => {
228+
const { client } = fakeOpenAI(
229+
openAiResp({ choices: [{ message: { content: { weird: true }, tool_calls: [] }, finish_reason: "stop" }] }),
230+
);
231+
const m = new OpenAIChatModel({ apiKey: "x", model: "gpt-x", client });
232+
await assert.rejects(() => m.create({ messages: [{ role: "user", content: "hi" }] }), /Unsupported message\.content type/);
233+
});
234+
182235
// --- Anthropic adapter ------------------------------------------------------
183236

184237
function fakeAnthropic(resp: unknown) {

0 commit comments

Comments
 (0)