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:
George·Dong
2026-04-25 02:22:23 +08:00
committed by GitHub
parent 4e1e4548bb
commit 63be624f7c
7 changed files with 214 additions and 5 deletions

View File

@@ -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', () => {

View File

@@ -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

View File

@@ -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 = [

View File

@@ -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)
})
})

View File

@@ -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') ||

View File

@@ -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'

View File

@@ -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.