From 733a08d368e92af9424e162a0b08034f31eda06c Mon Sep 17 00:00:00 2001 From: best Date: Fri, 3 Apr 2026 09:30:41 +0800 Subject: [PATCH] fix: strip markdown JSON code blocks in OpenAI adapter responseFormat Closes #54 When responseFormat.type is 'json', many LLMs return JSON wrapped in markdown code fences (\\\). This causes JSON.parse failures in consumers. Added stripJsonCodeBlock() utility that detects and removes the fencing, called in doGenerate() when responseFormat is set to json. Changes: - Add stripJsonCodeBlock() to openai-adapter.ts - Call it in doGenerate() when responseFormat.type === 'json' - Export stripJsonCodeBlock from @openlinkos/ai index - Add 8 unit tests for stripJsonCodeBlock All 1033 tests pass. --- .../__tests__/strip-json-code-blocks.test.ts | 47 +++++++++++++++++++ packages/ai/src/adapters/openai-adapter.ts | 31 ++++++++++++ packages/ai/src/index.ts | 1 + 3 files changed, 79 insertions(+) create mode 100644 packages/ai/__tests__/strip-json-code-blocks.test.ts 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,