Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
47 changes: 47 additions & 0 deletions packages/ai/__tests__/strip-json-code-blocks.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
31 changes: 31 additions & 0 deletions packages/ai/src/adapters/openai-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/ai/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export {
parseFinishReason,
parseUsage,
stripThinkTags,
stripJsonCodeBlock,
type StripThinkTagsResult,
type OpenAIMessage,
type OpenAIToolCall,
Expand Down
Loading