revert(core): revert GLM tagged thinking parsing (#6248)

This commit is contained in:
tanzhenxin 2026-07-03 15:56:27 +08:00 committed by GitHub
parent 183ad54b11
commit e3076e2990
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 46 additions and 638 deletions

View file

@ -3816,97 +3816,6 @@ describe('OpenAIContentConverter', () => {
]);
});
it('should convert GLM inline <think> content to thought parts', () => {
const response = converter.convertOpenAIResponseToGemini(
{
object: 'chat.completion',
id: 'chatcmpl-glm-1',
created: 123,
model: 'glm-5.2',
choices: [
{
index: 0,
message: {
role: 'assistant',
reasoning_content: '',
content:
'<think>The user is asking a simple logic puzzle.</think>LEAK_CHECK_FINAL: prize is in B',
},
finish_reason: 'stop',
logprobs: null,
},
],
} as unknown as OpenAI.Chat.ChatCompletion,
withTaggedThinkingOptions(),
);
expect(response.candidates?.[0]?.content?.parts).toEqual([
{
text: 'The user is asking a simple logic puzzle.',
thought: true,
},
{ text: 'LEAK_CHECK_FINAL: prize is in B' },
]);
});
it('should preserve reasoning_content when tagged parsing finds no thinking tags', () => {
const response = converter.convertOpenAIResponseToGemini(
{
object: 'chat.completion',
id: 'chatcmpl-glm-2',
created: 123,
model: 'glm-5.2',
choices: [
{
index: 0,
message: {
role: 'assistant',
reasoning_content: 'separate reasoning channel',
content: 'final answer',
},
finish_reason: 'stop',
logprobs: null,
},
],
} as unknown as OpenAI.Chat.ChatCompletion,
withTaggedThinkingOptions(),
);
expect(response.candidates?.[0]?.content?.parts).toEqual([
{ text: 'separate reasoning channel', thought: true },
{ text: 'final answer' },
]);
});
it('should not duplicate reasoning_content when content already has tagged thinking', () => {
const response = converter.convertOpenAIResponseToGemini(
{
object: 'chat.completion',
id: 'chatcmpl-glm-3',
created: 123,
model: 'glm-5.2',
choices: [
{
index: 0,
message: {
role: 'assistant',
reasoning_content: 'duplicate reasoning channel',
content: '<think>tagged reasoning</think>final answer',
},
finish_reason: 'stop',
logprobs: null,
},
],
} as unknown as OpenAI.Chat.ChatCompletion,
withTaggedThinkingOptions(),
);
expect(response.candidates?.[0]?.content?.parts).toEqual([
{ text: 'tagged reasoning', thought: true },
{ text: 'final answer' },
]);
});
it('should preserve ordering around <thinking> blocks', () => {
const response = converter.convertOpenAIResponseToGemini(
{
@ -4085,335 +3994,6 @@ describe('OpenAIContentConverter', () => {
]);
});
it('should suppress reasoning_content when the same streaming chunk has tagged thinking content', () => {
const context = withTaggedThinkingStreamParser();
const chunk = converter.convertOpenAIChunkToGemini(
{
object: 'chat.completion.chunk',
id: 'chunk-glm-dual-tagged',
created: 456,
choices: [
{
index: 0,
delta: {
reasoning_content: 'duplicate reasoning channel',
content: '<think>tagged reasoning</think>final answer',
},
finish_reason: 'stop',
logprobs: null,
},
],
model: 'glm-5.2',
} as unknown as OpenAI.Chat.ChatCompletionChunk,
context,
);
expect(chunk.candidates?.[0]?.content?.parts).toEqual([
{ text: 'tagged reasoning', thought: true },
{ text: 'final answer' },
]);
});
it('should suppress late reasoning_content after streaming tagged thinking content', () => {
const context = withTaggedThinkingStreamParser();
const firstChunk = converter.convertOpenAIChunkToGemini(
{
object: 'chat.completion.chunk',
id: 'chunk-glm-late-reasoning-1',
created: 456,
choices: [
{
index: 0,
delta: { content: '<think>tagged reasoning</think>' },
finish_reason: null,
logprobs: null,
},
],
model: 'glm-5.2',
} as unknown as OpenAI.Chat.ChatCompletionChunk,
context,
);
const secondChunk = converter.convertOpenAIChunkToGemini(
{
object: 'chat.completion.chunk',
id: 'chunk-glm-late-reasoning-2',
created: 457,
choices: [
{
index: 0,
delta: { reasoning_content: 'late reasoning' },
finish_reason: null,
logprobs: null,
},
],
model: 'glm-5.2',
} as unknown as OpenAI.Chat.ChatCompletionChunk,
context,
);
expect(firstChunk.candidates?.[0]?.content?.parts).toEqual([
{ text: 'tagged reasoning', thought: true },
]);
expect(secondChunk.candidates?.[0]?.content?.parts).toEqual([]);
});
it('should suppress buffered reasoning_content when later streaming content has tagged thinking', () => {
const context = withTaggedThinkingStreamParser();
const firstChunk = converter.convertOpenAIChunkToGemini(
{
object: 'chat.completion.chunk',
id: 'chunk-glm-buffered-reasoning-1',
created: 456,
choices: [
{
index: 0,
delta: { reasoning_content: 'duplicate reasoning channel' },
finish_reason: null,
logprobs: null,
},
],
model: 'glm-5.2',
} as unknown as OpenAI.Chat.ChatCompletionChunk,
context,
);
const secondChunk = converter.convertOpenAIChunkToGemini(
{
object: 'chat.completion.chunk',
id: 'chunk-glm-buffered-reasoning-2',
created: 457,
choices: [
{
index: 0,
delta: { content: '<think>tagged reasoning</think>' },
finish_reason: null,
logprobs: null,
},
],
model: 'glm-5.2',
} as unknown as OpenAI.Chat.ChatCompletionChunk,
context,
);
const finalChunk = converter.convertOpenAIChunkToGemini(
{
object: 'chat.completion.chunk',
id: 'chunk-glm-buffered-reasoning-3',
created: 458,
choices: [
{
index: 0,
delta: { content: 'final answer' },
finish_reason: 'stop',
logprobs: null,
},
],
model: 'glm-5.2',
} as unknown as OpenAI.Chat.ChatCompletionChunk,
context,
);
expect(firstChunk.candidates?.[0]?.content?.parts).toEqual([]);
expect(secondChunk.candidates?.[0]?.content?.parts).toEqual([
{ text: 'tagged reasoning', thought: true },
]);
expect(finalChunk.candidates?.[0]?.content?.parts).toEqual([
{ text: 'final answer' },
]);
});
it('should flush buffered content before later tagged thinking content', () => {
const context = withTaggedThinkingStreamParser();
const firstChunk = converter.convertOpenAIChunkToGemini(
{
object: 'chat.completion.chunk',
id: 'chunk-glm-buffered-content-before-tag-1',
created: 456,
choices: [
{
index: 0,
delta: {
reasoning_content: 'duplicate reasoning channel',
content: 'early visible ',
},
finish_reason: null,
logprobs: null,
},
],
model: 'glm-5.2',
} as unknown as OpenAI.Chat.ChatCompletionChunk,
context,
);
const secondChunk = converter.convertOpenAIChunkToGemini(
{
object: 'chat.completion.chunk',
id: 'chunk-glm-buffered-content-before-tag-2',
created: 457,
choices: [
{
index: 0,
delta: { content: '<think>tagged reasoning</think>final answer' },
finish_reason: null,
logprobs: null,
},
],
model: 'glm-5.2',
} as unknown as OpenAI.Chat.ChatCompletionChunk,
context,
);
expect(firstChunk.candidates?.[0]?.content?.parts).toEqual([]);
expect(secondChunk.candidates?.[0]?.content?.parts).toEqual([
{ text: 'early visible ' },
{ text: 'tagged reasoning', thought: true },
{ text: 'final answer' },
]);
});
it('should flush buffered content before current content when reasoning flushes on finish', () => {
const context = withTaggedThinkingStreamParser();
const firstChunk = converter.convertOpenAIChunkToGemini(
{
object: 'chat.completion.chunk',
id: 'chunk-glm-buffered-content-order-1',
created: 456,
choices: [
{
index: 0,
delta: {
reasoning_content: 'step 1',
content: 'hello ',
},
finish_reason: null,
logprobs: null,
},
],
model: 'glm-5.2',
} as unknown as OpenAI.Chat.ChatCompletionChunk,
context,
);
const finalChunk = converter.convertOpenAIChunkToGemini(
{
object: 'chat.completion.chunk',
id: 'chunk-glm-buffered-content-order-2',
created: 457,
choices: [
{
index: 0,
delta: { content: 'world' },
finish_reason: 'stop',
logprobs: null,
},
],
model: 'glm-5.2',
} as unknown as OpenAI.Chat.ChatCompletionChunk,
context,
);
expect(firstChunk.candidates?.[0]?.content?.parts).toEqual([]);
expect(finalChunk.candidates?.[0]?.content?.parts).toEqual([
{ text: 'step 1', thought: true },
{ text: 'hello ' },
{ text: 'world' },
]);
});
it('should flush buffered reasoning_content when tagged streaming content has no thinking tags', () => {
const context = withTaggedThinkingStreamParser();
const firstChunk = converter.convertOpenAIChunkToGemini(
{
object: 'chat.completion.chunk',
id: 'chunk-glm-reasoning-only-1',
created: 456,
choices: [
{
index: 0,
delta: {
reasoning_content: 'separate reasoning channel',
content: 'final ',
},
finish_reason: null,
logprobs: null,
},
],
model: 'glm-5.2',
} as unknown as OpenAI.Chat.ChatCompletionChunk,
context,
);
const finalChunk = converter.convertOpenAIChunkToGemini(
{
object: 'chat.completion.chunk',
id: 'chunk-glm-reasoning-only-2',
created: 457,
choices: [
{
index: 0,
delta: { content: 'answer' },
finish_reason: 'stop',
logprobs: null,
},
],
model: 'glm-5.2',
} as unknown as OpenAI.Chat.ChatCompletionChunk,
context,
);
expect(firstChunk.candidates?.[0]?.content?.parts).toEqual([]);
expect(finalChunk.candidates?.[0]?.content?.parts).toEqual([
{ text: 'separate reasoning channel', thought: true },
{ text: 'final ' },
{ text: 'answer' },
]);
});
it('should flush reasoning-only chunks when tagged streaming content has no thinking tags', () => {
const context = withTaggedThinkingStreamParser();
const firstChunk = converter.convertOpenAIChunkToGemini(
{
object: 'chat.completion.chunk',
id: 'chunk-glm-reasoning-only-no-content-1',
created: 456,
choices: [
{
index: 0,
delta: { reasoning_content: 'step 1' },
finish_reason: null,
logprobs: null,
},
],
model: 'glm-5.2',
} as unknown as OpenAI.Chat.ChatCompletionChunk,
context,
);
const finalChunk = converter.convertOpenAIChunkToGemini(
{
object: 'chat.completion.chunk',
id: 'chunk-glm-reasoning-only-no-content-2',
created: 457,
choices: [
{
index: 0,
delta: {},
finish_reason: 'stop',
logprobs: null,
},
],
model: 'glm-5.2',
} as unknown as OpenAI.Chat.ChatCompletionChunk,
context,
);
expect(firstChunk.candidates?.[0]?.content?.parts).toEqual([]);
expect(finalChunk.candidates?.[0]?.content?.parts).toEqual([
{ text: 'step 1', thought: true },
]);
});
it('should flush unclosed streaming thinking content on finish', () => {
const context = withTaggedThinkingStreamParser();

View file

@ -1081,10 +1081,6 @@ function convertOpenAITextToParts(
return parseTaggedThinkingText(text);
}
function hasThoughtPart(parts: Part[]): boolean {
return parts.some((part) => part.thought === true);
}
/**
* Convert OpenAI response to Gemini format.
*/
@ -1097,23 +1093,26 @@ export function convertOpenAIResponseToGemini(
if (choice) {
const parts: Part[] = [];
const textParts = choice.message.content
? convertOpenAITextToParts(choice.message.content, requestContext)
: [];
// Handle reasoning content (thoughts).
// Tagged thinking providers may put thoughts in content, while other
// responses still use reasoning_content. Preserve the separate reasoning
// channel unless content parsing already produced thought parts.
const reasoningText =
(choice.message as ExtendedCompletionMessage).reasoning_content ??
(choice.message as ExtendedCompletionMessage).reasoning;
if (reasoningText && !hasThoughtPart(textParts)) {
parts.push({ text: reasoningText, thought: true });
// When taggedThinkingTags is enabled, thought content is already
// extracted from the text content via convertOpenAITextToParts.
// Skip reasoning_content extraction to avoid duplicating thought parts.
if (!requestContext.responseParsingOptions?.taggedThinkingTags) {
const reasoningText =
(choice.message as ExtendedCompletionMessage).reasoning_content ??
(choice.message as ExtendedCompletionMessage).reasoning;
if (reasoningText) {
parts.push({ text: reasoningText, thought: true });
}
}
// Handle text content
parts.push(...textParts);
if (choice.message.content) {
parts.push(
...convertOpenAITextToParts(choice.message.content, requestContext),
);
}
// Handle tool calls
if (choice.message.tool_calls) {
@ -1225,12 +1224,29 @@ export function convertOpenAIChunkToGemini(
if (choice) {
const parts: Part[] = [];
let contentParts: Part[] = [];
// Handle reasoning content (thoughts).
const reasoningText =
(choice.delta as ExtendedCompletionChunkDelta)?.reasoning_content ??
(choice.delta as ExtendedCompletionChunkDelta)?.reasoning;
// When taggedThinkingTags is enabled, thought content is already
// extracted from the text content via convertOpenAITextToParts.
// Skip reasoning_content extraction to avoid duplicating thought parts.
if (!requestContext.responseParsingOptions?.taggedThinkingTags) {
const reasoningText =
(choice.delta as ExtendedCompletionChunkDelta)?.reasoning_content ??
(choice.delta as ExtendedCompletionChunkDelta)?.reasoning;
if (reasoningText) {
const normalizedReasoningText = normalizeStreamingTextDelta(
reasoningText,
(requestContext.reasoningDeltaState ??= {
emittedText: '',
emittedLength: 0,
cumulativeMode: false,
}),
);
if (normalizedReasoningText) {
parts.push({ text: normalizedReasoningText, thought: true });
}
}
}
// Handle text content
if (typeof choice.delta?.content === 'string') {
@ -1245,99 +1261,19 @@ export function convertOpenAIChunkToGemini(
// Skip empty-string push mid-stream; still call on finish_reason to
// flush any buffered tagged-thinking content.
if (normalizedContent || choice.finish_reason) {
contentParts = convertOpenAITextToParts(
normalizedContent,
requestContext,
Boolean(choice.finish_reason),
parts.push(
...convertOpenAITextToParts(
normalizedContent,
requestContext,
Boolean(choice.finish_reason),
),
);
}
} else if (choice.finish_reason) {
// Flush any buffered tagged-thinking content on stream end
contentParts = convertOpenAITextToParts('', requestContext, true);
parts.push(...convertOpenAITextToParts('', requestContext, true));
}
if (hasThoughtPart(contentParts)) {
requestContext.hasTaggedThinkingThought = true;
requestContext.pendingReasoningText = undefined;
debugLogger.debug(
'convertOpenAIChunkToGemini: tagged thinking content emitted a thought; dropping buffered reasoning',
);
if (requestContext.pendingContentParts?.length) {
debugLogger.debug(
`convertOpenAIChunkToGemini: flushing ${requestContext.pendingContentParts.length} buffered content part(s) before tagged content`,
);
parts.push(...requestContext.pendingContentParts);
requestContext.pendingContentParts = undefined;
}
}
if (
reasoningText &&
(!requestContext.responseParsingOptions?.taggedThinkingTags ||
!requestContext.hasTaggedThinkingThought)
) {
const normalizedReasoningText = normalizeStreamingTextDelta(
reasoningText,
(requestContext.reasoningDeltaState ??= {
emittedText: '',
emittedLength: 0,
cumulativeMode: false,
}),
);
if (
normalizedReasoningText &&
!requestContext.responseParsingOptions?.taggedThinkingTags
) {
parts.push({ text: normalizedReasoningText, thought: true });
} else if (
normalizedReasoningText &&
!requestContext.hasTaggedThinkingThought
) {
requestContext.pendingReasoningText =
(requestContext.pendingReasoningText ?? '') + normalizedReasoningText;
debugLogger.debug(
`convertOpenAIChunkToGemini: buffered reasoning text (${requestContext.pendingReasoningText.length} chars) for tagged stream`,
);
}
}
if (
requestContext.responseParsingOptions?.taggedThinkingTags &&
!requestContext.hasTaggedThinkingThought &&
requestContext.pendingReasoningText &&
contentParts.length
) {
requestContext.pendingContentParts = [
...(requestContext.pendingContentParts ?? []),
...contentParts,
];
debugLogger.debug(
`convertOpenAIChunkToGemini: buffered ${contentParts.length} content part(s) behind pending reasoning`,
);
contentParts = [];
}
if (
choice.finish_reason &&
requestContext.responseParsingOptions?.taggedThinkingTags &&
!requestContext.hasTaggedThinkingThought &&
requestContext.pendingReasoningText
) {
debugLogger.debug(
'convertOpenAIChunkToGemini: flushing buffered reasoning for tagged stream with no tagged thought',
);
parts.push({ text: requestContext.pendingReasoningText, thought: true });
requestContext.pendingReasoningText = undefined;
}
if (choice.finish_reason && requestContext.pendingContentParts?.length) {
debugLogger.debug(
`convertOpenAIChunkToGemini: flushing ${requestContext.pendingContentParts.length} buffered content part(s) on stream finish`,
);
parts.push(...requestContext.pendingContentParts);
requestContext.pendingContentParts = undefined;
}
parts.push(...contentParts);
// Handle tool calls using the stream-local parser
if (choice.delta?.tool_calls) {
for (const toolCall of choice.delta.tool_calls) {

View file

@ -268,44 +268,6 @@ describe('ContentGenerationPipeline', () => {
);
});
it('should request provider parsing options for the effective request model', async () => {
const request: GenerateContentParameters = {
model: 'glm-5.2',
contents: [{ parts: [{ text: 'Hello' }], role: 'user' }],
};
const mockOpenAIResponse = {
id: 'response-id',
choices: [{ message: { content: 'response' }, finish_reason: 'stop' }],
created: Date.now(),
model: 'glm-5.2',
} as OpenAI.Chat.ChatCompletion;
const mockGeminiResponse = new GenerateContentResponse();
mockProvider.getResponseParsingOptions = vi.fn().mockReturnValue({
taggedThinkingTags: true,
});
(mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]);
(mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue(
mockGeminiResponse,
);
(mockClient.chat.completions.create as Mock).mockResolvedValue(
mockOpenAIResponse,
);
await pipeline.execute(request, 'prompt-id');
expect(mockProvider.getResponseParsingOptions).toHaveBeenCalledWith(
'glm-5.2',
);
expect(mockConverter.convertOpenAIResponseToGemini).toHaveBeenCalledWith(
mockOpenAIResponse,
expect.objectContaining({
model: 'glm-5.2',
responseParsingOptions: { taggedThinkingTags: true },
}),
);
});
it('should let provider request context overrides take precedence over content generator config', async () => {
const request: GenerateContentParameters = {
model: 'test-model',

View file

@ -781,7 +781,7 @@ export class ContentGenerationPipeline {
? new StreamingToolCallParser()
: undefined;
const responseParsingOptions =
this.config.provider.getResponseParsingOptions?.(effectiveModel);
this.config.provider.getResponseParsingOptions?.();
const taggedThinkingParser =
isStreaming && responseParsingOptions?.taggedThinkingTags
? new TaggedThinkingParser()

View file

@ -108,49 +108,6 @@ describe('DashScopeOpenAICompatibleProvider', () => {
});
});
describe('getResponseParsingOptions', () => {
it('should enable tagged thinking parsing for GLM models', () => {
const glmProvider = new DashScopeOpenAICompatibleProvider(
{ ...mockContentGeneratorConfig, model: 'glm-5.2' },
mockCliConfig,
);
expect(glmProvider.getResponseParsingOptions()).toEqual({
taggedThinkingTags: true,
});
});
it('should match GLM models case-insensitively', () => {
const glmProvider = new DashScopeOpenAICompatibleProvider(
{ ...mockContentGeneratorConfig, model: 'GLM-5.1' },
mockCliConfig,
);
expect(glmProvider.getResponseParsingOptions()).toEqual({
taggedThinkingTags: true,
});
});
it('should use the request model override when provided', () => {
expect(provider.getResponseParsingOptions('glm-5.2')).toEqual({
taggedThinkingTags: true,
});
});
it('should let a non-GLM request model override a configured GLM model', () => {
const glmProvider = new DashScopeOpenAICompatibleProvider(
{ ...mockContentGeneratorConfig, model: 'glm-5.2' },
mockCliConfig,
);
expect(glmProvider.getResponseParsingOptions('qwen-max')).toEqual({});
});
it('should not enable tagged thinking parsing for non-GLM models', () => {
expect(provider.getResponseParsingOptions()).toEqual({});
});
});
describe('isDashScopeProvider', () => {
it('should return true for QWEN_OAUTH auth type', () => {
const config = {

View file

@ -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 { DefaultOpenAICompatibleProvider } from './default.js';
@ -314,13 +313,6 @@ export class DashScopeOpenAICompatibleProvider extends DefaultOpenAICompatiblePr
return {};
}
getResponseParsingOptions(model?: string): OpenAIResponseParsingOptions {
if (this.isGlmModel(model ?? this.contentGeneratorConfig.model)) {
return { taggedThinkingTags: true };
}
return {};
}
/**
* Add cache control flag to specified message(s) for DashScope providers
*/

View file

@ -31,7 +31,7 @@ export interface OpenAICompatibleProvider {
userPromptId: string,
): OpenAI.Chat.ChatCompletionCreateParams;
getDefaultGenerationConfig(): GenerateContentConfig;
getResponseParsingOptions?(model?: string): OpenAIResponseParsingOptions;
getResponseParsingOptions?(): OpenAIResponseParsingOptions;
getRequestContextOverrides?(): OpenAIRequestContextOverrides;
}

View file

@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { GenerateContentParameters, Part } from '@google/genai';
import type { GenerateContentParameters } from '@google/genai';
import type { Config } from '../../config/config.js';
import type {
ContentGeneratorConfig,
@ -63,25 +63,6 @@ export interface RequestContext {
* channel are deduplicated correctly.
*/
reasoningDeltaState?: StreamingTextDeltaState;
/**
* Set once tagged content parsing emits a thought in this stream. Used to
* suppress duplicate provider reasoning-channel output that arrives in a
* different chunk.
*/
hasTaggedThinkingThought?: boolean;
/**
* Reasoning-channel text buffered while a tagged-thinking stream is still
* open. It is emitted only if the stream finishes without tagged content
* producing any thought parts. If a stream ends abnormally before a finish
* chunk, buffered reasoning is best-effort and may be lost.
*/
pendingReasoningText?: string;
/**
* Visible content buffered behind pending reasoning-channel text while a
* tagged-thinking stream is still ambiguous. This preserves thought-before-
* answer ordering when the provider uses reasoning_content without tags.
*/
pendingContentParts?: Part[];
}
export interface ErrorHandler {