Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/core/src/core/openaiContentGenerator/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
DeepSeekOpenAICompatibleProvider,
ModelScopeOpenAICompatibleProvider,
MiniMaxOpenAICompatibleProvider,
MistralOpenAICompatibleProvider,
OpenRouterOpenAICompatibleProvider,
type OpenAICompatibleProvider,
DefaultOpenAICompatibleProvider,
Expand All @@ -29,6 +30,7 @@ export {
DashScopeOpenAICompatibleProvider,
DeepSeekOpenAICompatibleProvider,
MiniMaxOpenAICompatibleProvider,
MistralOpenAICompatibleProvider,
OpenRouterOpenAICompatibleProvider,
} from './provider/index.js';

Expand Down Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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>,
): 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.');
});
});
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) };
delete next['reasoning_content'];
return next as unknown as OpenAI.Chat.ChatCompletionMessageParam;
}
Loading