Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
a3f1b24
fix(core): demote balanced inline thinking blocks instead of failing …
yiliang114 Aug 20, 2026
3cf2952
fix(core): process the initial content delta in the inline thinking p…
yiliang114 Aug 20, 2026
8b18049
fix(core): fail closed on embedded thinking tags after inline demotion
yiliang114 Aug 21, 2026
c831e41
fix(core): hold trailing tag suffixes after inline thinking demotion …
yiliang114 Aug 21, 2026
4a8a9cf
fix(core): fail closed on over-cap tag tails and add replay defense a…
yiliang114 Aug 21, 2026
b03880f
fix(core): harden post-demotion stream handling
yiliang114 Aug 21, 2026
c3d7739
fix(core): align finish/EOF tag-fragment policy across all paths (#9348)
yiliang114 Aug 21, 2026
3d5dd6e
test(core): exercise the cumulative-prefix strip branch after demotio…
yiliang114 Aug 21, 2026
cce8d63
test(core): pin the closing-tag branch of the finish/EOF tag-word reg…
yiliang114 Aug 21, 2026
cf2ad1b
fix(core): flush held response parts at clean stream EOF (#9348)
yiliang114 Aug 22, 2026
3e3ec10
fix(core): recognize post-demotion replays across leading-whitespace …
yiliang114 Aug 22, 2026
d4cc65f
fix(core): gate clean-EOF backstops on no finish chunk seen (#9348)
yiliang114 Aug 22, 2026
b6b67a5
fix(core): align pre-demotion cumulative replays with the held candid…
yiliang114 Aug 22, 2026
9505064
fix(core): close replay/redelivery recognition on the emission sequen…
yiliang114 Aug 22, 2026
c3f357f
fix(core): invert tag-carrying replay-drop direction and harden finis…
yiliang114 Aug 22, 2026
161a0d9
fix(core): re-assemble renormalized closing-tag re-sends on closing-s…
yiliang114 Aug 23, 2026
83e92e8
fix(core): append tag-carrying replays and nested-block superset delt…
yiliang114 Aug 23, 2026
40eae28
fix(core): assign emittedLength instead of accumulating it in the tag…
yiliang114 Aug 23, 2026
5b057c1
fix(core): key held-candidate strip-vs-append on the normalizer regim…
yiliang114 Aug 23, 2026
a260129
fix(core): skip channel accumulation for post-finish redelivered chun…
yiliang114 Aug 23, 2026
550f541
fix(core): converge the tag-hold guards on the exact-repeat and prefi…
yiliang114 Aug 23, 2026
f36de57
chore: merge latest main for CI
yiliang114 Aug 23, 2026
4e44294
Merge origin/main into fix/issue-9348-thinking-tag-leak
yiliang114 Aug 28, 2026
6043697
fix(core): follow converter rename to keep CI typecheck green
yiliang114 Aug 28, 2026
592345d
fix(core): close finish-time tag-word release and entrance-3 trailing…
yiliang114 Aug 30, 2026
67a1f08
Merge branch 'main' into fix/issue-9348-thinking-tag-leak
yiliang114 Aug 30, 2026
3103ed7
Merge branch 'main' into fix/issue-9348-thinking-tag-leak
yiliang114 Aug 31, 2026
645152b
revert(core): drop speculative thinking-tag demotion
yiliang114 Aug 31, 2026
a97b562
fix(core): parse tagged thinking for Qwen3 models
yiliang114 Sep 1, 2026
04e679e
fix(core): activate Qwen3 tag parsing lazily
yiliang114 Sep 1, 2026
c9beb54
fix(core): suppress replayed Qwen3 tag snapshots
yiliang114 Sep 1, 2026
a4772ab
Merge branch 'main' into fix/issue-9348-thinking-tag-leak
yiliang114 Sep 1, 2026
24add73
fix(core): suppress long Qwen3 tag snapshot replays
yiliang114 Sep 1, 2026
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
385 changes: 385 additions & 0 deletions packages/core/src/core/openaiContentGenerator/converter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -571,6 +571,391 @@ describe('OpenAIContentConverter', () => {
);
});

// issue #9348: hybrid-thinking models (qwen3-class via OpenAI-compatible
// proxies) stream a first thinking phase through reasoning_content and may
// then emit a second, properly balanced <thinking>...</thinking> block
// inside content. The balanced block is legitimate — rejecting it
// hard-fails the whole turn (retries regenerate the same shape), which
// surfaced to users as "[API Error: Model response leaked thinking tags.]".
it('demotes a balanced inline thinking block to the thought channel when reasoning is present (issue #9348)', () => {
const stream = withStreamParser();
stream.responseParsingOptions = { contentOnlyThinkingTagLeaks: true };

const reasoning = converter.convertOpenAIChunkToGemini(
streamChunk('reasoning', { reasoning_content: 'Let me think.' }),
stream,
);
const opening = converter.convertOpenAIChunkToGemini(
streamChunk('opening', { content: '<thinking>' }),
stream,
);
const closing = converter.convertOpenAIChunkToGemini(
streamChunk('closing', { content: 'user asked X</thinking>' }),
stream,
);
const answer = converter.convertOpenAIChunkToGemini(
streamChunk('answer', { content: 'Answer here.' }, 'stop'),
stream,
);

expect(reasoning.candidates?.[0]?.content?.parts).toEqual([
{ thought: true, text: 'Let me think.' },
]);
expect(opening.candidates?.[0]?.content?.parts).toEqual([]);
expect(closing.candidates?.[0]?.content?.parts).toEqual([
{ thought: true, text: 'user asked X' },
]);
const answerParts = answer.candidates?.[0]?.content?.parts ?? [];
expect(answerParts).toEqual([{ text: 'Answer here.' }]);
expect(stream.pendingThinkingTagCandidate).toBeUndefined();
});

it('demotes a balanced inline thinking block split across chunks (issue #9348)', () => {
const stream = withStreamParser();
stream.responseParsingOptions = { contentOnlyThinkingTagLeaks: true };

converter.convertOpenAIChunkToGemini(
streamChunk('reasoning', { reasoning_content: 'phase one' }),
stream,
);
const first = converter.convertOpenAIChunkToGemini(
streamChunk('tag-start', { content: '<thi' }),
stream,
);
const second = converter.convertOpenAIChunkToGemini(
streamChunk('tag-body', { content: 'nking>recheck</thi' }),
stream,
);
const third = converter.convertOpenAIChunkToGemini(
streamChunk('tag-close', { content: 'nking>Done.' }, 'stop'),
stream,
);

expect(first.candidates?.[0]?.content?.parts).toEqual([]);
expect(second.candidates?.[0]?.content?.parts).toEqual([]);
const parts = third.candidates?.[0]?.content?.parts ?? [];
expect(parts).toEqual([
{ thought: true, text: 'recheck' },
{ text: 'Done.' },
]);
expect(stream.pendingThinkingTagCandidate).toBeUndefined();
});

it('demotes a balanced inline thinking block that arrives in a single content chunk (issue #9348)', () => {
// Chunking-dependent shape: the first content delta already carries the
// complete balanced block, so no pending tag candidate exists yet when
// the inline parser first sees the opening tag.
const stream = withStreamParser();
stream.responseParsingOptions = { contentOnlyThinkingTagLeaks: true };

converter.convertOpenAIChunkToGemini(
streamChunk('reasoning', { reasoning_content: 'Let me think.' }),
stream,
);
const block = converter.convertOpenAIChunkToGemini(
streamChunk('block', {
content: '<thinking>user asked X</thinking>',
}),
stream,
);
const answer = converter.convertOpenAIChunkToGemini(
streamChunk('answer', { content: 'Answer here.' }, 'stop'),
stream,
);

expect(block.candidates?.[0]?.content?.parts).toEqual([
{ thought: true, text: 'user asked X' },
]);
expect(answer.candidates?.[0]?.content?.parts).toEqual([
{ text: 'Answer here.' },
]);
expect(stream.pendingThinkingTagCandidate).toBeUndefined();
});

it('holds an unclosed inline thinking block that starts in the first content chunk (issue #9348)', () => {
// Same initial-delta path, but the block does not balance mid-stream:
// it must be held for a closing tag, then rejected at stream end.
const stream = withStreamParser();
stream.responseParsingOptions = { contentOnlyThinkingTagLeaks: true };

converter.convertOpenAIChunkToGemini(
streamChunk('reasoning', { reasoning_content: 'Let me think.' }),
stream,
);
const opening = converter.convertOpenAIChunkToGemini(
streamChunk('opening', { content: '<thinking>user asked X' }),
stream,
);

expect(opening.candidates?.[0]?.content?.parts).toEqual([]);
expect(stream.pendingThinkingTagCandidate).toEqual({
text: '<thinking>user asked X',
});
expect(() => finishStream(stream, 'stop')).toThrowError(
expect.objectContaining({ type: 'PROTOCOL_TAG_LEAK' }),
);
});

it('still rejects an unclosed inline thinking block after reasoning (issue #9348 regression guard)', () => {
// The fix must not weaken real leak detection: an opening tag that is
// never balanced by a closing tag remains a PROTOCOL_TAG_LEAK.
const stream = withStreamParser();
stream.responseParsingOptions = { contentOnlyThinkingTagLeaks: true };

converter.convertOpenAIChunkToGemini(
streamChunk('reasoning', { reasoning_content: 'Let me think.' }),
stream,
);
converter.convertOpenAIChunkToGemini(
streamChunk('opening', { content: '<thinking>' }),
stream,
);
converter.convertOpenAIChunkToGemini(
streamChunk('body', { content: 'user asked X' }),
stream,
);

expect(() => finishStream(stream, 'stop')).toThrowError(
expect.objectContaining({ type: 'PROTOCOL_TAG_LEAK' }),
);
});

it('demotes an empty balanced inline thinking block instead of failing the turn (issue #9348)', () => {
// An empty (whitespace-only) balanced block still balances. Treating it
// as "no block balanced" re-held the opener and hard-failed at finish
// with the exact PROTOCOL_TAG_LEAK error this fix removes.
const stream = withStreamParser();
stream.responseParsingOptions = { contentOnlyThinkingTagLeaks: true };

converter.convertOpenAIChunkToGemini(
streamChunk('reasoning', { reasoning_content: 'Let me think.' }),
stream,
);
const answer = converter.convertOpenAIChunkToGemini(
streamChunk(
'block',
{ content: '<thinking></thinking>Answer here.' },
'stop',
),
stream,
);

expect(answer.candidates?.[0]?.content?.parts).toEqual([
{ text: 'Answer here.' },
]);
expect(stream.pendingThinkingTagCandidate).toBeUndefined();
});

it('demotes a whitespace-only balanced inline thinking block split across chunks (issue #9348)', () => {
const stream = withStreamParser();
stream.responseParsingOptions = { contentOnlyThinkingTagLeaks: true };

converter.convertOpenAIChunkToGemini(
streamChunk('reasoning', { reasoning_content: 'Let me think.' }),
stream,
);
const opening = converter.convertOpenAIChunkToGemini(
streamChunk('opening', { content: '<thinking>\n' }),
stream,
);
const answer = converter.convertOpenAIChunkToGemini(
streamChunk('closing', { content: '</thinking>Answer here.' }, 'stop'),
stream,
);

expect(opening.candidates?.[0]?.content?.parts).toEqual([]);
expect(answer.candidates?.[0]?.content?.parts).toEqual([
{ text: 'Answer here.' },
]);
});

it('demotes consecutive balanced inline thinking blocks (issue #9348)', () => {
const stream = withStreamParser();
stream.responseParsingOptions = { contentOnlyThinkingTagLeaks: true };

converter.convertOpenAIChunkToGemini(
streamChunk('reasoning', { reasoning_content: 'Let me think.' }),
stream,
);
const blocks = converter.convertOpenAIChunkToGemini(
streamChunk(
'blocks',
{ content: '<thinking>a</thinking><thinking>b</thinking>Done.' },
'stop',
),
stream,
);

expect(blocks.candidates?.[0]?.content?.parts).toEqual([
{ thought: true, text: 'a' },
{ thought: true, text: 'b' },
{ text: 'Done.' },
]);
});

it('holds a trailing tag prefix after a demoted block until it balances (issue #9348)', () => {
// The only branch that keeps a trailing partial tag held after a
// successful demotion: it must stay held mid-stream and demote once the
// second block balances.
const stream = withStreamParser();
stream.responseParsingOptions = { contentOnlyThinkingTagLeaks: true };

converter.convertOpenAIChunkToGemini(
streamChunk('reasoning', { reasoning_content: 'Let me think.' }),
stream,
);
const first = converter.convertOpenAIChunkToGemini(
streamChunk('first-block', { content: '<thinking>a</thinking><thi' }),
stream,
);

expect(first.candidates?.[0]?.content?.parts).toEqual([]);
expect(stream.pendingThinkingTagCandidate).toEqual({ text: '<thi' });

const second = converter.convertOpenAIChunkToGemini(
streamChunk(
'second-block',
{ content: 'nking>b</thinking>Done.' },
'stop',
),
stream,
);

expect(second.candidates?.[0]?.content?.parts).toEqual([
{ thought: true, text: 'a' },
{ thought: true, text: 'b' },
{ text: 'Done.' },
]);
});

it('rejects an embedded thinking block after a demoted leading block (issue #9348 fail-closed)', () => {
// The demotion mechanism exists to stop raw thinking tags from reaching
// visible output: a complete tag inside the tail released after a
// demotion is embedded/stray and must fail closed like pre-PR.
const stream = withStreamParser();
stream.responseParsingOptions = { contentOnlyThinkingTagLeaks: true };

converter.convertOpenAIChunkToGemini(
streamChunk('reasoning', { reasoning_content: 'Let me think.' }),
stream,
);

expect(() =>
converter.convertOpenAIChunkToGemini(
streamChunk(
'embedded',
{
content:
'<thinking>a</thinking>Answer <thinking>b</thinking> done',
},
'stop',
),
stream,
),
).toThrowError(expect.objectContaining({ type: 'PROTOCOL_TAG_LEAK' }));
});

it('rejects embedded thinking tags arriving after visible content was already emitted (issue #9348 fail-closed)', () => {
// Chunk-split twin of the interleaved shape: once visible content
// exists the candidate machinery no longer applies, so the later chunk
// needs its own fail-closed gate.
const stream = withStreamParser();
stream.responseParsingOptions = { contentOnlyThinkingTagLeaks: true };

converter.convertOpenAIChunkToGemini(
streamChunk('reasoning', { reasoning_content: 'Let me think.' }),
stream,
);
const first = converter.convertOpenAIChunkToGemini(
streamChunk('leading-block', {
content: '<thinking>a</thinking>Answer ',
}),
stream,
);

expect(first.candidates?.[0]?.content?.parts).toEqual([
{ thought: true, text: 'a' },
{ text: 'Answer ' },
]);
expect(() =>
converter.convertOpenAIChunkToGemini(
streamChunk(
'embedded',
{ content: '<thinking>b</thinking> done' },
'stop',
),
stream,
),
).toThrowError(expect.objectContaining({ type: 'PROTOCOL_TAG_LEAK' }));
});

it('rejects a stray closing tag after a demoted leading block (issue #9348 fail-closed)', () => {
const stream = withStreamParser();
stream.responseParsingOptions = { contentOnlyThinkingTagLeaks: true };

converter.convertOpenAIChunkToGemini(
streamChunk('reasoning', { reasoning_content: 'Let me think.' }),
stream,
);

expect(() =>
converter.convertOpenAIChunkToGemini(
streamChunk(
'stray-closer',
{ content: '<thinking>a</thinking>b</thinking>' },
'stop',
),
stream,
),
).toThrowError(expect.objectContaining({ type: 'PROTOCOL_TAG_LEAK' }));
});

it('rejects a truncated tag prefix after a balanced block at stream finish (issue #9348 fail-closed)', () => {
// '<thi' classifies as pending, never leaked, and the finish-time
// leading-tag check needs a complete opener — without a dedicated
// fail-closed check the fragment would be released as visible text
// while the chunk-split twin throws.
const stream = withStreamParser();
stream.responseParsingOptions = { contentOnlyThinkingTagLeaks: true };

converter.convertOpenAIChunkToGemini(
streamChunk('reasoning', { reasoning_content: 'Let me think.' }),
stream,
);

expect(() =>
converter.convertOpenAIChunkToGemini(
streamChunk(
'truncated-prefix',
{ content: '<thinking>a</thinking><thi' },
'stop',
),
stream,
),
).toThrowError(expect.objectContaining({ type: 'PROTOCOL_TAG_LEAK' }));
});

it('rejects a split truncated tag prefix after a balanced block at stream finish (issue #9348 fail-closed)', () => {
// The tail re-held after a demotion must fail closed at finish instead
// of being released as a literal by the undecided-prefix release path.
const stream = withStreamParser();
stream.responseParsingOptions = { contentOnlyThinkingTagLeaks: true };

converter.convertOpenAIChunkToGemini(
streamChunk('reasoning', { reasoning_content: 'Let me think.' }),
stream,
);
const first = converter.convertOpenAIChunkToGemini(
streamChunk('first-block', { content: '<thinking>a</thinking><thi' }),
stream,
);

expect(first.candidates?.[0]?.content?.parts).toEqual([]);
expect(() => finishStream(stream, 'stop')).toThrowError(
expect.objectContaining({ type: 'PROTOCOL_TAG_LEAK' }),
);
});

it('holds a long confirmed opening tag until its closing tag arrives', () => {
const stream = withStreamParser();
stream.responseParsingOptions = { contentOnlyThinkingTagLeaks: true };
Expand Down
Loading
Loading