Skip to content
Open
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
9 changes: 9 additions & 0 deletions src/__fixtures__/interfaze-json-fence-stream.chunks.txt
Original file line number Diff line number Diff line change
@@ -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"}
22 changes: 22 additions & 0 deletions src/interfaze-ai-sdk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
31 changes: 31 additions & 0 deletions src/interfaze-chat-language-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
21 changes: 15 additions & 6 deletions src/interfaze-chat-language-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
} from './interfaze-chat-language-model-options';
import { injectInterfazeFileSentinels } from './interfaze-file-parts';
import {
JsonFenceFilter,
SideChannelFilter,
stripJsonFence,
stripSideChannels,
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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);
Expand Down
34 changes: 34 additions & 0 deletions src/side-channels.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import {
JsonFenceFilter,
SideChannelFilter,
stripJsonFence,
stripSideChannels,
Expand Down Expand Up @@ -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('');
});
});
56 changes: 56 additions & 0 deletions src/side-channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ['<think>', '<precontext>'] as const;
const SIDE_CLOSE: Record<string, string> = {
'<think>': '</think>',
Expand Down