mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-06 05:55:28 +08:00
fix(models): add vision and reasoning_effort support for mistral-small-2603 (#14541)
### What this PR does Before this PR: - `mistral-small-2603` (Mistral Small 4) was not detected as a vision model, requiring manual capability override - `reasoning_effort` parameter was not passed to Mistral API for models that support adjustable reasoning - Magistral models incorrectly received `reasoning_effort` parameter (causing 422 errors) After this PR: - All `mistral-small-*` variants are auto-detected as vision-capable models - `mistral-small-2603` correctly sends `reasoningEffort: 'none' | 'high'` to Mistral SDK - Magistral models no longer receive `reasoning_effort` parameter (native reasoning, no param needed) - ThinkingButton shows `default / none / high` options for `mistral-small-2603` ### Summary of Changes **Vision detection:** - Broaden regex from `'mistral-small-(2506|latest)'` to `'mistral-small'` for future-proof detection **Reasoning model support:** - Add `mistral-small-2603` to reasoning model detection - Add `isMistralReasoningModel()` function (only matches mistral-small-2603, not magistral) - Add `'mistral'` ThinkingModelType with `['default', 'none', 'high']` options - Register Mistral in `isSupportedReasoningEffortModel()` for UI effort selector **API parameter handling:** - Add Mistral-specific `reasoningEffort` handler in `getReasoningEffort()` (only for mistral-small-2603) - Exclude `magistral-*` from `reasoningEffort` parameter (native reasoning, returns 422 on param) **Test coverage:** - Vision detection tests for mistral-small-2603, provider prefix variants, regression checks - Reasoning model detection test for mistral-small-2603 - Reasoning effort mapping tests for none/high/low values ### Key Technical Details Per Mistral official docs: - `mistral-small-*` supports `reasoning_effort` with values `'none'` | `'high'` only - `magistral-*` models reason natively and **do not accept** `reasoning_effort` parameter ### Review Checklist - [ ] CI passes - [ ] Test `mistral-small-2603` in app: verify image attachment works without manual override - [ ] Test ThinkingButton shows `default / none / high` options - [ ] Verify Magistral models don't trigger 422 errors ### Related Issue Closes #14527 --------- Signed-off-by: suyao <sy20010504@gmail.com> Co-authored-by: SuYao <sy20010504@gmail.com>
This commit is contained in:
@@ -523,6 +523,159 @@ describe('reasoning utils', () => {
|
||||
const result = getReasoningEffort(assistant, model)
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
// Mistral models use reasoningEffort with only 'none' | 'high' support
|
||||
describe('Mistral models (mistral-small-2603 and magistral-*)', () => {
|
||||
// Helper: Create a Mistral model
|
||||
const createMistralModel = (id: string): Model => ({
|
||||
id,
|
||||
name: id,
|
||||
provider: 'mistral',
|
||||
group: 'Mistral'
|
||||
})
|
||||
|
||||
// Helper: Create an assistant with specific reasoning_effort setting
|
||||
const createAssistantWithReasoning = (effort: string | undefined): Assistant =>
|
||||
({
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
settings: {
|
||||
reasoning_effort: effort as any
|
||||
}
|
||||
}) as Assistant
|
||||
|
||||
describe('mistral-small-2603', () => {
|
||||
const mistralModel = createMistralModel('mistral-small-2603')
|
||||
|
||||
it('should return { reasoningEffort: "high" } when reasoning_effort is "high"', async () => {
|
||||
const { isReasoningModel } = await import('@renderer/config/models')
|
||||
vi.mocked(isReasoningModel).mockReturnValue(true)
|
||||
|
||||
const assistant = createAssistantWithReasoning('high')
|
||||
const result = getReasoningEffort(assistant, mistralModel)
|
||||
expect(result).toEqual({ reasoningEffort: 'high' })
|
||||
})
|
||||
|
||||
it('should return { reasoningEffort: "none" } when reasoning_effort is "none"', async () => {
|
||||
const { isReasoningModel } = await import('@renderer/config/models')
|
||||
vi.mocked(isReasoningModel).mockReturnValue(true)
|
||||
|
||||
const assistant = createAssistantWithReasoning('none')
|
||||
const result = getReasoningEffort(assistant, mistralModel)
|
||||
expect(result).toEqual({ reasoningEffort: 'none' })
|
||||
})
|
||||
|
||||
it('should return { reasoningEffort: "high" } when reasoning_effort is "low" (mapping)', async () => {
|
||||
// Mistral models only support 'none' and 'high', so other values map to 'high'
|
||||
const { isReasoningModel } = await import('@renderer/config/models')
|
||||
vi.mocked(isReasoningModel).mockReturnValue(true)
|
||||
|
||||
const assistant = createAssistantWithReasoning('low')
|
||||
const result = getReasoningEffort(assistant, mistralModel)
|
||||
expect(result).toEqual({ reasoningEffort: 'high' })
|
||||
})
|
||||
|
||||
it('should return { reasoningEffort: "high" } when reasoning_effort is "medium" (mapping)', async () => {
|
||||
const { isReasoningModel } = await import('@renderer/config/models')
|
||||
vi.mocked(isReasoningModel).mockReturnValue(true)
|
||||
|
||||
const assistant = createAssistantWithReasoning('medium')
|
||||
const result = getReasoningEffort(assistant, mistralModel)
|
||||
expect(result).toEqual({ reasoningEffort: 'high' })
|
||||
})
|
||||
|
||||
it('should return {} when reasoning_effort is "default"', async () => {
|
||||
const { isReasoningModel } = await import('@renderer/config/models')
|
||||
vi.mocked(isReasoningModel).mockReturnValue(true)
|
||||
|
||||
const assistant = createAssistantWithReasoning('default')
|
||||
const result = getReasoningEffort(assistant, mistralModel)
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it('should return {} when reasoning_effort is undefined', async () => {
|
||||
const { isReasoningModel } = await import('@renderer/config/models')
|
||||
vi.mocked(isReasoningModel).mockReturnValue(true)
|
||||
|
||||
const assistant = {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
settings: {
|
||||
reasoning_effort: undefined
|
||||
}
|
||||
} as Assistant
|
||||
const result = getReasoningEffort(assistant, mistralModel)
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('magistral-small-latest', () => {
|
||||
// Magistral models reason natively — they do NOT accept reasoning_effort parameter
|
||||
const magistralModel = createMistralModel('magistral-small-latest')
|
||||
|
||||
it('should return {} for magistral (native reasoning, no parameter)', async () => {
|
||||
const { isReasoningModel } = await import('@renderer/config/models')
|
||||
vi.mocked(isReasoningModel).mockReturnValue(true)
|
||||
|
||||
const assistant = createAssistantWithReasoning('high')
|
||||
const result = getReasoningEffort(assistant, magistralModel)
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it('should return {} for magistral when reasoning_effort is "none"', async () => {
|
||||
const { isReasoningModel } = await import('@renderer/config/models')
|
||||
vi.mocked(isReasoningModel).mockReturnValue(true)
|
||||
|
||||
const assistant = createAssistantWithReasoning('none')
|
||||
const result = getReasoningEffort(assistant, magistralModel)
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('magistral-medium-latest', () => {
|
||||
// Magistral models reason natively — no reasoning_effort parameter accepted
|
||||
const magistralModel = createMistralModel('magistral-medium-latest')
|
||||
|
||||
it('should return {} for magistral-medium (native reasoning)', async () => {
|
||||
const { isReasoningModel } = await import('@renderer/config/models')
|
||||
vi.mocked(isReasoningModel).mockReturnValue(true)
|
||||
|
||||
const assistant = createAssistantWithReasoning('xhigh')
|
||||
const result = getReasoningEffort(assistant, magistralModel)
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it('should return {} for magistral-medium with auto effort', async () => {
|
||||
const { isReasoningModel } = await import('@renderer/config/models')
|
||||
vi.mocked(isReasoningModel).mockReturnValue(true)
|
||||
|
||||
const assistant = createAssistantWithReasoning('auto')
|
||||
const result = getReasoningEffort(assistant, magistralModel)
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should return {} for non-reasoning Mistral models', async () => {
|
||||
// isReasoningModel returns false by default in the top-level mock
|
||||
const nonReasoningModel = createMistralModel('mistral-large-2407')
|
||||
const assistant = createAssistantWithReasoning('high')
|
||||
const result = getReasoningEffort(assistant, nonReasoningModel)
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it('should handle model ID with different case', async () => {
|
||||
// getLowerBaseModelName converts to lowercase, so case shouldn't matter
|
||||
const { isReasoningModel } = await import('@renderer/config/models')
|
||||
vi.mocked(isReasoningModel).mockReturnValue(true)
|
||||
|
||||
const upperCaseModel = createMistralModel('MISTRAL-SMALL-2603')
|
||||
const assistant = createAssistantWithReasoning('high')
|
||||
const result = getReasoningEffort(assistant, upperCaseModel)
|
||||
expect(result).toEqual({ reasoningEffort: 'high' })
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getOpenAIReasoningParams', () => {
|
||||
|
||||
@@ -213,6 +213,11 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
|
||||
}
|
||||
}
|
||||
|
||||
// Mistral Small models: reasoningEffort 'none'
|
||||
if (modelId.includes('mistral-small-2603')) {
|
||||
return { reasoningEffort: 'none' }
|
||||
}
|
||||
|
||||
logger.warn(`Model ${model.id} doesn't match any disable reasoning behavior. Fallback to empty reasoning param.`)
|
||||
return {}
|
||||
}
|
||||
@@ -505,6 +510,11 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
|
||||
}
|
||||
}
|
||||
|
||||
// Mistral Small models use reasoningEffort with 'none' | 'high'
|
||||
if (modelId.includes('mistral-small-2603')) {
|
||||
return { reasoningEffort: 'high' }
|
||||
}
|
||||
|
||||
// gemini series, openai compatible api
|
||||
if (isSupportedThinkingTokenGeminiModel(model)) {
|
||||
// https://ai.google.dev/gemini-api/docs/gemini-3?thinking=high#openai_compatibility
|
||||
|
||||
@@ -723,6 +723,11 @@ describe('isReasoningModel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Regression test for mistral-small-2603 reasoning support
|
||||
it('should return true for mistral-small-2603', () => {
|
||||
expect(isReasoningModel(createModel({ id: 'mistral-small-2603' }))).toBe(true)
|
||||
})
|
||||
|
||||
it('excludes non-fixed reasoning models from isFixedReasoningModel', () => {
|
||||
// Models that support thinking tokens or reasoning effort should NOT be fixed reasoning models
|
||||
const nonFixedModels = [
|
||||
|
||||
@@ -452,3 +452,29 @@ describe('Gemma 4 Models', () => {
|
||||
expect(isVisionModel(createModel({ id: 'gemma-2-27b-it' }))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Mistral Models', () => {
|
||||
// Regression test for mistral-small-2603 vision support (broken in previous implementation)
|
||||
it('should return true for mistral-small-2603', () => {
|
||||
expect(isVisionModel(createModel({ id: 'mistral-small-2603' }))).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for mistral-small-2603 with provider prefix', () => {
|
||||
expect(isVisionModel(createModel({ id: 'mistralai/mistral-small-2603' }))).toBe(true)
|
||||
})
|
||||
|
||||
// Regression check for existing mistral-small variants
|
||||
it('should return true for mistral-small-latest', () => {
|
||||
expect(isVisionModel(createModel({ id: 'mistral-small-latest' }))).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for mistral-small-2506', () => {
|
||||
expect(isVisionModel(createModel({ id: 'mistral-small-2506' }))).toBe(true)
|
||||
})
|
||||
|
||||
// Regression check for pixtral models (dedicated vision models)
|
||||
it('should return true for pixtral models', () => {
|
||||
expect(isVisionModel(createModel({ id: 'pixtral-12b' }))).toBe(true)
|
||||
expect(isVisionModel(createModel({ id: 'pixtral-large' }))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -84,7 +84,8 @@ export const MODEL_SUPPORTED_REASONING_EFFORT = {
|
||||
// Claude 3.7, 4.0, 4.5 reasoning models
|
||||
claude: ['low', 'medium', 'high'] as const,
|
||||
// Claude 4.6 supports low, medium, high, xhigh (xhigh is mapped to max in API)
|
||||
claude46: ['low', 'medium', 'high', 'xhigh'] as const
|
||||
claude46: ['low', 'medium', 'high', 'xhigh'] as const,
|
||||
mistral: ['high'] as const
|
||||
} as const satisfies ReasoningEffortConfig
|
||||
|
||||
// Model type to supported options mapping
|
||||
@@ -122,7 +123,8 @@ export const MODEL_SUPPORTED_OPTIONS: ThinkingOptionConfig = {
|
||||
deepseek_v4: ['default', 'none', ...MODEL_SUPPORTED_REASONING_EFFORT.deepseek_v4] as const,
|
||||
kimi_k2_5: ['default', ...MODEL_SUPPORTED_REASONING_EFFORT.kimi_k2_5] as const,
|
||||
claude: ['default', 'none', ...MODEL_SUPPORTED_REASONING_EFFORT.claude] as const,
|
||||
claude46: ['default', 'none', ...MODEL_SUPPORTED_REASONING_EFFORT.claude46] as const
|
||||
claude46: ['default', 'none', ...MODEL_SUPPORTED_REASONING_EFFORT.claude46] as const,
|
||||
mistral: ['default', 'none', ...MODEL_SUPPORTED_REASONING_EFFORT.mistral] as const
|
||||
} as const
|
||||
|
||||
// TODO: add ut
|
||||
@@ -212,6 +214,8 @@ const _getThinkModelType = (model: Model): ThinkingModelType => {
|
||||
thinkingModelType = 'mimo'
|
||||
} else if (isSupportedThinkingTokenKimiModel(model)) {
|
||||
thinkingModelType = 'kimi_k2_5'
|
||||
} else if (isMistralReasoningModel(model)) {
|
||||
thinkingModelType = 'mistral'
|
||||
}
|
||||
return thinkingModelType
|
||||
}
|
||||
@@ -322,7 +326,8 @@ export function isSupportedReasoningEffortModel(model?: Model): boolean {
|
||||
return (
|
||||
isSupportedReasoningEffortOpenAIModel(model) ||
|
||||
isSupportedReasoningEffortGrokModel(model) ||
|
||||
isSupportedReasoningEffortPerplexityModel(model)
|
||||
isSupportedReasoningEffortPerplexityModel(model) ||
|
||||
isMistralReasoningModel(model)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -344,6 +349,14 @@ export function isSupportedReasoningEffortGrokModel(model?: Model): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
// Mistral Small models with adjustable reasoning (mistral-small-2603+)
|
||||
// Note: magistral-* models reason natively and do NOT accept reasoning_effort parameter
|
||||
export function isMistralReasoningModel(model?: Model): boolean {
|
||||
if (!model) return false
|
||||
const modelId = getLowerBaseModelName(model.id)
|
||||
return modelId.includes('mistral-small-2603')
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the model is Grok 4 Fast reasoning version
|
||||
* Explicitly excludes non-reasoning variants (models with 'non-reasoning' in their ID)
|
||||
@@ -777,6 +790,7 @@ export function isReasoningModel(model?: Model): boolean {
|
||||
isBaichuanReasoningModel(model) ||
|
||||
isKimiReasoningModel(model) ||
|
||||
modelId.includes('magistral') ||
|
||||
modelId.includes('mistral-small-2603') ||
|
||||
modelId.includes('pangu-pro-moe') ||
|
||||
modelId.includes('seed-oss') ||
|
||||
modelId.includes('deepseek-v3.2-speciale') ||
|
||||
|
||||
@@ -60,7 +60,7 @@ const visionAllowedModels = [
|
||||
'qwen-omni(?:-[\\w-]+)?',
|
||||
'mistral-large-(2512|latest)',
|
||||
'mistral-medium-(2508|latest)',
|
||||
'mistral-small-(2506|latest)',
|
||||
'mistral-small',
|
||||
'mimo-v2\\.5$',
|
||||
'mimo-v2-omni(?:-[\\w-]+)?',
|
||||
'glm-5v-turbo'
|
||||
|
||||
@@ -129,7 +129,8 @@ const ThinkModelTypes = [
|
||||
'deepseek_v4',
|
||||
'kimi_k2_5',
|
||||
'claude',
|
||||
'claude46'
|
||||
'claude46',
|
||||
'mistral'
|
||||
] as const
|
||||
|
||||
/** If the model's reasoning effort could be controlled, or its reasoning behavior could be turned on/off.
|
||||
|
||||
Reference in New Issue
Block a user