Skip to content

Commit 7910a5b

Browse files
authored
fix(core): filter Mistral reasoning content at request boundary (#3882)
Co-authored-by: cyphercodes <cyphercodes@users.noreply.github.com>
1 parent 5316edb commit 7910a5b

4 files changed

Lines changed: 210 additions & 0 deletions

File tree

packages/core/src/core/openaiContentGenerator/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
DeepSeekOpenAICompatibleProvider,
1616
ModelScopeOpenAICompatibleProvider,
1717
MiniMaxOpenAICompatibleProvider,
18+
MistralOpenAICompatibleProvider,
1819
OpenRouterOpenAICompatibleProvider,
1920
type OpenAICompatibleProvider,
2021
DefaultOpenAICompatibleProvider,
@@ -29,6 +30,7 @@ export {
2930
DashScopeOpenAICompatibleProvider,
3031
DeepSeekOpenAICompatibleProvider,
3132
MiniMaxOpenAICompatibleProvider,
33+
MistralOpenAICompatibleProvider,
3234
OpenRouterOpenAICompatibleProvider,
3335
} from './provider/index.js';
3436

@@ -98,6 +100,14 @@ export function determineProvider(
98100
);
99101
}
100102

103+
// Check for Mistral provider
104+
if (MistralOpenAICompatibleProvider.isMistralProvider(config)) {
105+
return new MistralOpenAICompatibleProvider(
106+
contentGeneratorConfig,
107+
cliConfig,
108+
);
109+
}
110+
101111
// Default provider for standard OpenAI-compatible APIs
102112
return new DefaultOpenAICompatibleProvider(contentGeneratorConfig, cliConfig);
103113
}

packages/core/src/core/openaiContentGenerator/provider/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export { DashScopeOpenAICompatibleProvider } from './dashscope.js';
33
export { DeepSeekOpenAICompatibleProvider } from './deepseek.js';
44
export { OpenRouterOpenAICompatibleProvider } from './openrouter.js';
55
export { MiniMaxOpenAICompatibleProvider } from './minimax.js';
6+
export { MistralOpenAICompatibleProvider } from './mistral.js';
67
export { DefaultOpenAICompatibleProvider } from './default.js';
78
export type {
89
OpenAICompatibleProvider,
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Qwen
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { describe, expect, it, vi } from 'vitest';
8+
import type OpenAI from 'openai';
9+
import type { Config } from '../../../config/config.js';
10+
import type { ContentGeneratorConfig } from '../../contentGenerator.js';
11+
import { determineProvider } from '../index.js';
12+
13+
function createCliConfig(): Config {
14+
return {
15+
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
16+
getProxy: vi.fn().mockReturnValue(undefined),
17+
} as unknown as Config;
18+
}
19+
20+
function createProviderConfig(
21+
overrides: Partial<ContentGeneratorConfig>,
22+
): ContentGeneratorConfig {
23+
return {
24+
apiKey: 'test-api-key',
25+
baseUrl: 'https://api.mistral.ai/v1',
26+
model: 'mistral-large-latest',
27+
...overrides,
28+
} as ContentGeneratorConfig;
29+
}
30+
31+
function createReasoningRequest(): OpenAI.Chat.ChatCompletionCreateParams {
32+
return {
33+
model: 'mistral-large-latest',
34+
messages: [
35+
{ role: 'user', content: 'Say OK' },
36+
{
37+
role: 'assistant',
38+
content: 'OK',
39+
reasoning_content: 'User asked for a short response.',
40+
} as OpenAI.Chat.ChatCompletionAssistantMessageParam & {
41+
reasoning_content: string;
42+
},
43+
{ role: 'user', content: 'Say OK again' },
44+
],
45+
max_tokens: 1000,
46+
};
47+
}
48+
49+
describe('Mistral provider outbound compatibility filtering', () => {
50+
it('strips reasoning_content from outgoing requests for api.mistral.ai without mutating the source history', () => {
51+
const originalRequest = createReasoningRequest();
52+
const provider = determineProvider(
53+
createProviderConfig({
54+
baseUrl: 'https://api.mistral.ai/v1',
55+
model: 'strict-chat-alias',
56+
}),
57+
createCliConfig(),
58+
);
59+
60+
const result = provider.buildRequest(originalRequest, 'prompt-123');
61+
62+
expect(result.messages?.[1]).toEqual({
63+
role: 'assistant',
64+
content: 'OK',
65+
});
66+
expect(
67+
(originalRequest.messages[1] as { reasoning_content?: string })
68+
.reasoning_content,
69+
).toBe('User asked for a short response.');
70+
});
71+
72+
it('also strips reasoning_content when a Mistral model is served behind a custom base URL', () => {
73+
const originalRequest = createReasoningRequest();
74+
const provider = determineProvider(
75+
createProviderConfig({
76+
baseUrl: 'https://strict-proxy.example.com/v1',
77+
model: 'Mistral-Large-Latest',
78+
}),
79+
createCliConfig(),
80+
);
81+
82+
const result = provider.buildRequest(originalRequest, 'prompt-123');
83+
84+
expect(result.messages?.[1]).toEqual({
85+
role: 'assistant',
86+
content: 'OK',
87+
});
88+
});
89+
90+
it('preserves reasoning_content for non-Mistral OpenAI-compatible providers', () => {
91+
const originalRequest = createReasoningRequest();
92+
const provider = determineProvider(
93+
createProviderConfig({
94+
baseUrl: 'https://api.openai.com/v1',
95+
model: 'gpt-4o',
96+
}),
97+
createCliConfig(),
98+
);
99+
100+
const result = provider.buildRequest(originalRequest, 'prompt-123');
101+
102+
expect(
103+
(result.messages?.[1] as { reasoning_content?: string })
104+
.reasoning_content,
105+
).toBe('User asked for a short response.');
106+
});
107+
108+
it('does not treat hostile hostnames containing api.mistral.ai as Mistral', () => {
109+
const originalRequest = createReasoningRequest();
110+
const provider = determineProvider(
111+
createProviderConfig({
112+
baseUrl: 'https://api.mistral.ai.evil.example/v1',
113+
model: 'gpt-4o',
114+
}),
115+
createCliConfig(),
116+
);
117+
118+
const result = provider.buildRequest(originalRequest, 'prompt-123');
119+
120+
expect(
121+
(result.messages?.[1] as { reasoning_content?: string })
122+
.reasoning_content,
123+
).toBe('User asked for a short response.');
124+
});
125+
});
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Qwen
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import type OpenAI from 'openai';
8+
import type { ContentGeneratorConfig } from '../../contentGenerator.js';
9+
import { DefaultOpenAICompatibleProvider } from './default.js';
10+
11+
const MISTRAL_API_HOST = 'api.mistral.ai';
12+
const MISTRAL_MODEL_MARKERS = [
13+
'mistral',
14+
'mixtral',
15+
'codestral',
16+
'ministral',
17+
'pixtral',
18+
'magistral',
19+
'devstral',
20+
] as const;
21+
22+
function isMistralHostname(config: ContentGeneratorConfig): boolean {
23+
const baseUrl = config.baseUrl ?? '';
24+
if (!baseUrl) return false;
25+
26+
try {
27+
const hostname = new URL(baseUrl).hostname.toLowerCase();
28+
return (
29+
hostname === MISTRAL_API_HOST || hostname.endsWith(`.${MISTRAL_API_HOST}`)
30+
);
31+
} catch {
32+
return false;
33+
}
34+
}
35+
36+
export function isMistralProvider(config: ContentGeneratorConfig): boolean {
37+
if (isMistralHostname(config)) return true;
38+
39+
const model = config.model?.toLowerCase() ?? '';
40+
return MISTRAL_MODEL_MARKERS.some((marker) => model.includes(marker));
41+
}
42+
43+
/**
44+
* Mistral's OpenAI-compatible endpoint rejects non-standard
45+
* `messages[].reasoning_content` fields. Keep shared conversation history
46+
* intact and remove the field only at the outbound request boundary.
47+
*/
48+
export class MistralOpenAICompatibleProvider extends DefaultOpenAICompatibleProvider {
49+
static isMistralProvider = isMistralProvider;
50+
51+
override buildRequest(
52+
request: OpenAI.Chat.ChatCompletionCreateParams,
53+
userPromptId: string,
54+
): OpenAI.Chat.ChatCompletionCreateParams {
55+
const baseRequest = super.buildRequest(request, userPromptId);
56+
57+
return {
58+
...baseRequest,
59+
messages: baseRequest.messages.map(stripReasoningContent),
60+
};
61+
}
62+
}
63+
64+
function stripReasoningContent(
65+
message: OpenAI.Chat.ChatCompletionMessageParam,
66+
): OpenAI.Chat.ChatCompletionMessageParam {
67+
if (!('reasoning_content' in message)) {
68+
return message;
69+
}
70+
71+
const next = { ...(message as unknown as Record<string, unknown>) };
72+
delete next['reasoning_content'];
73+
return next as unknown as OpenAI.Chat.ChatCompletionMessageParam;
74+
}

0 commit comments

Comments
 (0)