Skip to content

Commit 4246e34

Browse files
roaminroMastra Code (openai/gpt-5.4)
andauthored
fix: preserve embedded json fences in structured output (#13948)
Co-authored-by: Mastra Code (openai/gpt-5.4) <noreply@mastra.ai>
1 parent f8c1bdd commit 4246e34

4 files changed

Lines changed: 62 additions & 11 deletions

File tree

.changeset/big-rats-smoke.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@mastra/core': patch
3+
---
4+
5+
Fixed structured output parsing when JSON string fields include fenced JSON examples.

packages/core/src/stream/base/output-format-handlers.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -851,6 +851,50 @@ describe('output-format-handlers', () => {
851851
expect(objectResultChunk).toBeDefined();
852852
expect(objectResultChunk?.object).toEqual({ title: 'Test', count: 5 });
853853
});
854+
855+
it('should preserve ```json fences inside valid JSON string values', async () => {
856+
const schema = z.object({
857+
response: z.string(),
858+
status: z.string(),
859+
});
860+
861+
const transformer = createObjectStreamTransformer({
862+
structuredOutput: { schema },
863+
});
864+
865+
const response = 'API example:\n```json\nPOST /v1/payments\n{\n "customerId": "cust_123"\n}\n```';
866+
867+
const streamParts: ChunkType<typeof schema>[] = [
868+
{
869+
type: 'text-delta',
870+
runId: 'test-run',
871+
from: ChunkFrom.AGENT,
872+
payload: {
873+
id: '1',
874+
text: JSON.stringify({ response, status: 'ok' }),
875+
},
876+
},
877+
{
878+
type: 'finish',
879+
runId: 'test-run',
880+
from: ChunkFrom.AGENT,
881+
payload: {
882+
stepResult: { reason: 'stop' },
883+
output: { usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 } },
884+
metadata: {},
885+
messages: { all: [], user: [], nonUser: [] },
886+
},
887+
},
888+
];
889+
890+
// @ts-expect-error - web/stream readable stream type error
891+
const stream = convertArrayToReadableStream(streamParts).pipeThrough(transformer);
892+
const chunks = await convertAsyncIterableToArray(stream);
893+
894+
const objectResultChunk = chunks.find(c => c?.type === 'object-result');
895+
expect(objectResultChunk).toBeDefined();
896+
expect(objectResultChunk?.object).toEqual({ response, status: 'ok' });
897+
});
854898
});
855899

856900
describe('unescaped newlines in JSON strings', () => {

packages/core/src/stream/base/output-format-handlers.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -258,16 +258,18 @@ abstract class BaseFormatHandler<OUTPUT = undefined> {
258258
}
259259
}
260260

261-
// Some LLMs wrap the JSON response in code blocks.
262-
// In that case, first try to extract content from complete code blocks
263-
if (processedText.includes('```json')) {
264-
const match = processedText.match(/```json\s*\n?([\s\S]*?)\n?\s*```/);
261+
// Some LLMs wrap the entire JSON response in a ```json code block.
262+
// Only unwrap when the accumulated text itself starts with that fence so
263+
// embedded examples inside valid JSON string values are preserved.
264+
const trimmedStart = processedText.trimStart();
265+
if (/^```json\b/.test(trimmedStart)) {
266+
const match = trimmedStart.match(/^```json\s*\n?([\s\S]*?)\n?\s*```\s*$/);
265267
if (match && match[1]) {
266268
// Complete code block found - use content between tags
267269
processedText = match[1].trim();
268270
} else {
269-
// No complete code block - just remove the opening ```json
270-
processedText = processedText.replace(/^```json\s*\n?/, '');
271+
// No complete code block yet - just remove the opening ```json prefix
272+
processedText = trimmedStart.replace(/^```json\s*\n?/, '');
271273
}
272274
}
273275

packages/playground/e2e/kitchen-sink/pnpm-lock.yaml

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)