From ade00b72a34d65965f14313c3d5e2184dbf07ff1 Mon Sep 17 00:00:00 2001 From: cyphercodes Date: Wed, 6 May 2026 18:30:07 +0300 Subject: [PATCH] fix(core): filter Mistral reasoning content at request boundary --- .../src/core/openaiContentGenerator/index.ts | 10 ++ .../openaiContentGenerator/provider/index.ts | 1 + .../provider/mistral.test.ts | 125 ++++++++++++++++++ .../provider/mistral.ts | 74 +++++++++++ 4 files changed, 210 insertions(+) create mode 100644 packages/core/src/core/openaiContentGenerator/provider/mistral.test.ts create mode 100644 packages/core/src/core/openaiContentGenerator/provider/mistral.ts diff --git a/packages/core/src/core/openaiContentGenerator/index.ts b/packages/core/src/core/openaiContentGenerator/index.ts index 55590631b1f..d22bff03d19 100644 --- a/packages/core/src/core/openaiContentGenerator/index.ts +++ b/packages/core/src/core/openaiContentGenerator/index.ts @@ -15,6 +15,7 @@ import { DeepSeekOpenAICompatibleProvider, ModelScopeOpenAICompatibleProvider, MiniMaxOpenAICompatibleProvider, + MistralOpenAICompatibleProvider, OpenRouterOpenAICompatibleProvider, type OpenAICompatibleProvider, DefaultOpenAICompatibleProvider, @@ -29,6 +30,7 @@ export { DashScopeOpenAICompatibleProvider, DeepSeekOpenAICompatibleProvider, MiniMaxOpenAICompatibleProvider, + MistralOpenAICompatibleProvider, OpenRouterOpenAICompatibleProvider, } from './provider/index.js'; @@ -98,6 +100,14 @@ export function determineProvider( ); } + // Check for Mistral provider + if (MistralOpenAICompatibleProvider.isMistralProvider(config)) { + return new MistralOpenAICompatibleProvider( + contentGeneratorConfig, + cliConfig, + ); + } + // Default provider for standard OpenAI-compatible APIs return new DefaultOpenAICompatibleProvider(contentGeneratorConfig, cliConfig); } diff --git a/packages/core/src/core/openaiContentGenerator/provider/index.ts b/packages/core/src/core/openaiContentGenerator/provider/index.ts index 907c1e9f53c..f5c02ca434e 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/index.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/index.ts @@ -3,6 +3,7 @@ export { DashScopeOpenAICompatibleProvider } from './dashscope.js'; export { DeepSeekOpenAICompatibleProvider } from './deepseek.js'; export { OpenRouterOpenAICompatibleProvider } from './openrouter.js'; export { MiniMaxOpenAICompatibleProvider } from './minimax.js'; +export { MistralOpenAICompatibleProvider } from './mistral.js'; export { DefaultOpenAICompatibleProvider } from './default.js'; export type { OpenAICompatibleProvider, diff --git a/packages/core/src/core/openaiContentGenerator/provider/mistral.test.ts b/packages/core/src/core/openaiContentGenerator/provider/mistral.test.ts new file mode 100644 index 00000000000..47910b6b586 --- /dev/null +++ b/packages/core/src/core/openaiContentGenerator/provider/mistral.test.ts @@ -0,0 +1,125 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; +import type OpenAI from 'openai'; +import type { Config } from '../../../config/config.js'; +import type { ContentGeneratorConfig } from '../../contentGenerator.js'; +import { determineProvider } from '../index.js'; + +function createCliConfig(): Config { + return { + getCliVersion: vi.fn().mockReturnValue('1.0.0'), + getProxy: vi.fn().mockReturnValue(undefined), + } as unknown as Config; +} + +function createProviderConfig( + overrides: Partial, +): ContentGeneratorConfig { + return { + apiKey: 'test-api-key', + baseUrl: 'https://api.mistral.ai/v1', + model: 'mistral-large-latest', + ...overrides, + } as ContentGeneratorConfig; +} + +function createReasoningRequest(): OpenAI.Chat.ChatCompletionCreateParams { + return { + model: 'mistral-large-latest', + messages: [ + { role: 'user', content: 'Say OK' }, + { + role: 'assistant', + content: 'OK', + reasoning_content: 'User asked for a short response.', + } as OpenAI.Chat.ChatCompletionAssistantMessageParam & { + reasoning_content: string; + }, + { role: 'user', content: 'Say OK again' }, + ], + max_tokens: 1000, + }; +} + +describe('Mistral provider outbound compatibility filtering', () => { + it('strips reasoning_content from outgoing requests for api.mistral.ai without mutating the source history', () => { + const originalRequest = createReasoningRequest(); + const provider = determineProvider( + createProviderConfig({ + baseUrl: 'https://api.mistral.ai/v1', + model: 'strict-chat-alias', + }), + createCliConfig(), + ); + + const result = provider.buildRequest(originalRequest, 'prompt-123'); + + expect(result.messages?.[1]).toEqual({ + role: 'assistant', + content: 'OK', + }); + expect( + (originalRequest.messages[1] as { reasoning_content?: string }) + .reasoning_content, + ).toBe('User asked for a short response.'); + }); + + it('also strips reasoning_content when a Mistral model is served behind a custom base URL', () => { + const originalRequest = createReasoningRequest(); + const provider = determineProvider( + createProviderConfig({ + baseUrl: 'https://strict-proxy.example.com/v1', + model: 'Mistral-Large-Latest', + }), + createCliConfig(), + ); + + const result = provider.buildRequest(originalRequest, 'prompt-123'); + + expect(result.messages?.[1]).toEqual({ + role: 'assistant', + content: 'OK', + }); + }); + + it('preserves reasoning_content for non-Mistral OpenAI-compatible providers', () => { + const originalRequest = createReasoningRequest(); + const provider = determineProvider( + createProviderConfig({ + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-4o', + }), + createCliConfig(), + ); + + const result = provider.buildRequest(originalRequest, 'prompt-123'); + + expect( + (result.messages?.[1] as { reasoning_content?: string }) + .reasoning_content, + ).toBe('User asked for a short response.'); + }); + + it('does not treat hostile hostnames containing api.mistral.ai as Mistral', () => { + const originalRequest = createReasoningRequest(); + const provider = determineProvider( + createProviderConfig({ + baseUrl: 'https://api.mistral.ai.evil.example/v1', + model: 'gpt-4o', + }), + createCliConfig(), + ); + + const result = provider.buildRequest(originalRequest, 'prompt-123'); + + expect( + (result.messages?.[1] as { reasoning_content?: string }) + .reasoning_content, + ).toBe('User asked for a short response.'); + }); +}); diff --git a/packages/core/src/core/openaiContentGenerator/provider/mistral.ts b/packages/core/src/core/openaiContentGenerator/provider/mistral.ts new file mode 100644 index 00000000000..ba60cf81538 --- /dev/null +++ b/packages/core/src/core/openaiContentGenerator/provider/mistral.ts @@ -0,0 +1,74 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type OpenAI from 'openai'; +import type { ContentGeneratorConfig } from '../../contentGenerator.js'; +import { DefaultOpenAICompatibleProvider } from './default.js'; + +const MISTRAL_API_HOST = 'api.mistral.ai'; +const MISTRAL_MODEL_MARKERS = [ + 'mistral', + 'mixtral', + 'codestral', + 'ministral', + 'pixtral', + 'magistral', + 'devstral', +] as const; + +function isMistralHostname(config: ContentGeneratorConfig): boolean { + const baseUrl = config.baseUrl ?? ''; + if (!baseUrl) return false; + + try { + const hostname = new URL(baseUrl).hostname.toLowerCase(); + return ( + hostname === MISTRAL_API_HOST || hostname.endsWith(`.${MISTRAL_API_HOST}`) + ); + } catch { + return false; + } +} + +export function isMistralProvider(config: ContentGeneratorConfig): boolean { + if (isMistralHostname(config)) return true; + + const model = config.model?.toLowerCase() ?? ''; + return MISTRAL_MODEL_MARKERS.some((marker) => model.includes(marker)); +} + +/** + * Mistral's OpenAI-compatible endpoint rejects non-standard + * `messages[].reasoning_content` fields. Keep shared conversation history + * intact and remove the field only at the outbound request boundary. + */ +export class MistralOpenAICompatibleProvider extends DefaultOpenAICompatibleProvider { + static isMistralProvider = isMistralProvider; + + override buildRequest( + request: OpenAI.Chat.ChatCompletionCreateParams, + userPromptId: string, + ): OpenAI.Chat.ChatCompletionCreateParams { + const baseRequest = super.buildRequest(request, userPromptId); + + return { + ...baseRequest, + messages: baseRequest.messages.map(stripReasoningContent), + }; + } +} + +function stripReasoningContent( + message: OpenAI.Chat.ChatCompletionMessageParam, +): OpenAI.Chat.ChatCompletionMessageParam { + if (!('reasoning_content' in message)) { + return message; + } + + const next = { ...(message as unknown as Record) }; + delete next['reasoning_content']; + return next as unknown as OpenAI.Chat.ChatCompletionMessageParam; +}