From 647500caf6d08d7591d69a5c7bf093deca86bca2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=93=E8=89=AF?= <1204183885@qq.com> Date: Wed, 12 Aug 2026 14:30:17 +0800 Subject: [PATCH] fix(core): catch content-only thinking-tag leaks on all OpenAI-compatible providers (#8818) * fix(core): catch content-only thinking-tag leaks on all OpenAI-compatible providers Production captures (issue #6666) show hybrid-thinking models occasionally bypassing the reasoning channel and emitting their thinking as literal / text inside content. The content-only leak fallback only covered the DashScope provider, and its 128-char candidate cap released confirmed opening tags mid-stream, so real-world leaks (always longer than the cap, typically unclosed) still reached users. - Enable contentOnlyThinkingTagLeaks on DefaultOpenAICompatibleProvider so every OpenAI-compatible endpoint gets the conservative fallback (gated to turns that start with a thinking tag and carry no structured reasoning or prior visible content); drop the now-redundant DashScope override. - Classify an opening tag followed by content with no balancing closing tag as an unclosed thinking block: held mid-stream, rejected as PROTOCOL_TAG_LEAK at stream end. Whitespace-only tails stay undecided. - Exempt confirmed opening tags with real content from the length-cap release so long unclosed blocks are rejected instead of leaked. Adds regression tests replaying the sanitized production shape (red on the previous code) plus a control proving the provider gate. * fix(core): document the fail-closed trade-off and cover the over-cap throw Review follow-up: state honestly that a legitimate balanced literal longer than the candidate cap whose closing tag has not arrived yet is rejected along with real leaks (indistinguishable at that point), and add a regression test for the mid-stream fail-closed throw on over-cap confirmed opening tags. * fix(core): parse closed thinking tags by default * test(core): update DashScope parsing-options assertion after override removal The vendor-specific getResponseParsingOptions override was removed in the parent commit so DashScope inherits the default provider's options, which now include taggedThinkingTags alongside contentOnlyThinkingTagLeaks. * fix(core): reject nested unclosed thinking blocks * fix(core): handle nested thinking tags * fix(core): preserve generic thinking-tag compatibility * fix(core): defer thinking leak rejection until finish * fix(core): note streaming-only scope of thinking-leak defense --- .../openaiContentGenerator/converter.test.ts | 138 ++++++++++++++++-- .../core/openaiContentGenerator/converter.ts | 30 +++- .../provider/dashscope.ts | 6 - .../provider/default.test.ts | 8 + .../provider/default.ts | 10 ++ .../provider/minimax.ts | 2 +- 6 files changed, 170 insertions(+), 24 deletions(-) diff --git a/packages/core/src/core/openaiContentGenerator/converter.test.ts b/packages/core/src/core/openaiContentGenerator/converter.test.ts index 453c476436..9c65b85320 100644 --- a/packages/core/src/core/openaiContentGenerator/converter.test.ts +++ b/packages/core/src/core/openaiContentGenerator/converter.test.ts @@ -544,6 +544,77 @@ describe('OpenAIContentConverter', () => { ); }); + it('rejects the recorded production unclosed content leak (issue #6666)', () => { + // Production capture shape (sanitized): a hybrid-thinking model + // skipped the reasoning channel entirely and streamed its thinking as + // literal text inside content — no reasoning_content on + // any chunk, no tool calls, and the tag is never closed before stop. + const stream = withStreamParser(); + stream.responseParsingOptions = { contentOnlyThinkingTagLeaks: true }; + const opening = converter.convertOpenAIChunkToGemini( + streamChunk('opening', { content: '\nThe user wants to query the compute resources for ' + + 'project space 10088. Let me check the available APIs.', + }), + stream, + ); + + expect(opening.candidates?.[0]?.content?.parts).toEqual([]); + expect(body.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 }; + const text = `${'x'.repeat(200)}`; + + const opening = converter.convertOpenAIChunkToGemini( + streamChunk('long-balanced', { + content: `${'x'.repeat(200)}`, + }), + stream, + ); + const closing = converter.convertOpenAIChunkToGemini( + streamChunk('long-balanced', { content: '' }, 'stop'), + stream, + ); + + expect(opening.candidates?.[0]?.content?.parts).toEqual([]); + expect(closing.candidates?.[0]?.content?.parts).toEqual([{ text }]); + }); + + it('leaks the production shape without provider provenance', () => { + // Control for the test above: without contentOnlyThinkingTagLeaks the + // same stream passes through verbatim — the defense is provider-gated, + // so endpoints whose provider does not opt in remain exposed. + const stream = withStreamParser(); + const response = converter.convertOpenAIChunkToGemini( + streamChunk( + 'literal', + { + content: + '\nThe user wants to query the compute resources.', + }, + 'stop', + ), + stream, + ); + + expect(response.candidates?.[0]?.content?.parts).toEqual([ + { + text: '\nThe user wants to query the compute resources.', + }, + ]); + }); + it.each([ ['split literal block', ['literal']], ['empty block with a separate finish chunk', ['\n\n', '']], @@ -551,6 +622,7 @@ describe('OpenAIContentConverter', () => { 'two split valid blocks', ['\n\n', 'literal'], ], + ['long empty block', [`${' '.repeat(128)}`, '']], ])('preserves content-only %s', (_name, chunks) => { const stream = withStreamParser(); stream.responseParsingOptions = { contentOnlyThinkingTagLeaks: true }; @@ -570,24 +642,33 @@ describe('OpenAIContentConverter', () => { expect(parts.every((part) => part.thought !== true)).toBe(true); }); - it('releases a long undecided prefix before the stream finishes', () => { + it('releases a long unconfirmed prefix before the stream finishes', () => { const stream = withStreamParser(); stream.responseParsingOptions = { contentOnlyThinkingTagLeaks: true }; - const text = `${' '.repeat(257)}`; + const text = ` { + const stream = withStreamParser(); + stream.responseParsingOptions = { contentOnlyThinkingTagLeaks: true }; + const response = converter.convertOpenAIChunkToGemini( + streamChunk('unclosed', { + content: `${' '.repeat(128)}`, + }), stream, ); - expect(response.candidates?.[0]?.content?.parts).toEqual([{ text }]); - expect(continuation.candidates?.[0]?.content?.parts).toEqual([ - { text: 'literal' }, - ]); + expect(response.candidates?.[0]?.content?.parts).toEqual([]); + expect(() => finishStream(stream, 'stop')).toThrowError( + expect.objectContaining({ type: 'PROTOCOL_TAG_LEAK' }), + ); }); it('preserves a leak-shaped literal without provider provenance', () => { @@ -631,17 +712,38 @@ describe('OpenAIContentConverter', () => { expect(parts.map((part) => part.text).join('')).toBe(chunks.join('')); }); - it('fails closed when a suspicious prefix exceeds the buffer limit', () => { + it('rejects an unclosed outer block containing a balanced nested block', () => { + const stream = withStreamParser(); + stream.responseParsingOptions = { contentOnlyThinkingTagLeaks: true }; + + expect(() => + converter.convertOpenAIChunkToGemini( + streamChunk( + 'nested-unclosed', + { + content: 'innerouter text', + }, + 'stop', + ), + stream, + ), + ).toThrowError(expect.objectContaining({ type: 'PROTOCOL_TAG_LEAK' })); + }); + + it('fails closed for a long suspicious prefix at stream finish', () => { const stream = withStreamParser(); stream.responseParsingOptions = { contentOnlyThinkingTagLeaks: true }; const content = '9' + 'x'.repeat(257); - expect(() => - converter.convertOpenAIChunkToGemini( - streamChunk('long-leak', { content }), - stream, - ), - ).toThrowError(expect.objectContaining({ type: 'PROTOCOL_TAG_LEAK' })); + const response = converter.convertOpenAIChunkToGemini( + streamChunk('long-leak', { content }), + stream, + ); + + expect(response.candidates?.[0]?.content?.parts).toEqual([]); + expect(() => finishStream(stream, 'stop')).toThrowError( + expect.objectContaining({ type: 'PROTOCOL_TAG_LEAK' }), + ); }); it.each([ @@ -5079,6 +5181,7 @@ describe('OpenAIContentConverter', () => { it('should handle a single chunk delta with both reasoning_content and content simultaneously', () => { const ctx = withStreamParser(); + ctx.responseParsingOptions = { contentOnlyThinkingTagLeaks: true }; const part = converter.convertOpenAIChunkToGemini( { @@ -5215,7 +5318,10 @@ describe('OpenAIContentConverter', () => { }, ], } as unknown as OpenAI.Chat.ChatCompletion, - requestContext, + { + ...requestContext, + responseParsingOptions: { contentOnlyThinkingTagLeaks: true }, + }, ); expect(response.candidates?.[0]?.content?.parts).toEqual([ diff --git a/packages/core/src/core/openaiContentGenerator/converter.ts b/packages/core/src/core/openaiContentGenerator/converter.ts index 6585d0cc31..ffd8dfcfd6 100644 --- a/packages/core/src/core/openaiContentGenerator/converter.ts +++ b/packages/core/src/core/openaiContentGenerator/converter.ts @@ -1160,8 +1160,25 @@ function classifyContentOnlyThinkingTagPrefix( for (const closing of [false, true, false]) { const tagLength = consumeTag(rest, closing); if (tagLength === null) return 'pending'; - if (tagLength === undefined) return 'clean'; + if (tagLength === undefined) { + if (!closing) return 'clean'; + break; + } rest = rest.slice(tagLength).trimStart(); + if (closing && !rest) return 'clean'; + // An opening tag followed by ordinary text is only a legitimate literal + // if a closing tag still balances it later. Without one, the turn is an + // unclosed thinking block — the exact shape of the recorded production + // leaks (issue #6666). Hold it mid-stream (a closing tag may still + // arrive) and reject it once the stream finishes. Whitespace-only tails + // stay undecided: they may still resolve into a closing tag. + if ( + closing === false && + /\S/.test(rest) && + !/<\/think(?:ing)?\s*>/i.test(rest) + ) { + return streamFinished ? 'leaked' : 'pending'; + } } let depth = 1; @@ -1564,8 +1581,18 @@ export function convertOpenAIChunkToGemini( Boolean(choice.finish_reason) && !closingTagName && !/\S/.test(combinedCandidateText); + // The length cap releases undecided prefixes (e.g. a literal " MAX_THINKING_TAG_CANDIDATE_LENGTH); @@ -1591,6 +1618,7 @@ export function convertOpenAIChunkToGemini( requestContext.pendingThinkingTagCandidate = undefined; } else if (isPossibleTag) { if ( + !confirmedOpeningTagCandidate && !closingTagName && combinedCandidateText.trimStart().length > MAX_THINKING_TAG_CANDIDATE_LENGTH diff --git a/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts b/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts index 5f42ae9d09..d375990ba3 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts @@ -15,7 +15,6 @@ import type { ChatCompletionContentPartWithCache, ChatCompletionToolWithCache, } from './types.js'; -import type { OpenAIResponseParsingOptions } from '../responseParsingOptions.js'; import { buildRuntimeFetchOptions } from '../../../utils/runtimeFetchOptions.js'; import { createDebugLogger } from '../../../utils/debugLogger.js'; import { @@ -166,11 +165,6 @@ export class DashScopeOpenAICompatibleProvider extends DefaultOpenAICompatiblePr super(contentGeneratorConfig, cliConfig); } - getResponseParsingOptions(): OpenAIResponseParsingOptions { - // ponytail: DashScope-only fallback; remove after provider output stabilizes. - return { contentOnlyThinkingTagLeaks: true }; - } - /** * Determines whether to use the DashScope-compatible provider. * Covers the official regional hosts (DASHSCOPE_REGIONAL_HOSTS), diff --git a/packages/core/src/core/openaiContentGenerator/provider/default.test.ts b/packages/core/src/core/openaiContentGenerator/provider/default.test.ts index ab8ce51253..052b2cfa61 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/default.test.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/default.test.ts @@ -93,6 +93,14 @@ describe('DefaultOpenAICompatibleProvider', () => { }); }); + describe('getResponseParsingOptions', () => { + it('enables leak handling without treating balanced tags as protocol', () => { + expect(provider.getResponseParsingOptions()).toEqual({ + contentOnlyThinkingTagLeaks: true, + }); + }); + }); + describe('buildHeaders', () => { it('should build headers with User-Agent', () => { const headers = provider.buildHeaders(); diff --git a/packages/core/src/core/openaiContentGenerator/provider/default.ts b/packages/core/src/core/openaiContentGenerator/provider/default.ts index 19000fc59c..6835bd38a3 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/default.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/default.ts @@ -4,6 +4,7 @@ import type { Config } from '../../../config/config.js'; import type { ContentGeneratorConfig } from '../../contentGenerator.js'; import { DEFAULT_MAX_RETRIES, resolveRequestTimeout } from '../constants.js'; import type { OpenAICompatibleProvider } from './types.js'; +import type { OpenAIResponseParsingOptions } from '../responseParsingOptions.js'; import { buildRuntimeFetchOptions } from '../../../utils/runtimeFetchOptions.js'; import { tokenLimit, @@ -123,6 +124,15 @@ export class DefaultOpenAICompatibleProvider return {}; } + getResponseParsingOptions(): OpenAIResponseParsingOptions { + // Hybrid-thinking models occasionally bypass the reasoning channel and + // emit their thinking as literal / tags inside content + // (observed in production on qwen3-class models, issue #6666). + // Honored on the streaming path only; non-streaming responses are + // not classified. + return { contentOnlyThinkingTagLeaks: true }; + } + /** * Apply output token limit to a request's max_tokens parameter. * diff --git a/packages/core/src/core/openaiContentGenerator/provider/minimax.ts b/packages/core/src/core/openaiContentGenerator/provider/minimax.ts index 97008e1a45..49b0974792 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/minimax.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/minimax.ts @@ -38,7 +38,7 @@ export class MiniMaxOpenAICompatibleProvider extends DefaultOpenAICompatibleProv } } - getResponseParsingOptions(): OpenAIResponseParsingOptions { + override getResponseParsingOptions(): OpenAIResponseParsingOptions { return { taggedThinkingTags: true }; } }