diff --git a/packages/core/src/core/openaiContentGenerator/converter.test.ts b/packages/core/src/core/openaiContentGenerator/converter.test.ts
index 76c31b08a5..a8eecc23d0 100644
--- a/packages/core/src/core/openaiContentGenerator/converter.test.ts
+++ b/packages/core/src/core/openaiContentGenerator/converter.test.ts
@@ -641,6 +641,61 @@ describe('OpenAIContentConverter', () => {
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: 'user asked X',
+ }),
+ 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: 'user asked X' }),
+ stream,
+ );
+
+ expect(opening.candidates?.[0]?.content?.parts).toEqual([]);
+ expect(stream.pendingThinkingTagCandidate).toEqual({
+ text: '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.
diff --git a/packages/core/src/core/openaiContentGenerator/converter.ts b/packages/core/src/core/openaiContentGenerator/converter.ts
index 3bf3022219..a1ce85b3da 100644
--- a/packages/core/src/core/openaiContentGenerator/converter.ts
+++ b/packages/core/src/core/openaiContentGenerator/converter.ts
@@ -1667,9 +1667,13 @@ export function convertOpenAIChunkToGemini(
// "[API Error: Model response leaked thinking tags.]" mid-session.
// Hold the block while it is incomplete, demote balanced block(s) to
// the thought channel, and keep rejecting blocks that never close.
+ // The inline path must also run on the initial content delta: when the
+ // first chunk already carries a complete opening tag (or a whole
+ // balanced block), no pending candidate exists yet, and falling
+ // through to the leaked-tag check would reject a valid shape that
+ // depends only on stream chunking.
const inlineOpeningTagCandidate =
hasStructuredReasoning &&
- pendingTagCandidate &&
!closingTagName &&
confirmedOpeningTagCandidate;