diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index fc24188896d..69781266068 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -37,6 +37,7 @@ import type { UseHistoryManagerReturn } from './useHistoryManager.js'; import type { HistoryItem, SlashCommandProcessorResult } from '../types.js'; import { MessageType, StreamingState } from '../types.js'; import type { LoadedSettings } from '../../config/settings.js'; +import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js'; // --- MOCKS --- const mockSendMessageStream = vi @@ -148,6 +149,9 @@ describe('useGeminiStream', () => { beforeEach(() => { vi.clearAllMocks(); // Clear mocks before each test + vi.mocked(findLastSafeSplitPoint).mockImplementation( + (s: string) => s.length, + ); mockAddItem = vi.fn(); // Define the mock for getGeminiClient @@ -1201,6 +1205,78 @@ describe('useGeminiStream', () => { }); }); + it('does not render leading blank content chunks as an empty assistant item', async () => { + vi.useFakeTimers(); + + let releaseNextChunk!: () => void; + const waitForNextChunk = new Promise((resolve) => { + releaseNextChunk = resolve; + }); + let releaseStream!: () => void; + const holdStream = new Promise((resolve) => { + releaseStream = resolve; + }); + vi.mocked(findLastSafeSplitPoint).mockImplementation((s: string) => + s.startsWith('\n\n') ? 2 : s.length, + ); + + const mockStream = (async function* () { + yield { + type: ServerGeminiEventType.Content, + value: '\n\n', + }; + await waitForNextChunk; + yield { + type: ServerGeminiEventType.Content, + value: '哈哈', + }; + await holdStream; + })(); + mockSendMessageStream.mockReturnValue(mockStream); + + const { result } = renderTestHook(); + + act(() => { + void result.current.submitQuery('test query'); + }); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + await act(async () => { + vi.advanceTimersByTime(60); + }); + + expect(result.current.pendingHistoryItems).toEqual([]); + + await act(async () => { + releaseNextChunk(); + await Promise.resolve(); + await Promise.resolve(); + }); + + await act(async () => { + vi.advanceTimersByTime(60); + }); + + expect(result.current.pendingHistoryItems).toEqual([ + expect.objectContaining({ + type: 'gemini', + text: '哈哈', + }), + ]); + + act(() => { + result.current.cancelOngoingRequest(); + }); + + await act(async () => { + releaseStream(); + }); + }); + it('buffers streamed thoughts until the throttle interval elapses', async () => { vi.useFakeTimers(); @@ -1257,6 +1333,77 @@ describe('useGeminiStream', () => { }); }); + it('does not render leading blank thought chunks as an empty thought item', async () => { + vi.useFakeTimers(); + + let releaseNextChunk!: () => void; + const waitForNextChunk = new Promise((resolve) => { + releaseNextChunk = resolve; + }); + let releaseStream!: () => void; + const holdStream = new Promise((resolve) => { + releaseStream = resolve; + }); + + const mockStream = (async function* () { + yield { + type: ServerGeminiEventType.Thought, + value: { description: '\n\n' }, + }; + await waitForNextChunk; + yield { + type: ServerGeminiEventType.Thought, + value: { description: 'Thinking' }, + }; + await holdStream; + })(); + mockSendMessageStream.mockReturnValue(mockStream); + + const { result } = renderTestHook(); + + act(() => { + void result.current.submitQuery('test query'); + }); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + await act(async () => { + vi.advanceTimersByTime(60); + }); + + expect(result.current.pendingHistoryItems).toEqual([]); + expect(result.current.thought).toBeNull(); + + await act(async () => { + releaseNextChunk(); + await Promise.resolve(); + await Promise.resolve(); + }); + + await act(async () => { + vi.advanceTimersByTime(60); + }); + + expect(result.current.pendingHistoryItems).toEqual([ + expect.objectContaining({ + type: 'gemini_thought', + text: 'Thinking', + }), + ]); + expect(result.current.thought).toEqual({ description: 'Thinking' }); + + act(() => { + result.current.cancelOngoingRequest(); + }); + + await act(async () => { + releaseStream(); + }); + }); + it('flushes buffered content before cancellation', async () => { vi.useFakeTimers(); diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 6d9ce9128ca..48067c275d7 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -107,6 +107,10 @@ function extractLastAssistantText(history: HistoryItem[]): string | undefined { return undefined; } +function stripLeadingBlankLines(text: string): string { + return text.replace(/^(?:[ \t]*\r?\n)+/, ''); +} + /** * Flatten `functionResponse` parts into a compact string for the summarizer. * The summarizer itself truncates to 300 chars per field, so we just join @@ -766,11 +770,14 @@ export const useGeminiStream = ( pendingHistoryItemRef.current?.type !== 'gemini' && pendingHistoryItemRef.current?.type !== 'gemini_content' ) { + if (newGeminiMessageBuffer.trim().length === 0) { + return newGeminiMessageBuffer; + } if (pendingHistoryItemRef.current) { addItem(pendingHistoryItemRef.current, userMessageTimestamp); } setPendingHistoryItem({ type: 'gemini', text: '' }); - newGeminiMessageBuffer = eventValue; + newGeminiMessageBuffer = stripLeadingBlankLines(newGeminiMessageBuffer); } // Split large messages for better rendering performance. Ideally, // we should maximize the amount of output sent to . @@ -845,13 +852,22 @@ export const useGeminiStream = ( const isPendingThought = pendingType === 'gemini_thought' || pendingType === 'gemini_thought_content'; + let thoughtToMerge = eventValue; // If we're not already showing a thought, start a new one if (!isPendingThought) { + if (newThoughtBuffer.trim().length === 0) { + return newThoughtBuffer; + } // If there's a pending non-thought item, finalize it first if (pendingHistoryItemRef.current) { addItem(pendingHistoryItemRef.current, userMessageTimestamp); } + newThoughtBuffer = stripLeadingBlankLines(newThoughtBuffer); + thoughtToMerge = { + ...eventValue, + description: newThoughtBuffer, + }; setPendingHistoryItem({ type: 'gemini_thought', text: '' }); } @@ -888,7 +904,7 @@ export const useGeminiStream = ( } // Also update the thought state for the loading indicator - mergeThought(eventValue); + mergeThought(thoughtToMerge); return newThoughtBuffer; }, diff --git a/packages/core/src/core/openaiContentGenerator/converter.test.ts b/packages/core/src/core/openaiContentGenerator/converter.test.ts index 8e4b52e01bb..da3323b9035 100644 --- a/packages/core/src/core/openaiContentGenerator/converter.test.ts +++ b/packages/core/src/core/openaiContentGenerator/converter.test.ts @@ -7,6 +7,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { OpenAIContentConverter } from './converter.js'; import { StreamingToolCallParser } from './streamingToolCallParser.js'; +import { TaggedThinkingParser } from './taggedThinkingParser.js'; import type { RequestContext } from './types.js'; import { Type, @@ -47,6 +48,21 @@ describe('OpenAIContentConverter', () => { }; } + function withTaggedThinkingOptions(): RequestContext { + return { + ...requestContext, + responseParsingOptions: { taggedThinkingTags: true }, + }; + } + + function withTaggedThinkingStreamParser(): RequestContext { + return { + ...withStreamParser(), + responseParsingOptions: { taggedThinkingTags: true }, + taggedThinkingParser: new TaggedThinkingParser(), + }; + } + describe('stream-local parser state', () => { it('creates fresh parser instances', () => { const ctx1 = new StreamingToolCallParser(); @@ -1893,6 +1909,241 @@ describe('OpenAIContentConverter', () => { }); }); + describe('OpenAI -> Gemini tagged thinking content', () => { + it('should convert MiniMax content to thought parts for non-streaming responses', () => { + const response = converter.convertOpenAIResponseToGemini( + { + object: 'chat.completion', + id: 'chatcmpl-minimax-1', + created: 123, + model: 'MiniMax-M2.7', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: 'internal reasoningfinal answer', + }, + finish_reason: 'stop', + logprobs: null, + }, + ], + } as unknown as OpenAI.Chat.ChatCompletion, + withTaggedThinkingOptions(), + ); + + expect(response.candidates?.[0]?.content?.parts).toEqual([ + { text: 'internal reasoning', thought: true }, + { text: 'final answer' }, + ]); + }); + + it('should preserve ordering around blocks', () => { + const response = converter.convertOpenAIResponseToGemini( + { + object: 'chat.completion', + id: 'chatcmpl-minimax-2', + created: 123, + model: 'MiniMax-M2.7', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: 'beforehiddenafter', + }, + finish_reason: 'stop', + logprobs: null, + }, + ], + } as unknown as OpenAI.Chat.ChatCompletion, + withTaggedThinkingOptions(), + ); + + expect(response.candidates?.[0]?.content?.parts).toEqual([ + { text: 'before' }, + { text: 'hidden', thought: true }, + { text: 'after' }, + ]); + }); + + it('should parse multiple tagged thinking blocks case-insensitively', () => { + const response = converter.convertOpenAIResponseToGemini( + { + object: 'chat.completion', + id: 'chatcmpl-minimax-3', + created: 123, + model: 'MiniMax-M2.7', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: 'avisibleb', + }, + finish_reason: 'stop', + logprobs: null, + }, + ], + } as unknown as OpenAI.Chat.ChatCompletion, + withTaggedThinkingOptions(), + ); + + expect(response.candidates?.[0]?.content?.parts).toEqual([ + { text: 'a', thought: true }, + { text: 'visible' }, + { text: 'b', thought: true }, + ]); + }); + + it('should leave tags visible when tagged thinking parsing is disabled', () => { + const response = converter.convertOpenAIResponseToGemini( + { + object: 'chat.completion', + id: 'chatcmpl-openai-1', + created: 123, + model: 'gpt-test', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: 'visible xml example', + }, + finish_reason: 'stop', + logprobs: null, + }, + ], + } as unknown as OpenAI.Chat.ChatCompletion, + requestContext, + ); + + expect(response.candidates?.[0]?.content?.parts).toEqual([ + { text: 'visible xml example' }, + ]); + }); + + it('should preserve incomplete tags as visible text on final non-streaming parse', () => { + const response = converter.convertOpenAIResponseToGemini( + { + object: 'chat.completion', + id: 'chatcmpl-minimax-4', + created: 123, + model: 'MiniMax-M2.7', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: 'final answer { + const context = withTaggedThinkingStreamParser(); + + const firstChunk = converter.convertOpenAIChunkToGemini( + { + object: 'chat.completion.chunk', + id: 'chunk-minimax-1', + created: 456, + choices: [ + { + index: 0, + delta: { content: 'pre hidden visible' }, + finish_reason: 'stop', + logprobs: null, + }, + ], + model: 'MiniMax-M2.7', + } as unknown as OpenAI.Chat.ChatCompletionChunk, + context, + ); + + expect(firstChunk.candidates?.[0]?.content?.parts).toEqual([ + { text: 'pre ' }, + ]); + expect(secondChunk.candidates?.[0]?.content?.parts).toEqual([ + { text: 'hidden', thought: true }, + ]); + expect(finalChunk.candidates?.[0]?.content?.parts).toEqual([ + { text: ' visible' }, + ]); + }); + + it('should flush unclosed streaming thinking content on finish', () => { + const context = withTaggedThinkingStreamParser(); + + const chunk = converter.convertOpenAIChunkToGemini( + { + object: 'chat.completion.chunk', + id: 'chunk-minimax-unclosed', + created: 456, + choices: [ + { + index: 0, + delta: { content: 'answer still thinking' }, + finish_reason: 'stop', + logprobs: null, + }, + ], + model: 'MiniMax-M2.7', + } as unknown as OpenAI.Chat.ChatCompletionChunk, + context, + ); + + expect(chunk.candidates?.[0]?.content?.parts).toEqual([ + { text: 'answer ' }, + { text: 'still thinking', thought: true }, + ]); + }); + }); + describe('convertGeminiToolsToOpenAI', () => { it('should convert Gemini tools with parameters field', async () => { const geminiTools = [ diff --git a/packages/core/src/core/openaiContentGenerator/converter.ts b/packages/core/src/core/openaiContentGenerator/converter.ts index 325e9b74bf0..c4b2d5d3ee1 100644 --- a/packages/core/src/core/openaiContentGenerator/converter.ts +++ b/packages/core/src/core/openaiContentGenerator/converter.ts @@ -22,6 +22,7 @@ import type OpenAI from 'openai'; import { safeJsonParse } from '../../utils/safeJsonParse.js'; import { createDebugLogger } from '../../utils/debugLogger.js'; import type { RequestContext } from './types.js'; +import { parseTaggedThinkingText } from './taggedThinkingParser.js'; import { convertSchema, type SchemaComplianceMode, @@ -862,6 +863,22 @@ function extractTextFromContentUnion(contentUnion: unknown): string { return ''; } +function convertOpenAITextToParts( + text: string, + requestContext: RequestContext, + final = true, +): Part[] { + if (!requestContext.responseParsingOptions?.taggedThinkingTags) { + return text ? [{ text }] : []; + } + + if (requestContext.taggedThinkingParser) { + return requestContext.taggedThinkingParser.parse(text, final); + } + + return parseTaggedThinkingText(text); +} + /** * Convert OpenAI response to Gemini format. */ @@ -875,17 +892,24 @@ export function convertOpenAIResponseToGemini( if (choice) { const parts: Part[] = []; - // Handle reasoning content (thoughts) - const reasoningText = - (choice.message as ExtendedCompletionMessage).reasoning_content ?? - (choice.message as ExtendedCompletionMessage).reasoning; - if (reasoningText) { - parts.push({ text: reasoningText, thought: true }); + // Handle reasoning content (thoughts). + // 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 if (choice.message.content) { - parts.push({ text: choice.message.content }); + parts.push( + ...convertOpenAITextToParts(choice.message.content, requestContext), + ); } // Handle tool calls @@ -997,18 +1021,31 @@ export function convertOpenAIChunkToGemini( if (choice) { const parts: Part[] = []; - const reasoningText = - (choice.delta as ExtendedCompletionChunkDelta)?.reasoning_content ?? - (choice.delta as ExtendedCompletionChunkDelta)?.reasoning; - if (reasoningText) { - parts.push({ text: reasoningText, thought: true }); + // Handle reasoning content (thoughts). + // 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) { + parts.push({ text: reasoningText, thought: true }); + } } // Handle text content - if (choice.delta?.content) { - if (typeof choice.delta.content === 'string') { - parts.push({ text: choice.delta.content }); - } + if (typeof choice.delta?.content === 'string') { + parts.push( + ...convertOpenAITextToParts( + choice.delta.content, + requestContext, + Boolean(choice.finish_reason), + ), + ); + } else if (choice.finish_reason) { + // Flush any buffered tagged-thinking content on stream end + parts.push(...convertOpenAITextToParts('', requestContext, true)); } // Handle tool calls using the stream-local parser diff --git a/packages/core/src/core/openaiContentGenerator/index.ts b/packages/core/src/core/openaiContentGenerator/index.ts index 15b40380851..55590631b1f 100644 --- a/packages/core/src/core/openaiContentGenerator/index.ts +++ b/packages/core/src/core/openaiContentGenerator/index.ts @@ -14,6 +14,7 @@ import { DashScopeOpenAICompatibleProvider, DeepSeekOpenAICompatibleProvider, ModelScopeOpenAICompatibleProvider, + MiniMaxOpenAICompatibleProvider, OpenRouterOpenAICompatibleProvider, type OpenAICompatibleProvider, DefaultOpenAICompatibleProvider, @@ -27,6 +28,7 @@ export { type OpenAICompatibleProvider, DashScopeOpenAICompatibleProvider, DeepSeekOpenAICompatibleProvider, + MiniMaxOpenAICompatibleProvider, OpenRouterOpenAICompatibleProvider, } from './provider/index.js'; @@ -88,6 +90,14 @@ export function determineProvider( ); } + // Check for MiniMax provider + if (MiniMaxOpenAICompatibleProvider.isMiniMaxProvider(config)) { + return new MiniMaxOpenAICompatibleProvider( + contentGeneratorConfig, + cliConfig, + ); + } + // Default provider for standard OpenAI-compatible APIs return new DefaultOpenAICompatibleProvider(contentGeneratorConfig, cliConfig); } diff --git a/packages/core/src/core/openaiContentGenerator/pipeline.ts b/packages/core/src/core/openaiContentGenerator/pipeline.ts index 2f173107920..6cb4ed69232 100644 --- a/packages/core/src/core/openaiContentGenerator/pipeline.ts +++ b/packages/core/src/core/openaiContentGenerator/pipeline.ts @@ -13,6 +13,7 @@ import { import type { ContentGeneratorConfig } from '../contentGenerator.js'; import { OpenAIContentConverter } from './converter.js'; import { StreamingToolCallParser } from './streamingToolCallParser.js'; +import { TaggedThinkingParser } from './taggedThinkingParser.js'; import type { PipelineConfig, RequestContext } from './types.js'; /** @@ -516,6 +517,12 @@ export class ContentGenerationPipeline { const toolCallParser = isStreaming ? new StreamingToolCallParser() : undefined; + const responseParsingOptions = + this.config.provider.getResponseParsingOptions?.(); + const taggedThinkingParser = + isStreaming && responseParsingOptions?.taggedThinkingTags + ? new TaggedThinkingParser() + : undefined; return { model: effectiveModel, @@ -523,6 +530,8 @@ export class ContentGenerationPipeline { startTime: Date.now(), splitToolMedia: this.contentGeneratorConfig.splitToolMedia ?? false, ...(toolCallParser ? { toolCallParser } : {}), + ...(responseParsingOptions ? { responseParsingOptions } : {}), + ...(taggedThinkingParser ? { taggedThinkingParser } : {}), }; } } diff --git a/packages/core/src/core/openaiContentGenerator/provider/index.ts b/packages/core/src/core/openaiContentGenerator/provider/index.ts index cb33834dd6b..907c1e9f53c 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/index.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/index.ts @@ -2,6 +2,7 @@ export { ModelScopeOpenAICompatibleProvider } from './modelscope.js'; export { DashScopeOpenAICompatibleProvider } from './dashscope.js'; export { DeepSeekOpenAICompatibleProvider } from './deepseek.js'; export { OpenRouterOpenAICompatibleProvider } from './openrouter.js'; +export { MiniMaxOpenAICompatibleProvider } from './minimax.js'; export { DefaultOpenAICompatibleProvider } from './default.js'; export type { OpenAICompatibleProvider, diff --git a/packages/core/src/core/openaiContentGenerator/provider/minimax.test.ts b/packages/core/src/core/openaiContentGenerator/provider/minimax.test.ts new file mode 100644 index 00000000000..faa4560b3f9 --- /dev/null +++ b/packages/core/src/core/openaiContentGenerator/provider/minimax.test.ts @@ -0,0 +1,141 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; +import type { Config } from '../../../config/config.js'; +import type { ContentGeneratorConfig } from '../../contentGenerator.js'; +import { determineProvider } from '../index.js'; +import { MiniMaxOpenAICompatibleProvider } from './minimax.js'; + +describe('MiniMaxOpenAICompatibleProvider', () => { + const mockCliConfig = { + getCliVersion: vi.fn().mockReturnValue('1.0.0'), + getProxy: vi.fn().mockReturnValue(undefined), + } as unknown as Config; + + function createConfig(baseUrl?: string): ContentGeneratorConfig { + return { + model: 'MiniMax-M2.7', + apiKey: 'test-api-key', + ...(baseUrl ? { baseUrl } : {}), + } as ContentGeneratorConfig; + } + + describe('isMiniMaxProvider', () => { + it('matches the official OpenAI-compatible MiniMax API host', () => { + expect( + MiniMaxOpenAICompatibleProvider.isMiniMaxProvider( + createConfig('https://api.minimaxi.com/v1'), + ), + ).toBe(true); + }); + + it('matches the official international OpenAI-compatible MiniMax API host', () => { + expect( + MiniMaxOpenAICompatibleProvider.isMiniMaxProvider( + createConfig('https://api.minimax.io/v1'), + ), + ).toBe(true); + }); + + it('matches known hosts via exact match (api.minimaxi.com)', () => { + // Exact match on the well-known host, not suffix-based + expect( + MiniMaxOpenAICompatibleProvider.isMiniMaxProvider( + createConfig('https://api.minimaxi.com/v1/chat/completions'), + ), + ).toBe(true); + }); + + it('matches known hosts via exact match (api.minimax.io)', () => { + // Exact match on the well-known host, not suffix-based + expect( + MiniMaxOpenAICompatibleProvider.isMiniMaxProvider( + createConfig('https://api.minimax.io/v1/chat/completions'), + ), + ).toBe(true); + }); + + it('matches MiniMax subdomain hosts via wildcard suffix', () => { + expect( + MiniMaxOpenAICompatibleProvider.isMiniMaxProvider( + createConfig('https://gateway.minimaxi.com/v1'), + ), + ).toBe(true); + expect( + MiniMaxOpenAICompatibleProvider.isMiniMaxProvider( + createConfig('https://api.minimaxi.com/v1/chat/completions'), + ), + ).toBe(true); + expect( + MiniMaxOpenAICompatibleProvider.isMiniMaxProvider( + createConfig('https://custom.api.minimax.io/v1'), + ), + ).toBe(true); + }); + + it('matches custom proxy subdomains via suffix fallback', () => { + // Suffix matching intentionally matches any subdomain under + // minimaxi.com / minimax.io to support custom MiniMax deployments. + expect( + MiniMaxOpenAICompatibleProvider.isMiniMaxProvider( + createConfig('https://my-proxy.minimaxi.com/v1'), + ), + ).toBe(true); + expect( + MiniMaxOpenAICompatibleProvider.isMiniMaxProvider( + createConfig('https://custom-gateway.minimax.io/v1'), + ), + ).toBe(true); + }); + + it('does not match unrelated or invalid URLs', () => { + expect( + MiniMaxOpenAICompatibleProvider.isMiniMaxProvider( + createConfig('https://api.openai.com/v1'), + ), + ).toBe(false); + expect( + MiniMaxOpenAICompatibleProvider.isMiniMaxProvider( + createConfig('https://minimaxi.com/v1'), + ), + ).toBe(false); + expect( + MiniMaxOpenAICompatibleProvider.isMiniMaxProvider( + createConfig('https://minimax.io/v1'), + ), + ).toBe(false); + expect( + MiniMaxOpenAICompatibleProvider.isMiniMaxProvider( + createConfig('not a url'), + ), + ).toBe(false); + expect( + MiniMaxOpenAICompatibleProvider.isMiniMaxProvider(createConfig()), + ).toBe(false); + }); + }); + + it('enables tagged thinking response parsing', () => { + const provider = new MiniMaxOpenAICompatibleProvider( + createConfig('https://api.minimaxi.com/v1'), + mockCliConfig, + ); + + expect(provider.getResponseParsingOptions()).toEqual({ + taggedThinkingTags: true, + }); + }); + + it('is selected by the OpenAI-compatible provider factory', () => { + const provider = determineProvider( + createConfig('https://api.minimax.io/v1'), + mockCliConfig, + ); + + expect(provider).toBeInstanceOf(MiniMaxOpenAICompatibleProvider); + }); +}); diff --git a/packages/core/src/core/openaiContentGenerator/provider/minimax.ts b/packages/core/src/core/openaiContentGenerator/provider/minimax.ts new file mode 100644 index 00000000000..97008e1a453 --- /dev/null +++ b/packages/core/src/core/openaiContentGenerator/provider/minimax.ts @@ -0,0 +1,44 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { ContentGeneratorConfig } from '../../contentGenerator.js'; +import type { OpenAIResponseParsingOptions } from '../responseParsingOptions.js'; +import { DefaultOpenAICompatibleProvider } from './default.js'; + +/** Well-known MiniMax API hostnames for exact matching. */ +const MINIMAX_KNOWN_HOSTS = ['api.minimaxi.com', 'api.minimax.io'] as const; + +/** + * Suffix patterns for custom MiniMax OpenAI-compatible API hosts. + * Note: suffix matching is intentionally permissive — it enables + * tagged thinking parsing for any subdomain under minimaxi.com / + * minimax.io. If a user configures a proxy at a minimaxi subdomain + * that points to a non-MiniMax backend, tagged thinking parsing + * could be incorrectly enabled. The known-host exact match above + * covers official endpoints; the suffix fallback exists for custom + * MiniMax deployments. + */ +const MINIMAX_HOST_SUFFIXES = ['.minimaxi.com', '.minimax.io'] as const; + +export class MiniMaxOpenAICompatibleProvider extends DefaultOpenAICompatibleProvider { + static isMiniMaxProvider(config: ContentGeneratorConfig): boolean { + if (!config.baseUrl) return false; + + try { + const hostname = new URL(config.baseUrl).hostname.toLowerCase(); + if ((MINIMAX_KNOWN_HOSTS as readonly string[]).includes(hostname)) { + return true; + } + return MINIMAX_HOST_SUFFIXES.some((suffix) => hostname.endsWith(suffix)); + } catch { + return false; + } + } + + getResponseParsingOptions(): OpenAIResponseParsingOptions { + return { taggedThinkingTags: true }; + } +} diff --git a/packages/core/src/core/openaiContentGenerator/provider/types.ts b/packages/core/src/core/openaiContentGenerator/provider/types.ts index 6998cb5b769..3f6eb138c6b 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/types.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/types.ts @@ -1,5 +1,6 @@ import type { GenerateContentConfig } from '@google/genai'; import type OpenAI from 'openai'; +import type { OpenAIResponseParsingOptions } from '../responseParsingOptions.js'; // Extended types to support cache_control for DashScope export interface ChatCompletionContentPartTextWithCache @@ -24,6 +25,7 @@ export interface OpenAICompatibleProvider { userPromptId: string, ): OpenAI.Chat.ChatCompletionCreateParams; getDefaultGenerationConfig(): GenerateContentConfig; + getResponseParsingOptions?(): OpenAIResponseParsingOptions; } export type DashScopeRequestMetadata = { diff --git a/packages/core/src/core/openaiContentGenerator/responseParsingOptions.ts b/packages/core/src/core/openaiContentGenerator/responseParsingOptions.ts new file mode 100644 index 00000000000..6fdbea88672 --- /dev/null +++ b/packages/core/src/core/openaiContentGenerator/responseParsingOptions.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface OpenAIResponseParsingOptions { + taggedThinkingTags?: boolean; +} diff --git a/packages/core/src/core/openaiContentGenerator/taggedThinkingParser.test.ts b/packages/core/src/core/openaiContentGenerator/taggedThinkingParser.test.ts new file mode 100644 index 00000000000..0811ec84f40 --- /dev/null +++ b/packages/core/src/core/openaiContentGenerator/taggedThinkingParser.test.ts @@ -0,0 +1,278 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { + TaggedThinkingParser, + parseTaggedThinkingText, +} from './taggedThinkingParser.js'; + +describe('TaggedThinkingParser', () => { + // ── Basic parsing ───────────────────────────────────── + + it('should leave plain text unchanged', () => { + const parser = new TaggedThinkingParser(); + expect(parser.parse('hello world', true)).toEqual([ + { text: 'hello world' }, + ]); + }); + + it('should parse content as thought part', () => { + const parser = new TaggedThinkingParser(); + expect(parser.parse('reasoninganswer', true)).toEqual([ + { text: 'reasoning', thought: true }, + { text: 'answer' }, + ]); + }); + + it('should parse content as thought part', () => { + const parser = new TaggedThinkingParser(); + expect(parser.parse('ra', true)).toEqual([ + { text: 'r', thought: true }, + { text: 'a' }, + ]); + }); + + it('should handle mixed usage of and ', () => { + const parser = new TaggedThinkingParser(); + expect( + parser.parse('abcd', true), + ).toEqual([ + { text: 'a', thought: true }, + { text: 'b' }, + { text: 'c', thought: true }, + { text: 'd' }, + ]); + }); + + // ── Case insensitivity ──────────────────────────────── + + it('should handle uppercase tags', () => { + const parser = new TaggedThinkingParser(); + expect(parser.parse('ab', true)).toEqual([ + { text: 'a', thought: true }, + { text: 'b' }, + ]); + }); + + it('should handle uppercase tags', () => { + const parser = new TaggedThinkingParser(); + expect(parser.parse('ab', true)).toEqual([ + { text: 'a', thought: true }, + { text: 'b' }, + ]); + }); + + it('should handle mixed-case tags', () => { + const parser = new TaggedThinkingParser(); + expect(parser.parse('ab', true)).toEqual([ + { text: 'a', thought: true }, + { text: 'b' }, + ]); + }); + + // ── Empty tag content ───────────────────────────────── + + it('should handle empty tags', () => { + const parser = new TaggedThinkingParser(); + // Empty thought should not produce a part (appendPart skips empty text) + expect(parser.parse('beforeafter', true)).toEqual([ + { text: 'before' }, + { text: 'after' }, + ]); + }); + + it('should handle empty tags', () => { + const parser = new TaggedThinkingParser(); + expect(parser.parse('ab', true)).toEqual([ + { text: 'a' }, + { text: 'b' }, + ]); + }); + + // ── Close tags in text mode (no preceding open tag) ─── + + it('should treat as normal text in text mode (no opening tag)', () => { + const parser = new TaggedThinkingParser(); + expect(parser.parse('some text', true)).toEqual([ + { text: 'some text' }, + ]); + }); + + it('should treat as normal text in text mode (no opening tag)', () => { + const parser = new TaggedThinkingParser(); + expect(parser.parse('x y', true)).toEqual([ + { text: 'x y' }, + ]); + }); + + // ── Pure partial-tag-prefix chunk (streaming core) ──── + + it('should buffer partial tag prefix across chunks', () => { + const parser = new TaggedThinkingParser(); + + // " or + const r1 = parser.parse('pre hiddenvisible', true); + expect(r2).toEqual([ + { text: 'hidden', thought: true }, + { text: 'visible' }, + ]); + }); + + it('should handle chunk that is only a partial tag prefix', () => { + const parser = new TaggedThinkingParser(); + + // Entire chunk is just a partial tag prefix + const r1 = parser.parse('thoughtout', true); + expect(r2).toEqual([{ text: 'thought', thought: true }, { text: 'out' }]); + }); + + it('should handle close tag partial prefix (< + /th...) in thought mode', () => { + const parser = new TaggedThinkingParser(); + + // Enter thought mode; " → buffered + expect(parser.parse('content visible" → completes + // → exits thought mode. " visible" is normal text. + expect(parser.parse('ink> visible', true)).toEqual([{ text: ' visible' }]); + }); + + // ── Multi-chunk tag splitting ───────────────────────── + + it('should handle tag split across 3+ chunks', () => { + const parser = new TaggedThinkingParser(); + expect(parser.parse('a hiddenb', true)).toEqual([ + { text: 'hidden', thought: true }, + { text: 'b' }, + ]); + }); + + it('should handle close tag split across chunks', () => { + const parser = new TaggedThinkingParser(); + + expect(parser.parse('thoughtvisible', true)).toEqual([{ text: 'visible' }]); + }); + + // ── final flag: flush unclosed tags ─────────────────── + + it('should flush unclosed thinking content as thought on final', () => { + const parser = new TaggedThinkingParser(); + // "stuff" without closing tag → on final, thought is flushed + expect(parser.parse('answer reasoning', true)).toEqual([ + { text: 'answer ' }, + { text: 'reasoning', thought: true }, + ]); + }); + + it('should preserve incomplete open tag as text on final', () => { + const parser = new TaggedThinkingParser(); + expect(parser.parse('text { + const parser = new TaggedThinkingParser(); + expect(parser.parse('stuff { + const parser = new TaggedThinkingParser(); + + const r1 = parser.parse('a'); + expect(r1).toEqual([{ text: 'a', thought: true }]); + + const r2 = parser.parse('bcd', true); + expect(r2).toEqual([ + { text: 'b' }, + { text: 'c', thought: true }, + { text: 'd' }, + ]); + }); + + // ── Static convenience method ───────────────────────── + + it('parseTaggedThinkingText should work as a one-shot parser', () => { + expect(parseTaggedThinkingText('xy')).toEqual([ + { text: 'x', thought: true }, + { text: 'y' }, + ]); + }); + + it('parseTaggedThinkingText handles plain text', () => { + expect(parseTaggedThinkingText('no tags here')).toEqual([ + { text: 'no tags here' }, + ]); + }); + + it('parseTaggedThinkingText preserves incomplete tags as visible text', () => { + expect(parseTaggedThinkingText('final content ', () => { + const parser = new TaggedThinkingParser(); + // Binary mode toggle allows to close + expect(parser.parse('reasoningvisible', true)).toEqual([ + { text: 'reasoning', thought: true }, + { text: 'visible' }, + ]); + }); + + it('should handle cross-matching: content ', () => { + const parser = new TaggedThinkingParser(); + // Binary mode toggle allows to close + expect(parser.parse('reasoningvisible', true)).toEqual([ + { text: 'reasoning', thought: true }, + { text: 'visible' }, + ]); + }); + + // ── Unclosed thought flush on stream end ──────────────── + + it('should flush unclosed thought as thought part on final (stream truncated after )', () => { + const parser = new TaggedThinkingParser(); + // Simulate stream truncation: opened, network drops, final flush + // The content is flushed as thought (invisible to user), but the debugLogger.warn + // makes this observable. This test verifies the flush behavior itself. + expect(parser.parse('partial response', true)).toEqual([ + { text: 'partial response', thought: true }, + ]); + }); + + it('should flush unclosed thought as text when stream ends with visible prefix', () => { + const parser = new TaggedThinkingParser(); + // opens thought mode, closes it, then another opens + // but stream ends before closing → final flush as thought + expect( + parser.parse('donevisible unclosed', true), + ).toEqual([ + { text: 'done', thought: true }, + { text: 'visible ' }, + { text: 'unclosed', thought: true }, + ]); + }); +}); diff --git a/packages/core/src/core/openaiContentGenerator/taggedThinkingParser.ts b/packages/core/src/core/openaiContentGenerator/taggedThinkingParser.ts new file mode 100644 index 00000000000..b3fc6db83d6 --- /dev/null +++ b/packages/core/src/core/openaiContentGenerator/taggedThinkingParser.ts @@ -0,0 +1,129 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Part } from '@google/genai'; +import { createDebugLogger } from '../../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('TAGGED_THINKING_PARSER'); + +// Parser uses a binary mode toggle rather than a tag stack, so +// content is valid and cross-matching is intentional. +// MiniMax only uses one tag type per response in practice. +const OPEN_TAGS = ['', ''] as const; +const CLOSE_TAGS = ['', ''] as const; + +/** Longest tag length across all open/close variants ('' = 11). */ +const MAX_TAG_LENGTH = Math.max( + ...OPEN_TAGS.map((t) => t.length), + ...CLOSE_TAGS.map((t) => t.length), +); + +type ParserMode = 'text' | 'thought'; + +function appendPart(parts: Part[], text: string, mode: ParserMode): void { + if (!text) return; + parts.push(mode === 'thought' ? { text, thought: true } : { text }); +} + +/** + * Check whether the suffix starting at `offset` in the pre-computed + * lowercase buffer is a prefix of any tag. The caller MUST pass a + * fully-lowercased buffer to avoid repeated `toLowerCase()` allocations. + */ +function isPrefixOfAnyTag( + lower: string, + offset: number, + tags: readonly string[], +): boolean { + const remainingLen = lower.length - offset; + if (remainingLen <= 0) return false; + // If the remaining text is longer than the longest tag it cannot be a + // prefix of any tag, so we can bail early without slicing. + if (remainingLen > MAX_TAG_LENGTH) return false; + // Slice is bounded to MAX_TAG_LENGTH (≤ 11 chars) → O(1). + return tags.some((tag) => + tag.startsWith(lower.slice(offset, offset + remainingLen)), + ); +} + +/** + * Find a tag that matches the text at `offset` in the pre-computed + * lowercase buffer. Returns the matched tag string or undefined. + */ +function findMatchingTag( + lower: string, + offset: number, + tags: readonly string[], +): string | undefined { + return tags.find((tag) => lower.startsWith(tag, offset)); +} + +export class TaggedThinkingParser { + private mode: ParserMode = 'text'; + private buffer = ''; + + parse(chunk: string, final = false): Part[] { + this.buffer += chunk; + + // Pre-compute a lowercase copy once per call to avoid repeated + // O(N) slice+toLowerCase allocations inside the character loop. + const lower = this.buffer.toLowerCase(); + + const parts: Part[] = []; + let segment = ''; + let index = 0; + + while (index < this.buffer.length) { + const activeTags = this.mode === 'text' ? OPEN_TAGS : CLOSE_TAGS; + const matchedTag = findMatchingTag(lower, index, activeTags); + + if (matchedTag) { + debugLogger.debug( + `taggedThinking: detected tag "${matchedTag}" at offset ${index}`, + ); + appendPart(parts, segment, this.mode); + segment = ''; + this.mode = this.mode === 'text' ? 'thought' : 'text'; + index += matchedTag.length; + continue; + } + + if (!final && isPrefixOfAnyTag(lower, index, activeTags)) { + break; + } + + segment += this.buffer[index]; + index += 1; + } + + if (index < this.buffer.length) { + appendPart(parts, segment, this.mode); + this.buffer = this.buffer.slice(index); + debugLogger.debug( + `taggedThinking: emitted ${parts.length} part(s), buffered ${this.buffer.length} char(s)`, + ); + return parts; + } + + this.buffer = ''; + // Safety net: log when flushing an unclosed thought buffer + // to make this silent data-loss scenario observable. + if (this.mode === 'thought' && segment) { + debugLogger.warn( + `taggedThinking: flushing ${segment.length} chars of unclosed thought on stream end`, + ); + } + appendPart(parts, segment, this.mode); + debugLogger.debug( + `taggedThinking: emitted ${parts.length} part(s), flush complete`, + ); + return parts; + } +} + +export function parseTaggedThinkingText(text: string): Part[] { + return new TaggedThinkingParser().parse(text, true); +} diff --git a/packages/core/src/core/openaiContentGenerator/types.ts b/packages/core/src/core/openaiContentGenerator/types.ts index 0c481c7ff45..77daa53189b 100644 --- a/packages/core/src/core/openaiContentGenerator/types.ts +++ b/packages/core/src/core/openaiContentGenerator/types.ts @@ -11,13 +11,17 @@ import type { InputModalities, } from '../contentGenerator.js'; import type { OpenAICompatibleProvider } from './provider/index.js'; +import type { OpenAIResponseParsingOptions } from './responseParsingOptions.js'; import type { StreamingToolCallParser } from './streamingToolCallParser.js'; +import type { TaggedThinkingParser } from './taggedThinkingParser.js'; export interface RequestContext { model: string; modalities: InputModalities; startTime: number; toolCallParser?: StreamingToolCallParser; + responseParsingOptions?: OpenAIResponseParsingOptions; + taggedThinkingParser?: TaggedThinkingParser; // When true, media parts in tool-result messages are split into a follow-up // user message for strict OpenAI-compat servers. See ContentGeneratorConfig // for details.