From 93376326c8e590a0e33e3df0ff0a8f489bf33038 Mon Sep 17 00:00:00 2001 From: Abhishek B R Date: Wed, 30 Sep 2026 23:49:11 +0530 Subject: [PATCH] fix: unwrap the json fence when streaming in schema-less JSON mode --- .../interfaze-json-fence-stream.chunks.txt | 9 +++ src/interfaze-ai-sdk.test.ts | 22 ++++++++ src/interfaze-chat-language-model.test.ts | 31 ++++++++++ src/interfaze-chat-language-model.ts | 21 +++++-- src/side-channels.test.ts | 34 +++++++++++ src/side-channels.ts | 56 +++++++++++++++++++ 6 files changed, 167 insertions(+), 6 deletions(-) create mode 100644 src/__fixtures__/interfaze-json-fence-stream.chunks.txt diff --git a/src/__fixtures__/interfaze-json-fence-stream.chunks.txt b/src/__fixtures__/interfaze-json-fence-stream.chunks.txt new file mode 100644 index 0000000..f76c071 --- /dev/null +++ b/src/__fixtures__/interfaze-json-fence-stream.chunks.txt @@ -0,0 +1,9 @@ +{"id":"chatcmpl-interfaze-json-fence-stream","choices":[{"index":0,"delta":{"role":"assistant"}}],"created":1780000011,"model":"interfaze","object":"chat.completion.chunk"} +{"id":"chatcmpl-interfaze-json-fence-stream","choices":[{"index":0,"delta":{"content":"``"}}],"created":1780000011,"model":"interfaze","object":"chat.completion.chunk"} +{"id":"chatcmpl-interfaze-json-fence-stream","choices":[{"index":0,"delta":{"content":"`js"}}],"created":1780000011,"model":"interfaze","object":"chat.completion.chunk"} +{"id":"chatcmpl-interfaze-json-fence-stream","choices":[{"index":0,"delta":{"content":"on\n{\"res"}}],"created":1780000011,"model":"interfaze","object":"chat.completion.chunk"} +{"id":"chatcmpl-interfaze-json-fence-stream","choices":[{"index":0,"delta":{"content":"ult\":\"2026\"}"}}],"created":1780000011,"model":"interfaze","object":"chat.completion.chunk"} +{"id":"chatcmpl-interfaze-json-fence-stream","choices":[{"index":0,"delta":{"content":"\n`"}}],"created":1780000011,"model":"interfaze","object":"chat.completion.chunk"} +{"id":"chatcmpl-interfaze-json-fence-stream","choices":[{"index":0,"delta":{"content":"``"}}],"created":1780000011,"model":"interfaze","object":"chat.completion.chunk"} +{"id":"chatcmpl-interfaze-json-fence-stream","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"created":1780000011,"model":"interfaze","object":"chat.completion.chunk"} +{"id":"chatcmpl-interfaze-json-fence-stream","choices":[],"usage":{"prompt_tokens":5,"completion_tokens":6,"total_tokens":11},"vcache":false,"created":1780000011,"model":"interfaze","object":"chat.completion.chunk"} diff --git a/src/interfaze-ai-sdk.test.ts b/src/interfaze-ai-sdk.test.ts index 31714e7..2bfb39c 100644 --- a/src/interfaze-ai-sdk.test.ts +++ b/src/interfaze-ai-sdk.test.ts @@ -96,6 +96,28 @@ describe('streamText', () => { }); }); +describe('streamText + Output.json', () => { + // Schema-less JSON mode, the shape the run-task docs use. Interfaze wraps + // the object in a ```json fence, which generateText already unwraps. + it('parses the object from a fenced stream', async () => { + const { partialOutputStream, output } = streamText({ + model: modelWith( + createStreamFixtureFetchMock('interfaze-json-fence-stream'), + ), + output: Output.json(), + prompt: 'Magic number as {result}.', + }); + + const partials: unknown[] = []; + for await (const partial of partialOutputStream) { + partials.push(partial); + } + + expect(partials.length).toBeGreaterThan(0); + expect(await output).toEqual({ result: '2026' }); + }); +}); + describe('generateText + Output.object', () => { it('returns the typed output alongside interfaze metadata', async () => { const { output, finalStep } = await generateText({ diff --git a/src/interfaze-chat-language-model.test.ts b/src/interfaze-chat-language-model.test.ts index f23da2f..348ccd1 100644 --- a/src/interfaze-chat-language-model.test.ts +++ b/src/interfaze-chat-language-model.test.ts @@ -240,6 +240,37 @@ describe('doStream', () => { }); }); +describe('doStream in schema-less JSON mode', () => { + it('unwraps a ```json fence split across chunk boundaries', async () => { + const fetch = createStreamFixtureFetchMock('interfaze-json-fence-stream'); + const model = modelWith(fetch); + + const { stream } = await model.doStream({ + prompt: TEST_PROMPT, + responseFormat: { type: 'json' }, + }); + const chunks = await convertStreamToArray(stream); + + expect(visibleText(chunks)).toBe('{"result":"2026"}'); + }); + + it('does not unwrap the fence when a schema is present (json_schema mode)', async () => { + const fetch = createStreamFixtureFetchMock('interfaze-json-fence-stream'); + const model = modelWith(fetch); + + const { stream } = await model.doStream({ + prompt: TEST_PROMPT, + responseFormat: { + type: 'json', + schema: { type: 'object', properties: { result: { type: 'string' } } }, + }, + }); + const chunks = await convertStreamToArray(stream); + + expect(visibleText(chunks)).toBe('```json\n{"result":"2026"}\n```'); + }); +}); + describe('file-part sentinel hardening', () => { it('does not convert attacker text that mimics the sentinel into a file part', async () => { const { fetch, requests } = createCapturingFetchMock('interfaze-basic'); diff --git a/src/interfaze-chat-language-model.ts b/src/interfaze-chat-language-model.ts index 0d886ee..84bfbd3 100644 --- a/src/interfaze-chat-language-model.ts +++ b/src/interfaze-chat-language-model.ts @@ -20,6 +20,7 @@ import { } from './interfaze-chat-language-model-options'; import { injectInterfazeFileSentinels } from './interfaze-file-parts'; import { + JsonFenceFilter, SideChannelFilter, stripJsonFence, stripSideChannels, @@ -157,6 +158,12 @@ export class InterfazeChatLanguageModel }); const filter = new SideChannelFilter(); + // Same unwrap doGenerate does, applied as the text streams, so partial + // and final JSON parsing in `streamText` see the bare object. + const fence = isFencedJsonMode(options.responseFormat) + ? new JsonFenceFilter() + : undefined; + const unfence = (text: string) => (fence ? fence.feed(text) : text); let rawAccumulated = ''; let emittedAccumulated = ''; let textId: string | undefined; @@ -173,9 +180,10 @@ export class InterfazeChatLanguageModel if (part.delta.length > 0) { rawAccumulated += part.delta; const visible = filter.feed(part.delta); - if (visible.length > 0) { - emittedAccumulated += visible; - controller.enqueue({ ...part, delta: visible }); + emittedAccumulated += visible; + const delta = unfence(visible); + if (delta.length > 0) { + controller.enqueue({ ...part, delta }); } } return; @@ -184,12 +192,13 @@ export class InterfazeChatLanguageModel if (part.type === 'text-end') { textId = part.id; const tail = filter.flush(); - if (tail.length > 0) { - emittedAccumulated += tail; + emittedAccumulated += tail; + const delta = unfence(tail) + (fence?.flush() ?? ''); + if (delta.length > 0) { controller.enqueue({ type: 'text-delta', id: part.id, - delta: tail, + delta, }); } controller.enqueue(part); diff --git a/src/side-channels.test.ts b/src/side-channels.test.ts index 30f0cef..30ec4c4 100644 --- a/src/side-channels.test.ts +++ b/src/side-channels.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { + JsonFenceFilter, SideChannelFilter, stripJsonFence, stripSideChannels, @@ -136,3 +137,36 @@ describe('SideChannelFilter', () => { expect(filter.flush()).toBe(''); }); }); + +describe('JsonFenceFilter', () => { + const run = (...parts: string[]) => { + const filter = new JsonFenceFilter(); + return parts.map(part => filter.feed(part)).join('') + filter.flush(); + }; + + it('unwraps a fence delivered in a single chunk', () => { + expect(run('```json\n{"result":"2026"}\n```')).toBe('{"result":"2026"}'); + }); + + it('unwraps a fence whose markers are split across chunks', () => { + expect(run('``', '`js', 'on\n{"a":', '1}\n`', '``')).toBe('{"a":1}'); + }); + + it('unwraps a bare ``` fence', () => { + expect(run('```\n', '{"a":1}', '\n```\n')).toBe('{"a":1}'); + }); + + it('passes unfenced content through as it arrives', () => { + const filter = new JsonFenceFilter(); + expect(filter.feed('{"a":')).toBe('{"a":'); + expect(filter.feed('"`x`"}')).toBe('"`x`"}'); + expect(filter.flush()).toBe(''); + }); + + it('releases backticks held at a chunk edge when more content follows', () => { + const filter = new JsonFenceFilter(); + expect(filter.feed('```json\n{"a":"x`')).toBe('{"a":"x'); + expect(filter.feed('"}')).toBe('`"}'); + expect(filter.flush()).toBe(''); + }); +}); diff --git a/src/side-channels.ts b/src/side-channels.ts index 87f6f0c..8a1da54 100644 --- a/src/side-channels.ts +++ b/src/side-channels.ts @@ -70,6 +70,62 @@ export function stripJsonFence(content: string): string { .trim(); } +const OPENING_FENCE = /^```(?:json)?\s*/i; + +/** + * Streaming counterpart of {@link stripJsonFence}. Holds back the start of the + * stream until it can tell whether a fence opens it, then holds back any + * trailing whitespace and backticks, since those may be the closing fence. + * Content that doesn't open with a fence passes through untouched. + */ +export class JsonFenceFilter { + #state: 'start' | 'fenced' | 'plain' = 'start'; + #held = ''; + + feed(text: string): string { + if (this.#state === 'plain') { + return text; + } + + this.#held += text; + + if (this.#state === 'start') { + const trimmed = this.#held.trimStart(); + if (trimmed.length === 0 || '```'.startsWith(trimmed)) { + return ''; + } + if (!trimmed.startsWith('```')) { + this.#state = 'plain'; + const out = this.#held; + this.#held = ''; + return out; + } + const opening = OPENING_FENCE.exec(trimmed)![0]; + const rest = trimmed.slice(opening.length); + // Wait for the whole opening line: "```js" may still become "```json". + if ( + rest.length === 0 || + (opening === '```' && 'json'.startsWith(rest.toLowerCase())) + ) { + return ''; + } + this.#state = 'fenced'; + this.#held = rest; + } + + const tail = /[\s`]*$/.exec(this.#held)![0]; + const out = this.#held.slice(0, this.#held.length - tail.length); + this.#held = tail; + return out; + } + + flush(): string { + const held = this.#held; + this.#held = ''; + return this.#state === 'fenced' ? held.replace(/\s*```\s*$/, '') : held; + } +} + const SIDE_OPEN = ['', ''] as const; const SIDE_CLOSE: Record = { '': '',