diff --git a/app/src/components/settings/panels/AIPanel.tsx b/app/src/components/settings/panels/AIPanel.tsx index d0d51c90b..64db13c30 100644 --- a/app/src/components/settings/panels/AIPanel.tsx +++ b/app/src/components/settings/panels/AIPanel.tsx @@ -64,7 +64,9 @@ import SettingsBackButton from '../components/SettingsBackButton'; import { SettingsSelect, SettingsStatusLine, SettingsSwitch, SettingsTextField } from '../controls'; import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; import { ClaudeCodeConnect } from './ai/ClaudeCodeStatusCard'; +import { ModelEntryField, useModelEntryMode } from './ai/ModelEntryField'; import { routingWithProviderRemoved, toSelectableChatModels } from './aiRouting'; +import { isAzureFoundryEndpoint, isAzureV1BaseUrl } from './azureDeployment'; import { authStyleForBuiltinCloudProvider, BUILTIN_CLOUD_PROVIDER_META, @@ -303,6 +305,24 @@ function maskKeyLabel(hasKey: boolean): string { return hasKey ? '•••• configured' : 'Not configured'; } +/** + * The live `/models` verification rejected. Distinguished from every other + * submit failure (bad slug, key write failure, …) so the editor can offer to + * add the provider without verifying: a provider that does not serve an + * OpenAI-shaped `{base}/models` listing — Azure's classic `api-version` + * surface, a chat-only gateway — is still usable for inference, and blocking + * creation on the probe left those users with no way to reach the model / + * deployment-name field at all (#5213). + */ +class ProviderProbeError extends Error { + readonly probeFailed = true; + + constructor(message: string) { + super(message); + this.name = 'ProviderProbeError'; + } +} + function slugifyCustomProviderName(name: string): string { return name .trim() @@ -2052,6 +2072,14 @@ const CustomRoutingDialog = ({ const selectedCloud = source?.kind === 'cloud' ? customCloud.find(c => c.slug === source.providerSlug) : undefined; + // Azure routes inference by deployment name, so the model field is relabelled + // and defaults to free text for these connections (#5213). Shared with the + // global "Use Your Own Models" card so the two pickers cannot drift. + const modelEntry = useModelEntryMode({ + endpoint: selectedCloud?.endpoint, + model, + catalogIds: cloudModels.map(m => m.id), + }); // Fetch available models whenever the selected cloud provider changes. const selectedSlug = source?.kind === 'cloud' ? source.providerSlug : null; @@ -2235,12 +2263,17 @@ const CustomRoutingDialog = ({ if (kind === 'local') { setSource({ kind: 'local' }); setModel(localModels[0]?.id ?? ''); + modelEntry.syncToEndpoint(undefined); } else if (kind === 'cloud') { setSource({ kind: 'cloud', providerSlug: slug }); setModel(''); + // Azure connections need a deployment name, which the + // catalog never lists — start on free text (#5213). + modelEntry.syncToEndpoint(customCloud.find(c => c.slug === slug)?.endpoint); } else if (kind === 'claude-code') { setSource({ kind: 'claude-code' }); setModel(CLAUDE_CODE_DEFAULT_MODEL); + modelEntry.syncToEndpoint(undefined); } }} className="w-full"> @@ -2259,11 +2292,11 @@ const CustomRoutingDialog = ({ -
- - {source?.kind === 'local' ? ( + {source?.kind === 'local' ? ( +
+ { @@ -2277,98 +2310,47 @@ const CustomRoutingDialog = ({ ))} - ) : source?.kind === 'claude-code' ? ( -
- setModel(e.target.value)} - placeholder="sonnet" - /> -

- A model id the claude CLI accepts — an alias (sonnet,{' '} - opus) or full name (claude-sonnet-4-5). Passed - verbatim to claude --model; marketing strings like{' '} - sonnet-4-5 are rejected. -

-
- ) : cloudModelsLoading ? ( - - - - ) : cloudModelsError ? ( -
-
- {cloudModelsError} -
-
- - - {t('settings.ai.enterModelIdManually')} - -
- { - resetTestState(); - setModel(e.target.value); - }} - placeholder={ - selectedCloud - ? formatI18n(t('settings.ai.modelIdPlaceholderForProvider'), { - slug: selectedCloud.slug, - }) - : t('settings.ai.modelIdPlaceholder') - } - /> -
- ) : cloudModels.length > 0 ? ( - { - resetTestState(); - setModel(e.target.value); - }} - className="w-full"> - {!model && } - {/* Keep existing value selectable even if the provider no longer lists it */} - {model && !cloudModels.some(m => m.id === model) && ( - - )} - {cloudModels.map(m => ( - - ))} - - ) : ( +
+ ) : source?.kind === 'claude-code' ? ( +
+ { - resetTestState(); - setModel(e.target.value); - }} - placeholder={ - selectedCloud - ? formatI18n(t('settings.ai.modelIdPlaceholderForProvider'), { - slug: selectedCloud.slug, - }) - : t('settings.ai.modelIdPlaceholder') - } + onChange={e => setModel(e.target.value)} + placeholder="sonnet" /> - )} -
+

+ {t('settings.ai.claudeCode.modelHelp')} +

+
+ ) : ( + { + resetTestState(); + setModel(next); + }} + catalog={cloudModels} + catalogLoading={cloudModelsLoading} + catalogError={cloudModelsError} + onRetry={() => setModelsKey(k => k + 1)} + label={t('settings.ai.modelLabel')} + placeholder={ + selectedCloud + ? formatI18n(t('settings.ai.modelIdPlaceholderForProvider'), { + slug: selectedCloud.slug, + }) + : t('settings.ai.modelIdPlaceholder') + } + analyticsId="ai-model-entry-mode-toggle" + optionLabel={m => `${humanizeModelId(m.id)} — ${m.id}`} + /> + )} {/* Temperature override (optional). When unchecked, the workload inherits the provider/global default temperature. */} @@ -2656,6 +2638,15 @@ const GlobalOwnModelSelector = ({ const [saving, setSaving] = useState(false); const selectedSlug = source?.kind === 'cloud' ? source.providerSlug : null; + const selectedCloud = customCloud.find(c => c.slug === selectedSlug); + // Azure deployment names are never in the probed catalog, so free text is the + // only way to reach them (#5213). Same hook as CustomRoutingDialog — the two + // pickers deliberately share one implementation. + const modelEntry = useModelEntryMode({ + endpoint: selectedCloud?.endpoint, + model, + catalogIds: cloudModels.map(m => m.id), + }); useEffect(() => { if (!selectedSlug) { @@ -2678,7 +2669,10 @@ const GlobalOwnModelSelector = ({ if (!active) return; setCloudModels(ms); setCloudModelsLoading(false); - if (!model.trim() && ms[0]?.id) { + // Never auto-pick for Azure: the catalog holds base model ids, and + // silently seeding one is exactly what produced "Model not found" + // (#5213). Leave the field empty so the user supplies the deployment. + if (!model.trim() && ms[0]?.id && !isAzureFoundryEndpoint(provider.endpoint)) { setModel(ms[0].id); } }) @@ -2767,13 +2761,16 @@ const GlobalOwnModelSelector = ({ const nextModel = localModels[0]?.id ?? ''; setSource(nextSource); setModel(nextModel); + modelEntry.syncToEndpoint(undefined); } else if (kind === 'claude-code') { setSource({ kind: 'claude-code' }); setModel(CLAUDE_CODE_DEFAULT_MODEL); + modelEntry.syncToEndpoint(undefined); } else { const nextSource = { kind: 'cloud', providerSlug: slug } as const; setSource(nextSource); setModel(''); + modelEntry.syncToEndpoint(customCloud.find(c => c.slug === slug)?.endpoint); } }} className="w-full"> @@ -2791,11 +2788,11 @@ const GlobalOwnModelSelector = ({ -
- - {source?.kind === 'local' ? ( + {source?.kind === 'local' ? ( +
+ setModel(e.target.value)} @@ -2806,32 +2803,20 @@ const GlobalOwnModelSelector = ({ ))} - ) : cloudModels.length > 0 ? ( - setModel(e.target.value)} - className="w-full"> - {cloudModels.map(m => ( - - ))} - - ) : ( - setModel(e.target.value)} - placeholder={ - cloudModelsLoading - ? t('settings.ai.globalModel.loadingModels') - : t('settings.ai.globalModel.enterModelId') - } - /> - )} - {cloudModelsError ? ( -
{cloudModelsError}
- ) : null} -
+
+ ) : ( + + )} {registrySlug && model.trim().length > 0 && (