diff --git a/packages/ai/__tests__/strip-json-code-blocks.test.ts b/packages/ai/__tests__/strip-json-code-blocks.test.ts new file mode 100644 index 0000000..4408feb --- /dev/null +++ b/packages/ai/__tests__/strip-json-code-blocks.test.ts @@ -0,0 +1,47 @@ +/** + * Tests for stripJsonCodeBlock — strips markdown JSON code fences. + */ + +import { describe, it, expect } from "vitest"; +import { stripJsonCodeBlock } from "../src/adapters/openai-adapter.js"; + +describe("stripJsonCodeBlock", () => { + it("strips ```json code fences", () => { + const input = '```json\n{"name": "Alice", "age": 30}\n```'; + expect(stripJsonCodeBlock(input)).toBe('{"name": "Alice", "age": 30}'); + }); + + it("strips ``` code fences without language tag", () => { + const input = '```\n{"name": "Bob"}\n```'; + expect(stripJsonCodeBlock(input)).toBe('{"name": "Bob"}'); + }); + + it("strips fences with extra whitespace", () => { + const input = '```json \n {"key": "value"} \n ``` '; + expect(stripJsonCodeBlock(input)).toBe('{"key": "value"}'); + }); + + it("handles multiline JSON in code block", () => { + const input = '```json\n{\n "name": "Alice",\n "age": 30\n}\n```'; + expect(stripJsonCodeBlock(input)).toBe('{\n "name": "Alice",\n "age": 30\n}'); + }); + + it("returns plain JSON unchanged", () => { + const input = '{"name": "Alice"}'; + expect(stripJsonCodeBlock(input)).toBe('{"name": "Alice"}'); + }); + + it("returns non-JSON text unchanged", () => { + const input = "Hello, world!"; + expect(stripJsonCodeBlock(input)).toBe("Hello, world!"); + }); + + it("handles empty input", () => { + expect(stripJsonCodeBlock("")).toBe(""); + }); + + it("does not strip if code block is not the whole input", () => { + const input = 'Here is the JSON:\n```json\n{"key": "value"}\n```\nDone.'; + expect(stripJsonCodeBlock(input)).toBe(input); + }); +}); diff --git a/packages/ai/src/adapters/openai-adapter.ts b/packages/ai/src/adapters/openai-adapter.ts index 78f3d6f..7fd0968 100644 --- a/packages/ai/src/adapters/openai-adapter.ts +++ b/packages/ai/src/adapters/openai-adapter.ts @@ -25,6 +25,32 @@ import { TimeoutError, } from "../errors.js"; +// --------------------------------------------------------------------------- +// JSON code-block stripping +// --------------------------------------------------------------------------- + +/** + * Strip markdown JSON code blocks from model output. + * + * Many LLMs wrap JSON responses in ```json ... ``` fences even when + * asked for plain JSON. This function detects and removes them. + * + * Handles: + * - ```json ... ``` + * - ``` ... ``` + * - Leading/trailing whitespace around the code block + */ +export function stripJsonCodeBlock(input: string): string { + const text = input.trim(); + // Match ```json ... ``` or ``` ... ``` + const codeBlockPattern = /^```(?:json)?\s*\n?([\s\S]*?)\n?```\s*$/; + const match = text.match(codeBlockPattern); + if (match) { + return match[1].trim(); + } + return text; +} + // --------------------------------------------------------------------------- // Think-tag stripping // --------------------------------------------------------------------------- @@ -473,6 +499,11 @@ export abstract class OpenAIAdapter implements ModelProvider { reasoning = result.reasoning; } + // Strip markdown code blocks when JSON response format is requested + if (text && options.responseFormat?.type === "json") { + text = stripJsonCodeBlock(text); + } + return { text, toolCalls, diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index b4379b6..9fa5b82 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -122,6 +122,7 @@ export { parseFinishReason, parseUsage, stripThinkTags, + stripJsonCodeBlock, type StripThinkTagsResult, type OpenAIMessage, type OpenAIToolCall,