Refactor model handling and deduplication logic in settings service

- Updated ModelDropdown to use option names directly instead of model names.
- Introduced canonicalModelNameForProvider to standardize model names across providers.
- Implemented deduplication of provider models to avoid duplicates and manage aliases effectively.
- Enhanced model fallback logic in modelCapabilities for better compatibility with new AI models.
- Adjusted settings service to incorporate deduplication and normalization of model names.
This commit is contained in:
Jérôme Commaret 2026-05-20 16:02:36 +02:00
parent d4ee802099
commit d5dcfe2f07
4 changed files with 151 additions and 454 deletions

View file

@ -37,8 +37,8 @@ const ModelSelectBox = ({ options, featureName, className }: { options: ModelOpt
options={options}
selectedOption={selectedOption}
onChangeOption={onChangeOption}
getOptionDisplayName={(option) => option.selection.modelName}
getOptionDropdownName={(option) => option.selection.modelName}
getOptionDisplayName={(option) => option.name}
getOptionDropdownName={(option) => option.name}
getOptionDropdownDetail={(option) => option.selection.providerName}
getOptionsEqual={(a, b) => optionsEqual([a], [b])}
className={className}

View file

@ -3,6 +3,7 @@
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import { defaultModelsOfProvider } from './modelCapabilities.js';
import { ProviderName, VoidStatefulModelInfo } from './voidSettingsTypes.js';
/** One loaded model per endpoint (mlx_lm.server / afm). */
@ -13,16 +14,100 @@ export type SingleAutodetectedLocalProvider = typeof singleAutodetectedLocalProv
const canonicalAppleFoundationModelName = (modelName: string) =>
modelName === 'foundation-models' || modelName === 'foundation-model' ? 'foundation' : modelName
/** Map legacy / alias API ids to one canonical name per provider (avoids duplicate list entries). */
export const canonicalModelNameForProvider = (providerName: ProviderName, modelName: string): string => {
const lower = modelName.toLowerCase()
if (providerName === 'appleFoundationModels') {
return canonicalAppleFoundationModelName(modelName)
}
if (providerName === 'anthropic') {
if (lower === 'claude-sonnet-4-5-20250929') return 'claude-sonnet-4-5'
if (lower === 'claude-3-7-sonnet-20250219') return 'claude-3-7-sonnet-latest'
if (lower === 'claude-sonnet-4-20250514') return 'claude-sonnet-4-6'
if (lower === 'claude-opus-4-20250514') return 'claude-opus-4-6'
}
if (providerName === 'openAI') {
if (lower === 'gpt-4o-mini') return 'gpt-4.1-mini'
if (lower === 'gpt-4o') return 'gpt-4.1'
if (lower === 'o1-mini') return 'o4-mini'
if (lower === 'o1') return 'o3'
if (lower === 'o3-mini') return 'o4-mini'
}
if (providerName === 'xAI') {
if (lower.startsWith('grok-2') || lower.startsWith('grok-3')) return 'grok-4.3'
}
if (providerName === 'mistral') {
if (lower === 'magistral-small-latest') return 'mistral-small-latest'
if (lower === 'devstral-small-latest') return 'devstral-latest'
}
if (providerName === 'gemini') {
if (lower.includes('preview') || lower.includes('-exp-') || lower.includes('2.0') || lower.includes('1.5')) {
if (lower.includes('pro')) return 'gemini-2.5-pro'
if (lower.includes('flash-lite') || lower.includes('flash_lite')) return 'gemini-2.5-flash-lite'
return 'gemini-2.5-flash'
}
}
return modelName
}
const modelTypePriority: Record<VoidStatefulModelInfo['type'], number> = {
autodetected: 3,
default: 2,
custom: 1,
}
/** Collapse duplicate model names (aliases, default+custom overlap, etc.). */
export const dedupeProviderModels = (providerName: ProviderName, models: VoidStatefulModelInfo[]): VoidStatefulModelInfo[] => {
const defaultNames = new Set<string>(defaultModelsOfProvider[providerName] ?? [])
const groups = new Map<string, VoidStatefulModelInfo[]>()
for (const model of models) {
const canonical = canonicalModelNameForProvider(providerName, model.modelName)
const normalized = canonical === model.modelName ? model : { ...model, modelName: canonical }
const key = canonical.toLowerCase()
const group = groups.get(key) ?? []
group.push(normalized)
groups.set(key, group)
}
const deduped: VoidStatefulModelInfo[] = []
for (const group of groups.values()) {
const best = group.reduce((keep, candidate) => {
const keepPriority = modelTypePriority[keep.type]
const candidatePriority = modelTypePriority[candidate.type]
if (candidatePriority > keepPriority) return candidate
if (keepPriority > candidatePriority) return keep
const keepInDefaults = defaultNames.has(keep.modelName) ? 1 : 0
const candidateInDefaults = defaultNames.has(candidate.modelName) ? 1 : 0
return candidateInDefaults > keepInDefaults ? candidate : keep
})
deduped.push({
...best,
modelName: best.modelName,
isHidden: group.every(m => m.isHidden),
})
}
return deduped
}
export const normalizeAutodetectedModelNamesForProvider = (providerName: ProviderName, modelNames: string[]): string[] => {
if (providerName === 'appleFoundationModels') {
const normalized = modelNames.map(canonicalAppleFoundationModelName)
return [new Set(normalized).values().next().value ?? 'foundation']
}
if (providerName === 'mlx') {
const unique = [...new Set(modelNames)]
const unique = [...new Set(modelNames.map(n => canonicalModelNameForProvider(providerName, n)))]
return unique.length > 0 ? [unique[0]] : []
}
return modelNames
return modelNames.map(n => canonicalModelNameForProvider(providerName, n))
}
export const consolidateSingleAutodetectedProviderModels = (
@ -45,8 +130,8 @@ export const consolidateSingleAutodetectedProviderModels = (
modelName: primaryName,
type: 'autodetected',
}
return [
return dedupeProviderModels(providerName, [
primary,
...customModels.filter(m => m.modelName !== primaryName),
]
...customModels.filter(m => canonicalModelNameForProvider(providerName, m.modelName).toLowerCase() !== primaryName.toLowerCase()),
])
}

View file

@ -420,8 +420,10 @@ const extensiveModelOptionsFallback: VoidStaticProviderInfo['modelOptionsFallbac
if (lower.includes('flash')) return toFallback(geminiModelOptions, 'gemini-2.5-flash')
return toFallback(geminiModelOptions, 'gemini-2.5-pro')
}
if (lower.includes('gemini')) return toFallback(geminiModelOptions, 'gemini-2.5-flash')
if (lower.includes('claude-3-5') || lower.includes('claude-3.5')) return toFallback(anthropicModelOptions, 'claude-3-5-sonnet-20241022')
if (lower.includes('claude-3-7') || lower.includes('claude-3.7')) return toFallback(anthropicModelOptions, 'claude-3-7-sonnet-20250219')
if (lower.includes('claude-3-5') || lower.includes('claude-3.5')) return toFallback(anthropicModelOptions, 'claude-sonnet-4-6')
if (lower.includes('opus-4-7') || lower.includes('opus-4.7')) return toFallback(anthropicModelOptions, 'claude-opus-4-7')
if (lower.includes('sonnet-4-6') || lower.includes('sonnet-4.6')) return toFallback(anthropicModelOptions, 'claude-sonnet-4-6')
if (lower.includes('haiku-4-5') || lower.includes('haiku-4.5')) return toFallback(anthropicModelOptions, 'claude-haiku-4-5')
@ -434,7 +436,7 @@ const extensiveModelOptionsFallback: VoidStaticProviderInfo['modelOptionsFallbac
if (lower.includes('reasoning')) return toFallback(xAIModelOptions, 'grok-4.20-0309-reasoning')
return toFallback(xAIModelOptions, 'grok-4.3')
}
if (lower.includes('grok2') || lower.includes('grok-2')) return toFallback(xAIModelOptions, 'grok-2')
if (lower.includes('grok2') || lower.includes('grok-2') || lower.includes('grok-3') || lower.includes('grok3')) return toFallback(xAIModelOptions, 'grok-4.3')
if (lower.includes('grok')) return toFallback(xAIModelOptions, 'grok-4.3')
if (lower.includes('deepseek') && (lower.includes('v4-pro') || lower.includes('v4_pro'))) return toFallback(deepseekModelOptions, 'deepseek-v4-pro')
@ -473,12 +475,12 @@ const extensiveModelOptionsFallback: VoidStaticProviderInfo['modelOptionsFallbac
if (lower.includes('gpt') && lower.includes('nano') && (lower.includes('4.1') || lower.includes('4-1'))) return toFallback(openAIModelOptions, 'gpt-4.1-nano')
if (lower.includes('gpt') && (lower.includes('4.1') || lower.includes('4-1'))) return toFallback(openAIModelOptions, 'gpt-4.1')
if (lower.includes('4o') && lower.includes('mini')) return toFallback(openAIModelOptions, 'gpt-4o-mini')
if (lower.includes('4o')) return toFallback(openAIModelOptions, 'gpt-4o')
if (lower.includes('4o') && lower.includes('mini')) return toFallback(openAIModelOptions, 'gpt-4.1-mini')
if (lower.includes('4o')) return toFallback(openAIModelOptions, 'gpt-4.1')
if (lower.includes('o1') && lower.includes('mini')) return toFallback(openAIModelOptions, 'o1-mini')
if (lower.includes('o1')) return toFallback(openAIModelOptions, 'o1')
if (lower.includes('o3') && lower.includes('mini')) return toFallback(openAIModelOptions, 'o3-mini')
if (lower.includes('o1') && lower.includes('mini')) return toFallback(openAIModelOptions, 'o4-mini')
if (lower.includes('o1')) return toFallback(openAIModelOptions, 'o3')
if (lower.includes('o3') && lower.includes('mini')) return toFallback(openAIModelOptions, 'o4-mini')
if (lower.includes('o3')) return toFallback(openAIModelOptions, 'o3')
if (lower.includes('o4') && lower.includes('mini')) return toFallback(openAIModelOptions, 'o4-mini')
@ -565,67 +567,6 @@ const anthropicModelOptions = {
reasoningCapabilities: anthropicThinkingCapabilities,
},
'claude-opus-4-20250514': {
contextWindow: 200_000,
reservedOutputTokenSpace: 8_192,
cost: { input: 15.00, cache_read: 1.50, cache_write: 18.75, output: 30.00 },
downloadable: false,
supportsFIM: false,
specialToolFormat: 'anthropic-style',
supportsSystemMessage: 'separated',
reasoningCapabilities: anthropicThinkingCapabilities,
},
'claude-sonnet-4-20250514': {
contextWindow: 200_000,
reservedOutputTokenSpace: 8_192,
cost: { input: 3.00, cache_read: 0.30, cache_write: 3.75, output: 6.00 },
downloadable: false,
supportsFIM: false,
specialToolFormat: 'anthropic-style',
supportsSystemMessage: 'separated',
reasoningCapabilities: anthropicThinkingCapabilities,
},
'claude-3-5-sonnet-20241022': {
contextWindow: 200_000,
reservedOutputTokenSpace: 8_192,
cost: { input: 3.00, cache_read: 0.30, cache_write: 3.75, output: 15.00 },
downloadable: false,
supportsFIM: false,
specialToolFormat: 'anthropic-style',
supportsSystemMessage: 'separated',
reasoningCapabilities: false,
},
'claude-3-5-haiku-20241022': {
contextWindow: 200_000,
reservedOutputTokenSpace: 8_192,
cost: { input: 0.80, cache_read: 0.08, cache_write: 1.00, output: 4.00 },
downloadable: false,
supportsFIM: false,
specialToolFormat: 'anthropic-style',
supportsSystemMessage: 'separated',
reasoningCapabilities: false,
},
'claude-3-opus-20240229': {
contextWindow: 200_000,
reservedOutputTokenSpace: 4_096,
cost: { input: 15.00, cache_read: 1.50, cache_write: 18.75, output: 75.00 },
downloadable: false,
supportsFIM: false,
specialToolFormat: 'anthropic-style',
supportsSystemMessage: 'separated',
reasoningCapabilities: false,
},
'claude-3-sonnet-20240229': { // no point of using this, but including this for people who put it in
contextWindow: 200_000, cost: { input: 3.00, output: 15.00 },
downloadable: false,
reservedOutputTokenSpace: 4_096,
supportsFIM: false,
specialToolFormat: 'anthropic-style',
supportsSystemMessage: 'separated',
reasoningCapabilities: false,
}
} as const satisfies { [s: string]: VoidStaticModelInfo }
const anthropicSettings: VoidStaticProviderInfo = {
@ -650,13 +591,10 @@ const anthropicSettings: VoidStaticProviderInfo = {
else if (lower.includes('haiku-4-5') || lower.includes('haiku-4.5')) fallbackName = 'claude-haiku-4-5'
else if (lower.includes('opus-4-6') || lower.includes('opus-4.6')) fallbackName = 'claude-opus-4-6'
else if (lower.includes('sonnet-4-5') || lower.includes('sonnet-4.5')) fallbackName = 'claude-sonnet-4-5-20250929'
else if (lower.includes('claude-4-opus') || lower.includes('claude-opus-4')) fallbackName = 'claude-opus-4-20250514'
else if (lower.includes('claude-4-sonnet') || lower.includes('claude-sonnet-4')) fallbackName = 'claude-sonnet-4-20250514'
else if (lower.includes('claude-3-7-sonnet')) fallbackName = 'claude-3-7-sonnet-20250219'
else if (lower.includes('claude-3-5-sonnet')) fallbackName = 'claude-3-5-sonnet-20241022'
else if (lower.includes('claude-3-5-haiku')) fallbackName = 'claude-3-5-haiku-20241022'
else if (lower.includes('claude-3-opus')) fallbackName = 'claude-3-opus-20240229'
else if (lower.includes('claude-3-sonnet')) fallbackName = 'claude-3-sonnet-20240229'
else if (lower.includes('claude-3-5-sonnet') || lower.includes('claude-3-5-haiku') || lower.includes('claude-3-opus') || lower.includes('claude-3-sonnet')) fallbackName = 'claude-sonnet-4-6'
else if (lower.includes('claude-4-opus') || lower.includes('claude-opus-4')) fallbackName = 'claude-opus-4-6'
else if (lower.includes('claude-4-sonnet') || lower.includes('claude-sonnet-4')) fallbackName = 'claude-sonnet-4-6'
if (fallbackName) return { modelName: fallbackName, recognizedModelName: fallbackName, ...anthropicModelOptions[fallbackName] }
return null
},
@ -762,53 +700,6 @@ const openAIModelOptions = { // https://platform.openai.com/docs/pricing
supportsSystemMessage: 'developer-role',
reasoningCapabilities: false,
},
'o1': {
contextWindow: 128_000,
reservedOutputTokenSpace: 100_000,
cost: { input: 15.00, cache_read: 7.50, output: 60.00, },
downloadable: false,
supportsFIM: false,
supportsSystemMessage: 'developer-role',
reasoningCapabilities: { supportsReasoning: true, canTurnOffReasoning: false, canIOReasoning: false, reasoningSlider: { type: 'effort_slider', values: ['low', 'medium', 'high'], default: 'low' } },
},
'o3-mini': {
contextWindow: 200_000,
reservedOutputTokenSpace: 100_000,
cost: { input: 1.10, cache_read: 0.55, output: 4.40, },
downloadable: false,
supportsFIM: false,
supportsSystemMessage: 'developer-role',
reasoningCapabilities: { supportsReasoning: true, canTurnOffReasoning: false, canIOReasoning: false, reasoningSlider: { type: 'effort_slider', values: ['low', 'medium', 'high'], default: 'low' } },
},
'gpt-4o': {
contextWindow: 128_000,
reservedOutputTokenSpace: 16_384,
cost: { input: 2.50, cache_read: 1.25, output: 10.00, },
downloadable: false,
supportsFIM: false,
specialToolFormat: 'openai-style',
supportsSystemMessage: 'system-role',
reasoningCapabilities: false,
},
'o1-mini': {
contextWindow: 128_000,
reservedOutputTokenSpace: 65_536,
cost: { input: 1.10, cache_read: 0.55, output: 4.40, },
downloadable: false,
supportsFIM: false,
supportsSystemMessage: false, // does not support any system
reasoningCapabilities: { supportsReasoning: true, canTurnOffReasoning: false, canIOReasoning: false, reasoningSlider: { type: 'effort_slider', values: ['low', 'medium', 'high'], default: 'low' } },
},
'gpt-4o-mini': {
contextWindow: 128_000,
reservedOutputTokenSpace: 16_384,
cost: { input: 0.15, cache_read: 0.075, output: 0.60, },
downloadable: false,
supportsFIM: false,
specialToolFormat: 'openai-style',
supportsSystemMessage: 'system-role', // ??
reasoningCapabilities: false,
},
} as const satisfies { [s: string]: VoidStaticModelInfo }
@ -837,17 +728,17 @@ const openAISettings: VoidStaticProviderInfo = {
else fallbackName = 'gpt-5.4'
}
else if (lower.includes('o4') && lower.includes('mini')) fallbackName = 'o4-mini'
else if (lower.includes('o3') && lower.includes('mini')) fallbackName = 'o3-mini'
else if (lower.includes('o3') && lower.includes('mini')) fallbackName = 'o4-mini'
else if (lower.includes('o3')) fallbackName = 'o3'
else if (lower.includes('o1') && lower.includes('mini')) fallbackName = 'o1-mini'
else if (lower.includes('o1')) fallbackName = 'o1'
else if (lower.includes('o1') && lower.includes('mini')) fallbackName = 'o4-mini'
else if (lower.includes('o1')) fallbackName = 'o3'
else if (lower.includes('gpt-4.1') || lower.includes('gpt4.1')) {
if (lower.includes('nano')) fallbackName = 'gpt-4.1-nano'
else if (lower.includes('mini')) fallbackName = 'gpt-4.1-mini'
else fallbackName = 'gpt-4.1'
}
else if (lower.includes('4o') && lower.includes('mini')) fallbackName = 'gpt-4o-mini'
else if (lower.includes('4o') || lower.includes('gpt-4o')) fallbackName = 'gpt-4o'
else if (lower.includes('4o') && lower.includes('mini')) fallbackName = 'gpt-4.1-mini'
else if (lower.includes('4o') || lower.includes('gpt-4o')) fallbackName = 'gpt-4.1'
if (fallbackName) return { modelName: fallbackName, recognizedModelName: fallbackName, ...openAIModelOptions[fallbackName] }
return null
},
@ -897,57 +788,6 @@ const xAIModelOptions = {
specialToolFormat: 'openai-style',
reasoningCapabilities: false,
},
'grok-2': {
contextWindow: 131_072,
reservedOutputTokenSpace: null,
cost: { input: 2.00, output: 10.00 },
downloadable: false,
supportsFIM: false,
supportsSystemMessage: 'system-role',
specialToolFormat: 'openai-style',
reasoningCapabilities: false,
},
'grok-3': {
contextWindow: 131_072,
reservedOutputTokenSpace: null,
cost: { input: 3.00, output: 15.00 },
downloadable: false,
supportsFIM: false,
supportsSystemMessage: 'system-role',
specialToolFormat: 'openai-style',
reasoningCapabilities: false,
},
'grok-3-fast': {
contextWindow: 131_072,
reservedOutputTokenSpace: null,
cost: { input: 5.00, output: 25.00 },
downloadable: false,
supportsFIM: false,
supportsSystemMessage: 'system-role',
specialToolFormat: 'openai-style',
reasoningCapabilities: false,
},
// only mini supports thinking
'grok-3-mini': {
contextWindow: 131_072,
reservedOutputTokenSpace: null,
cost: { input: 0.30, output: 0.50 },
downloadable: false,
supportsFIM: false,
supportsSystemMessage: 'system-role',
specialToolFormat: 'openai-style',
reasoningCapabilities: { supportsReasoning: true, canTurnOffReasoning: false, canIOReasoning: false, reasoningSlider: { type: 'effort_slider', values: ['low', 'high'], default: 'low' } },
},
'grok-3-mini-fast': {
contextWindow: 131_072,
reservedOutputTokenSpace: null,
cost: { input: 0.60, output: 4.00 },
downloadable: false,
supportsFIM: false,
supportsSystemMessage: 'system-role',
specialToolFormat: 'openai-style',
reasoningCapabilities: { supportsReasoning: true, canTurnOffReasoning: false, canIOReasoning: false, reasoningSlider: { type: 'effort_slider', values: ['low', 'high'], default: 'low' } },
},
} as const satisfies { [s: string]: VoidStaticModelInfo }
const xAISettings: VoidStaticProviderInfo = {
@ -960,8 +800,7 @@ const xAISettings: VoidStaticProviderInfo = {
else fallbackName = 'grok-4.20-0309-reasoning'
}
else if (lower.includes('grok-4') || lower.includes('grok4.3') || lower.includes('grok-4.3')) fallbackName = 'grok-4.3'
else if (lower.includes('grok-2')) fallbackName = 'grok-2'
else if (lower.includes('grok-3')) fallbackName = 'grok-4.3' // grok-3 aliases redirect to grok-4.3
else if (lower.includes('grok-2') || lower.includes('grok-3')) fallbackName = 'grok-4.3'
else if (lower.includes('grok')) fallbackName = 'grok-4.3'
if (fallbackName) return { modelName: fallbackName, recognizedModelName: fallbackName, ...xAIModelOptions[fallbackName] }
return null
@ -1039,115 +878,6 @@ const geminiModelOptions = { // https://ai.google.dev/gemini-api/docs/pricing
reasoningReservedOutputTokenSpace: 8192,
},
},
// https://ai.google.dev/gemini-api/docs/thinking#set-budget
'gemini-2.5-pro-preview-05-06': {
contextWindow: 1_048_576,
reservedOutputTokenSpace: 8_192,
cost: { input: 0, output: 0 },
downloadable: false,
supportsFIM: false,
supportsSystemMessage: 'separated',
specialToolFormat: 'gemini-style',
reasoningCapabilities: {
supportsReasoning: true,
canTurnOffReasoning: true,
canIOReasoning: false,
reasoningSlider: { type: 'budget_slider', min: 1024, max: 8192, default: 1024 }, // max is really 24576
reasoningReservedOutputTokenSpace: 8192,
},
},
'gemini-2.0-flash-lite': {
contextWindow: 1_048_576,
reservedOutputTokenSpace: 8_192,
cost: { input: 0, output: 0 },
downloadable: false,
supportsFIM: false,
supportsSystemMessage: 'separated',
specialToolFormat: 'gemini-style',
reasoningCapabilities: false, // no reasoning
},
'gemini-2.5-flash-preview-04-17': {
contextWindow: 1_048_576,
reservedOutputTokenSpace: 8_192,
cost: { input: 0.15, output: .60 }, // TODO $3.50 output with thinking not included
downloadable: false,
supportsFIM: false,
supportsSystemMessage: 'separated',
specialToolFormat: 'gemini-style',
reasoningCapabilities: {
supportsReasoning: true,
canTurnOffReasoning: true,
canIOReasoning: false,
reasoningSlider: { type: 'budget_slider', min: 1024, max: 8192, default: 1024 }, // max is really 24576
reasoningReservedOutputTokenSpace: 8192,
},
},
'gemini-2.5-pro-exp-03-25': {
contextWindow: 1_048_576,
reservedOutputTokenSpace: 8_192,
cost: { input: 0, output: 0 },
downloadable: false,
supportsFIM: false,
supportsSystemMessage: 'separated',
specialToolFormat: 'gemini-style',
reasoningCapabilities: {
supportsReasoning: true,
canTurnOffReasoning: true,
canIOReasoning: false,
reasoningSlider: { type: 'budget_slider', min: 1024, max: 8192, default: 1024 }, // max is really 24576
reasoningReservedOutputTokenSpace: 8192,
},
},
'gemini-2.0-flash': {
contextWindow: 1_048_576,
reservedOutputTokenSpace: 8_192, // 8_192,
cost: { input: 0.10, output: 0.40 },
downloadable: false,
supportsFIM: false,
supportsSystemMessage: 'separated',
specialToolFormat: 'gemini-style',
reasoningCapabilities: false,
},
'gemini-2.0-flash-lite-preview-02-05': {
contextWindow: 1_048_576,
reservedOutputTokenSpace: 8_192, // 8_192,
cost: { input: 0.075, output: 0.30 },
downloadable: false,
supportsFIM: false,
supportsSystemMessage: 'separated',
specialToolFormat: 'gemini-style',
reasoningCapabilities: false,
},
'gemini-1.5-flash': {
contextWindow: 1_048_576,
reservedOutputTokenSpace: 8_192, // 8_192,
cost: { input: 0.075, output: 0.30 }, // TODO!!! price doubles after 128K tokens, we are NOT encoding that info right now
downloadable: false,
supportsFIM: false,
supportsSystemMessage: 'separated',
specialToolFormat: 'gemini-style',
reasoningCapabilities: false,
},
'gemini-1.5-pro': {
contextWindow: 2_097_152,
reservedOutputTokenSpace: 8_192,
cost: { input: 1.25, output: 5.00 }, // TODO!!! price doubles after 128K tokens, we are NOT encoding that info right now
downloadable: false,
supportsFIM: false,
supportsSystemMessage: 'separated',
specialToolFormat: 'gemini-style',
reasoningCapabilities: false,
},
'gemini-1.5-flash-8b': {
contextWindow: 1_048_576,
reservedOutputTokenSpace: 8_192,
cost: { input: 0.0375, output: 0.15 }, // TODO!!! price doubles after 128K tokens, we are NOT encoding that info right now
downloadable: false,
supportsFIM: false,
supportsSystemMessage: 'separated',
specialToolFormat: 'gemini-style',
reasoningCapabilities: false,
},
} as const satisfies { [s: string]: VoidStaticModelInfo }
const geminiSettings: VoidStaticProviderInfo = {
@ -1161,9 +891,7 @@ const geminiSettings: VoidStaticProviderInfo = {
else if (lower.includes('flash')) fallbackName = 'gemini-2.5-flash'
else fallbackName = 'gemini-2.5-pro'
}
else if (lower.includes('2.0') && lower.includes('flash')) fallbackName = 'gemini-2.0-flash'
else if (lower.includes('1.5') && lower.includes('pro')) fallbackName = 'gemini-1.5-pro'
else if (lower.includes('1.5') && lower.includes('flash')) fallbackName = 'gemini-1.5-flash'
else if (lower.includes('2.0') || lower.includes('1.5') || lower.includes('preview') || lower.includes('-exp-')) fallbackName = 'gemini-2.5-flash'
if (fallbackName) return { modelName: fallbackName, recognizedModelName: fallbackName, ...geminiModelOptions[fallbackName] }
return null
},
@ -1289,26 +1017,6 @@ const mistralModelOptions = { // https://docs.mistral.ai/getting-started/models/
supportsSystemMessage: 'system-role',
reasoningCapabilities: { supportsReasoning: true, canIOReasoning: true, canTurnOffReasoning: false, openSourceThinkTags: ['<think>', '</think>'] },
},
'magistral-small-latest': { // Magistral Small 1.2 (deprecated → Mistral Small 4) — https://docs.mistral.ai/models/model-cards/magistral-small-1-2-25-09
contextWindow: 128_000,
reservedOutputTokenSpace: 8_192,
cost: { input: 0.30, output: 0.90 },
supportsFIM: false,
specialToolFormat: 'openai-style',
downloadable: { sizeGb: 'not-known' },
supportsSystemMessage: 'system-role',
reasoningCapabilities: { supportsReasoning: true, canIOReasoning: true, canTurnOffReasoning: false, openSourceThinkTags: ['<think>', '</think>'] },
},
'devstral-small-latest': { // Devstral Small 2 (labs, deprecated → Devstral 2) — https://docs.mistral.ai/models/model-cards/devstral-small-2-25-12
contextWindow: 256_000,
reservedOutputTokenSpace: 8_192,
cost: { input: 0.20, output: 0.80 },
supportsFIM: false,
specialToolFormat: 'openai-style',
downloadable: { sizeGb: 14 },
supportsSystemMessage: 'system-role',
reasoningCapabilities: false,
},
'ministral-14b-latest': { // Ministral 3 14B — https://docs.mistral.ai/models/model-cards/ministral-3-14b-25-12
contextWindow: 256_000,
reservedOutputTokenSpace: 4_096,
@ -1347,8 +1055,8 @@ const mistralSettings: VoidStaticProviderInfo = {
const lower = modelName.toLowerCase()
let fallbackName: keyof typeof mistralModelOptions | null = null
if (lower.includes('codestral')) fallbackName = 'codestral-latest'
else if (lower.includes('magistral')) fallbackName = lower.includes('small') ? 'magistral-small-latest' : 'magistral-medium-latest'
else if (lower.includes('devstral')) fallbackName = lower.includes('small') ? 'devstral-small-latest' : 'devstral-latest'
else if (lower.includes('magistral')) fallbackName = lower.includes('small') ? 'mistral-small-latest' : 'magistral-medium-latest'
else if (lower.includes('devstral')) fallbackName = 'devstral-latest'
else if (lower.includes('ministral')) {
if (lower.includes('14')) fallbackName = 'ministral-14b-latest'
else if (lower.includes('8')) fallbackName = 'ministral-8b-latest'
@ -1719,51 +1427,6 @@ const openRouterModelOptions_assumingOpenAICompat = {
supportsSystemMessage: 'system-role',
reasoningCapabilities: { supportsReasoning: true, canIOReasoning: true, canTurnOffReasoning: false },
},
'microsoft/phi-4-reasoning-plus:free': { // a 14B model...
contextWindow: 32_768,
reservedOutputTokenSpace: null,
cost: { input: 0, output: 0 },
downloadable: false,
supportsFIM: false,
supportsSystemMessage: 'system-role',
reasoningCapabilities: { supportsReasoning: true, canIOReasoning: true, canTurnOffReasoning: false },
},
'mistralai/mistral-small-3.1-24b-instruct:free': {
contextWindow: 128_000,
reservedOutputTokenSpace: null,
cost: { input: 0, output: 0 },
downloadable: false,
supportsFIM: false,
supportsSystemMessage: 'system-role',
reasoningCapabilities: false,
},
'google/gemini-2.0-flash-lite-preview-02-05:free': {
contextWindow: 1_048_576,
reservedOutputTokenSpace: null,
cost: { input: 0, output: 0 },
downloadable: false,
supportsFIM: false,
supportsSystemMessage: 'system-role',
reasoningCapabilities: false,
},
'google/gemini-2.0-pro-exp-02-05:free': {
contextWindow: 1_048_576,
reservedOutputTokenSpace: null,
cost: { input: 0, output: 0 },
downloadable: false,
supportsFIM: false,
supportsSystemMessage: 'system-role',
reasoningCapabilities: false,
},
'google/gemini-2.0-flash-exp:free': {
contextWindow: 1_048_576,
reservedOutputTokenSpace: null,
cost: { input: 0, output: 0 },
downloadable: false,
supportsFIM: false,
supportsSystemMessage: 'system-role',
reasoningCapabilities: false,
},
'deepseek/deepseek-r1': {
...openSourceModelOptions_assumingOAICompat.deepseekR1,
contextWindow: 128_000,
@ -1771,87 +1434,6 @@ const openRouterModelOptions_assumingOpenAICompat = {
cost: { input: 0.8, output: 2.4 },
downloadable: false,
},
'anthropic/claude-opus-4': {
contextWindow: 200_000,
reservedOutputTokenSpace: null,
cost: { input: 15.00, output: 75.00 },
downloadable: false,
supportsFIM: false,
supportsSystemMessage: 'system-role',
reasoningCapabilities: false,
},
'anthropic/claude-sonnet-4': {
contextWindow: 200_000,
reservedOutputTokenSpace: null,
cost: { input: 3.00, output: 15.00 },
downloadable: false,
supportsFIM: false,
supportsSystemMessage: 'system-role',
reasoningCapabilities: false,
},
'anthropic/claude-3.7-sonnet:thinking': {
contextWindow: 200_000,
reservedOutputTokenSpace: null,
cost: { input: 3.00, output: 15.00 },
downloadable: false,
supportsFIM: false,
supportsSystemMessage: 'system-role',
reasoningCapabilities: { // same as anthropic, see above
supportsReasoning: true,
canTurnOffReasoning: false,
canIOReasoning: true,
reasoningReservedOutputTokenSpace: 8192,
reasoningSlider: { type: 'budget_slider', min: 1024, max: 8192, default: 1024 }, // they recommend batching if max > 32_000.
},
},
'anthropic/claude-3.7-sonnet': {
contextWindow: 200_000,
reservedOutputTokenSpace: null,
cost: { input: 3.00, output: 15.00 },
downloadable: false,
supportsFIM: false,
supportsSystemMessage: 'system-role',
reasoningCapabilities: false, // stupidly, openrouter separates thinking from non-thinking
},
'anthropic/claude-3.5-sonnet': {
contextWindow: 200_000,
reservedOutputTokenSpace: null,
cost: { input: 3.00, output: 15.00 },
downloadable: false,
supportsFIM: false,
supportsSystemMessage: 'system-role',
reasoningCapabilities: false,
},
'mistralai/codestral-2501': {
...openSourceModelOptions_assumingOAICompat.codestral,
contextWindow: 256_000,
reservedOutputTokenSpace: null,
cost: { input: 0.3, output: 0.9 },
downloadable: false,
reasoningCapabilities: false,
},
'mistralai/devstral-small:free': {
...openSourceModelOptions_assumingOAICompat.devstral,
contextWindow: 130_000,
reservedOutputTokenSpace: null,
cost: { input: 0, output: 0 },
downloadable: false,
reasoningCapabilities: false,
},
'qwen/qwen-2.5-coder-32b-instruct': {
...openSourceModelOptions_assumingOAICompat['qwen2.5coder'],
contextWindow: 33_000,
reservedOutputTokenSpace: null,
cost: { input: 0.07, output: 0.16 },
downloadable: false,
},
'qwen/qwq-32b': {
...openSourceModelOptions_assumingOAICompat['qwq'],
contextWindow: 33_000,
reservedOutputTokenSpace: null,
cost: { input: 0.07, output: 0.16 },
downloadable: false,
}
} as const satisfies { [s: string]: VoidStaticModelInfo }
const openRouterSettings: VoidStaticProviderInfo = {

View file

@ -14,7 +14,7 @@ import { IMetricsService } from './metricsService.js';
import { defaultProviderSettings, getModelCapabilities, ModelOverrides } from './modelCapabilities.js';
import { VOID_SETTINGS_STORAGE_KEY } from './storageKeys.js';
import { isMacintosh } from '../../../../base/common/platform.js';
import { consolidateSingleAutodetectedProviderModels, normalizeAutodetectedModelNamesForProvider } from './localSingleModelProviders.js';
import { consolidateSingleAutodetectedProviderModels, dedupeProviderModels, normalizeAutodetectedModelNamesForProvider } from './localSingleModelProviders.js';
import { defaultSettingsOfProvider, FeatureName, ProviderName, ModelSelectionOfFeature, SettingsOfProvider, SettingName, providerNames, ModelSelection, modelSelectionsEqual, featureNames, VoidStatefulModelInfo, GlobalSettings, GlobalSettingName, defaultGlobalSettings, ModelSelectionOptions, OptionsOfModelSelection, ChatMode, OverridesOfModel, defaultOverridesOfModel, MCPUserStateOfName as MCPUserStateOfName, MCPUserState } from './voidSettingsTypes.js';
@ -86,8 +86,8 @@ export interface IVoidSettingsService {
const _modelsWithSwappedInNewModels = (options: { existingModels: VoidStatefulModelInfo[], models: string[], type: 'autodetected' | 'default' }) => {
const { existingModels, models, type } = options
const _modelsWithSwappedInNewModels = (options: { providerName: ProviderName, existingModels: VoidStatefulModelInfo[], models: string[], type: 'autodetected' | 'default' }) => {
const { providerName, existingModels, models, type } = options
const existingModelsMap: Record<string, VoidStatefulModelInfo> = {}
for (const existingModel of existingModels) {
@ -96,13 +96,13 @@ const _modelsWithSwappedInNewModels = (options: { existingModels: VoidStatefulMo
const newDefaultModels = models.map((modelName, i) => ({ modelName, type, isHidden: !!existingModelsMap[modelName]?.isHidden, }))
return [
return dedupeProviderModels(providerName, [
...newDefaultModels, // swap out all the models of this type for the new models of this type
...existingModels.filter(m => {
const keep = m.type !== type
return keep
})
]
])
}
export const modelFilterOfFeatureName: {
@ -129,7 +129,7 @@ const _stateWithMergedDefaultModels = (state: VoidSettingsState): VoidSettingsSt
const defaultModels = defaultSettingsOfProvider[providerName]?.models ?? []
const currentModels = newSettingsOfProvider[providerName]?.models ?? []
const defaultModelNames = defaultModels.map(m => m.modelName)
const newModels = _modelsWithSwappedInNewModels({ existingModels: currentModels, models: defaultModelNames, type: 'default' })
const newModels = _modelsWithSwappedInNewModels({ providerName, existingModels: currentModels, models: defaultModelNames, type: 'default' })
newSettingsOfProvider = {
...newSettingsOfProvider,
[providerName]: {
@ -165,14 +165,40 @@ const _validatedModelState = (state: Omit<VoidSettingsState, '_modelOptions'>):
}
}
// dedupe models (aliases, default+custom overlap, persisted legacy ids)
for (const providerName of providerNames) {
const models = newSettingsOfProvider[providerName].models
const deduped = dedupeProviderModels(providerName, models)
if (deduped.length !== models.length || deduped.some((m, i) => m.modelName !== models[i]?.modelName || m.type !== models[i]?.type || m.isHidden !== models[i]?.isHidden)) {
newSettingsOfProvider = {
...newSettingsOfProvider,
[providerName]: {
...newSettingsOfProvider[providerName],
models: deduped,
},
}
}
}
// update model options
const visibleModelNameCounts = new Map<string, number>()
for (const providerName of providerNames) {
if (!newSettingsOfProvider[providerName]._didFillInProviderSettings) continue
for (const { modelName, isHidden } of newSettingsOfProvider[providerName].models) {
if (isHidden) continue
visibleModelNameCounts.set(modelName, (visibleModelNameCounts.get(modelName) ?? 0) + 1)
}
}
let newModelOptions: ModelOption[] = []
for (const providerName of providerNames) {
const providerTitle = providerName // displayInfoOfProviderName(providerName).title.toLowerCase() // looks better lowercase, best practice to not use raw providerName
if (!newSettingsOfProvider[providerName]._didFillInProviderSettings) continue // if disabled, don't display model options
for (const { modelName, isHidden } of newSettingsOfProvider[providerName].models) {
if (isHidden) continue
newModelOptions.push({ name: `${modelName} (${providerTitle})`, selection: { providerName, modelName } })
const showProviderInName = (visibleModelNameCounts.get(modelName) ?? 0) > 1
const name = showProviderInName ? `${modelName} (${providerTitle})` : modelName
newModelOptions.push({ name, selection: { providerName, modelName } })
}
}
@ -345,6 +371,10 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService {
readS.settingsOfProvider[providerName].models,
)
}
readS.settingsOfProvider[providerName].models = dedupeProviderModels(
providerName,
readS.settingsOfProvider[providerName].models,
)
}
}
@ -521,7 +551,7 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService {
const oldModelNames = models.map(m => m.modelName)
const normalizedNames = normalizeAutodetectedModelNamesForProvider(providerName, autodetectedModelNames)
let newModels = _modelsWithSwappedInNewModels({ existingModels: models, models: normalizedNames, type: 'autodetected' })
let newModels = _modelsWithSwappedInNewModels({ providerName, existingModels: models, models: normalizedNames, type: 'autodetected' })
if (providerName === 'mlx' || providerName === 'appleFoundationModels') {
newModels = consolidateSingleAutodetectedProviderModels(providerName, newModels)
}