Skip to content

Commit fbb5b7b

Browse files
authored
fix(extra): handle malformed JSON in structChat parsed response (#230)
JSON.parse in convertToParsedChatCompletionResponse can throw a SyntaxError when the model returns malformed JSON (common when finish_reason is "length" and the output is truncated). Wrap the parse in try/catch and leave parsed as undefined so callers can detect the failure gracefully instead of crashing. Also adds unit tests covering malformed JSON, valid JSON failing schema validation, null content, empty choices, and undefined choices. Original work by @JacobiusMakes in #200.
1 parent 2b42fd6 commit fbb5b7b

2 files changed

Lines changed: 156 additions & 5 deletions

File tree

src/extra/structChat.ts

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -118,23 +118,43 @@ export function convertToParsedChatCompletionResponse<T extends z.ZodTypeAny>(re
118118
for (const _choice of response.choices) {
119119
if (_choice.message === null || typeof _choice.message === 'undefined') {
120120
parsedChoices.push({..._choice, message: undefined});
121+
} else if (
122+
_choice.message.content !== null
123+
&& typeof _choice.message.content !== 'undefined'
124+
&& !Array.isArray(_choice.message.content)
125+
) {
126+
let parsed: z.infer<T> | undefined;
127+
try {
128+
parsed = responseFormat.safeParse(JSON.parse(_choice.message.content)).data;
129+
} catch {
130+
// JSON.parse can throw if the model returns malformed JSON
131+
// (e.g. truncated output when finish_reason is "length").
132+
// Leave parsed as undefined so callers can detect the failure.
133+
parsed = undefined;
134+
}
135+
parsedChoices.push({
136+
..._choice,
137+
message: {
138+
..._choice.message,
139+
parsed,
140+
},
141+
});
121142
} else {
122-
if (_choice.message.content !== null && typeof _choice.message.content !== 'undefined' && !Array.isArray(_choice.message.content)) {
143+
// content is null, undefined, or an array of content chunks;
144+
// preserve the choice without a parsed field.
123145
parsedChoices.push({
124146
..._choice,
125147
message: {
126148
..._choice.message,
127-
parsed: responseFormat.safeParse(JSON.parse(_choice.message.content)).data,
128-
}
149+
parsed: undefined,
150+
},
129151
});
130-
}
131152
}
132153
}
133154
return {
134155
...response,
135156
choices: parsedChoices,
136157
};
137-
138158
}
139159

140160
// Function to convert Zod schema to strict JSON schema

tests/extra/structChat.test.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,137 @@ describe("convertToParsedChatCompletionResponse", () => {
188188
convertToParsedChatCompletionResponse(raw_response, MathDemonstration)
189189
).toStrictEqual(ccr_response);
190190
});
191+
192+
it("should set parsed to undefined when content is malformed JSON", () => {
193+
const truncatedResponse: components.ChatCompletionResponse = {
194+
id: "test-malformed",
195+
object: "chat.completion",
196+
model: "mistral-tiny-latest",
197+
usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 },
198+
created: 1700000000,
199+
choices: [
200+
{
201+
index: 0,
202+
message: {
203+
content: '{"steps": [{"explanation": "truncated',
204+
toolCalls: null,
205+
prefix: false,
206+
role: "assistant",
207+
},
208+
finishReason: "length",
209+
},
210+
],
211+
};
212+
213+
const result = convertToParsedChatCompletionResponse(
214+
truncatedResponse,
215+
MathDemonstration,
216+
);
217+
218+
expect(result.choices).toHaveLength(1);
219+
expect(result.choices![0].message).toBeDefined();
220+
expect(result.choices![0].message!.parsed).toBeUndefined();
221+
expect(result.choices![0].message!.content).toBe(
222+
'{"steps": [{"explanation": "truncated',
223+
);
224+
});
225+
226+
it("should set parsed to undefined when content is valid JSON but fails schema validation", () => {
227+
const wrongShapeResponse: components.ChatCompletionResponse = {
228+
id: "test-wrong-shape",
229+
object: "chat.completion",
230+
model: "mistral-tiny-latest",
231+
usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 },
232+
created: 1700000000,
233+
choices: [
234+
{
235+
index: 0,
236+
message: {
237+
content: '{"wrong_field": "not matching schema"}',
238+
toolCalls: null,
239+
prefix: false,
240+
role: "assistant",
241+
},
242+
finishReason: "stop",
243+
},
244+
],
245+
};
246+
247+
const result = convertToParsedChatCompletionResponse(
248+
wrongShapeResponse,
249+
MathDemonstration,
250+
);
251+
252+
expect(result.choices).toHaveLength(1);
253+
expect(result.choices![0].message).toBeDefined();
254+
expect(result.choices![0].message!.parsed).toBeUndefined();
255+
});
256+
257+
it("should preserve choices when content is null", () => {
258+
const nullContentResponse: components.ChatCompletionResponse = {
259+
id: "test-null-content",
260+
object: "chat.completion",
261+
model: "mistral-tiny-latest",
262+
usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 },
263+
created: 1700000000,
264+
choices: [
265+
{
266+
index: 0,
267+
message: {
268+
content: null,
269+
toolCalls: null,
270+
prefix: false,
271+
role: "assistant",
272+
},
273+
finishReason: "stop",
274+
},
275+
],
276+
};
277+
278+
const result = convertToParsedChatCompletionResponse(
279+
nullContentResponse,
280+
MathDemonstration,
281+
);
282+
283+
expect(result.choices).toHaveLength(1);
284+
expect(result.choices![0].message).toBeDefined();
285+
expect(result.choices![0].message!.parsed).toBeUndefined();
286+
});
287+
288+
it("should return empty choices array for empty choices", () => {
289+
const emptyChoicesResponse: components.ChatCompletionResponse = {
290+
id: "test-empty",
291+
object: "chat.completion",
292+
model: "mistral-tiny-latest",
293+
usage: { promptTokens: 10, completionTokens: 0, totalTokens: 10 },
294+
created: 1700000000,
295+
choices: [],
296+
};
297+
298+
const result = convertToParsedChatCompletionResponse(
299+
emptyChoicesResponse,
300+
MathDemonstration,
301+
);
302+
303+
expect(result.choices).toEqual([]);
304+
});
305+
306+
it("should return undefined choices when choices is undefined", () => {
307+
const noChoicesResponse: components.ChatCompletionResponse = {
308+
id: "test-undefined",
309+
object: "chat.completion",
310+
model: "mistral-tiny-latest",
311+
usage: { promptTokens: 10, completionTokens: 0, totalTokens: 10 },
312+
created: 1700000000,
313+
};
314+
315+
const result = convertToParsedChatCompletionResponse(
316+
noChoicesResponse,
317+
MathDemonstration,
318+
);
319+
320+
expect(result.choices).toBeUndefined();
321+
});
191322
});
192323

193324
describe("responseFormatFromZodObject", () => {

0 commit comments

Comments
 (0)