mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-29 22:23:01 +00:00
This commit is contained in:
@@ -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 = ({
|
||||
</SettingsSelect>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-content-secondary">
|
||||
{t('settings.ai.modelLabel')}
|
||||
</label>
|
||||
{source?.kind === 'local' ? (
|
||||
{source?.kind === 'local' ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-content-secondary">
|
||||
{t('settings.ai.modelLabel')}
|
||||
</label>
|
||||
<SettingsSelect
|
||||
value={model}
|
||||
onChange={e => {
|
||||
@@ -2277,98 +2310,47 @@ const CustomRoutingDialog = ({
|
||||
</option>
|
||||
))}
|
||||
</SettingsSelect>
|
||||
) : source?.kind === 'claude-code' ? (
|
||||
<div className="space-y-1.5">
|
||||
<SettingsTextField
|
||||
type="text"
|
||||
mono
|
||||
value={model}
|
||||
onChange={e => setModel(e.target.value)}
|
||||
placeholder="sonnet"
|
||||
/>
|
||||
<p className="text-[11px] text-content-muted">
|
||||
A model id the <code>claude</code> CLI accepts — an alias (<code>sonnet</code>,{' '}
|
||||
<code>opus</code>) or full name (<code>claude-sonnet-4-5</code>). Passed
|
||||
verbatim to <code>claude --model</code>; marketing strings like{' '}
|
||||
<code>sonnet-4-5</code> are rejected.
|
||||
</p>
|
||||
</div>
|
||||
) : cloudModelsLoading ? (
|
||||
<SettingsSelect disabled className="w-full opacity-60 cursor-wait">
|
||||
<option>{t('settings.ai.loadingModels')}</option>
|
||||
</SettingsSelect>
|
||||
) : cloudModelsError ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="rounded-lg border border-red-200 dark:border-red-500/30 bg-red-50 dark:bg-red-500/10 px-3 py-2 text-xs text-red-700 dark:text-red-300 font-mono break-all">
|
||||
{cloudModelsError}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="tertiary"
|
||||
size="xs"
|
||||
onClick={() => setModelsKey(k => k + 1)}>
|
||||
{t('common.retry')}
|
||||
</Button>
|
||||
<span className="text-xs text-content-faint">
|
||||
{t('settings.ai.enterModelIdManually')}
|
||||
</span>
|
||||
</div>
|
||||
<SettingsTextField
|
||||
type="text"
|
||||
mono
|
||||
value={model}
|
||||
onChange={e => {
|
||||
resetTestState();
|
||||
setModel(e.target.value);
|
||||
}}
|
||||
placeholder={
|
||||
selectedCloud
|
||||
? formatI18n(t('settings.ai.modelIdPlaceholderForProvider'), {
|
||||
slug: selectedCloud.slug,
|
||||
})
|
||||
: t('settings.ai.modelIdPlaceholder')
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : cloudModels.length > 0 ? (
|
||||
<SettingsSelect
|
||||
value={model}
|
||||
onChange={e => {
|
||||
resetTestState();
|
||||
setModel(e.target.value);
|
||||
}}
|
||||
className="w-full">
|
||||
{!model && <option value="">{t('settings.ai.selectModel')}</option>}
|
||||
{/* Keep existing value selectable even if the provider no longer lists it */}
|
||||
{model && !cloudModels.some(m => m.id === model) && (
|
||||
<option value={model}>{model}</option>
|
||||
)}
|
||||
{cloudModels.map(m => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{humanizeModelId(m.id)} — {m.id}
|
||||
</option>
|
||||
))}
|
||||
</SettingsSelect>
|
||||
) : (
|
||||
</div>
|
||||
) : source?.kind === 'claude-code' ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-content-secondary">
|
||||
{t('settings.ai.modelLabel')}
|
||||
</label>
|
||||
<SettingsTextField
|
||||
type="text"
|
||||
mono
|
||||
value={model}
|
||||
onChange={e => {
|
||||
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"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-content-muted">
|
||||
{t('settings.ai.claudeCode.modelHelp')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ModelEntryField
|
||||
mode={modelEntry}
|
||||
model={model}
|
||||
onModelChange={next => {
|
||||
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 = ({
|
||||
</SettingsSelect>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-content-secondary">
|
||||
{t('settings.ai.globalModel.model')}
|
||||
</label>
|
||||
{source?.kind === 'local' ? (
|
||||
{source?.kind === 'local' ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-content-secondary">
|
||||
{t('settings.ai.globalModel.model')}
|
||||
</label>
|
||||
<SettingsSelect
|
||||
value={model}
|
||||
onChange={e => setModel(e.target.value)}
|
||||
@@ -2806,32 +2803,20 @@ const GlobalOwnModelSelector = ({
|
||||
</option>
|
||||
))}
|
||||
</SettingsSelect>
|
||||
) : cloudModels.length > 0 ? (
|
||||
<SettingsSelect
|
||||
value={model}
|
||||
onChange={e => setModel(e.target.value)}
|
||||
className="w-full">
|
||||
{cloudModels.map(m => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.id}
|
||||
</option>
|
||||
))}
|
||||
</SettingsSelect>
|
||||
) : (
|
||||
<SettingsTextField
|
||||
value={model}
|
||||
onChange={e => setModel(e.target.value)}
|
||||
placeholder={
|
||||
cloudModelsLoading
|
||||
? t('settings.ai.globalModel.loadingModels')
|
||||
: t('settings.ai.globalModel.enterModelId')
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{cloudModelsError ? (
|
||||
<div className="text-xs text-coral-700 dark:text-coral-300">{cloudModelsError}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ModelEntryField
|
||||
mode={modelEntry}
|
||||
model={model}
|
||||
onModelChange={setModel}
|
||||
catalog={cloudModels}
|
||||
catalogLoading={cloudModelsLoading}
|
||||
catalogError={cloudModelsError}
|
||||
label={t('settings.ai.globalModel.model')}
|
||||
placeholder={t('settings.ai.globalModel.enterModelId')}
|
||||
analyticsId="ai-global-model-entry-mode-toggle"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{registrySlug && model.trim().length > 0 && (
|
||||
<label className="flex items-start gap-2 text-xs font-medium text-content-secondary">
|
||||
@@ -3568,7 +3553,7 @@ const AIPanel = ({ embedded = false }: AIPanelProps = {}) => {
|
||||
.filter(p => p.id !== (editing === 'new' ? '' : editing.id))
|
||||
.map(p => p.slug)}
|
||||
onClose={() => setEditing(null)}
|
||||
onSubmit={async (next, apiKey) => {
|
||||
onSubmit={async (next, apiKey, opts) => {
|
||||
setBusyAction('save-provider');
|
||||
try {
|
||||
const id =
|
||||
@@ -3616,15 +3601,27 @@ const AIPanel = ({ embedded = false }: AIPanelProps = {}) => {
|
||||
auth_style: p.authStyle,
|
||||
}));
|
||||
await flushCloudProviders(nextWireProviders);
|
||||
try {
|
||||
await listProviderModels(upserted.slug);
|
||||
} catch (probeErr) {
|
||||
await flushCloudProviders(priorWireProviders).catch(() => {});
|
||||
if (apiKey) {
|
||||
await clearCloudProviderKey(upserted.slug).catch(() => {});
|
||||
// `skipProbe` is the user's explicit "add it anyway" after a
|
||||
// failed verification. A provider whose `/models` listing is
|
||||
// absent or auth-shaped differently (Azure's classic
|
||||
// `api-version` surface is both) is still perfectly usable for
|
||||
// inference — gating creation on the probe made the deployment
|
||||
// name field unreachable for exactly those users (#5213).
|
||||
if (!opts?.skipProbe) {
|
||||
try {
|
||||
await listProviderModels(upserted.slug);
|
||||
} catch (probeErr) {
|
||||
await flushCloudProviders(priorWireProviders).catch(() => {});
|
||||
if (apiKey) {
|
||||
await clearCloudProviderKey(upserted.slug).catch(() => {});
|
||||
}
|
||||
const msg = probeErr instanceof Error ? probeErr.message : String(probeErr);
|
||||
console.warn('[ai-settings] provider /models probe failed', {
|
||||
slug: upserted.slug,
|
||||
summary: presentProviderSetupError(msg, t).summary,
|
||||
});
|
||||
throw new ProviderProbeError(`Could not reach ${upserted.label}: ${msg}`);
|
||||
}
|
||||
const msg = probeErr instanceof Error ? probeErr.message : String(probeErr);
|
||||
throw new Error(`Could not reach ${upserted.label}: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3766,7 +3763,11 @@ const CloudProviderEditor = ({
|
||||
initial: CloudProvider | null;
|
||||
existingSlugs: string[];
|
||||
onClose: () => void;
|
||||
onSubmit: (next: CloudProvider, apiKey: string) => Promise<void> | void;
|
||||
onSubmit: (
|
||||
next: CloudProvider,
|
||||
apiKey: string,
|
||||
opts?: { skipProbe?: boolean }
|
||||
) => Promise<void> | void;
|
||||
onClearKey: (slug: string) => Promise<void> | void;
|
||||
}) => {
|
||||
const { t } = useT();
|
||||
@@ -3775,6 +3776,10 @@ const CloudProviderEditor = ({
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
// Set once the live `/models` verification has rejected, which unlocks the
|
||||
// "add without verifying" path. Only a probe failure earns it — a bad slug or
|
||||
// a failed key write must still block (#5213).
|
||||
const [probeFailed, setProbeFailed] = useState(false);
|
||||
const slug = initial?.slug ?? slugifyCustomProviderName(label);
|
||||
const hasReservedSlugCollision = !initial && BUILTIN_RESERVED_SLUGS.includes(slug);
|
||||
const slugError = !slug
|
||||
@@ -3785,6 +3790,57 @@ const CloudProviderEditor = ({
|
||||
? t('settings.ai.slugReservedError')
|
||||
: null;
|
||||
const hasExistingKey = (initial?.maskedKey ?? '').startsWith('••••');
|
||||
// Skipping verification is a bet that the provider works despite an
|
||||
// unreadable listing. For an Azure host that is not the `/openai/v1` base
|
||||
// that bet is already lost: `{base}/chat/completions` is not a route Azure
|
||||
// serves there and the stored bearer auth is the wrong header, so the entry
|
||||
// would be dead on arrival. Withhold the bypass and let the inline nudge do
|
||||
// its job instead of manufacturing a broken provider (#5213).
|
||||
const knownUnusableEndpoint =
|
||||
isAzureFoundryEndpoint(endpoint) && !isAzureV1BaseUrl(endpoint.trim());
|
||||
|
||||
const submitProvider = async (opts?: { skipProbe?: boolean }) => {
|
||||
setSaving(true);
|
||||
setSubmitError(null);
|
||||
// Cleared alongside the error: a later attempt that fails for an unrelated
|
||||
// reason (slug collision, key write) must not still offer to skip
|
||||
// verification, which is the distinction `ProviderProbeError` exists for.
|
||||
setProbeFailed(false);
|
||||
try {
|
||||
if (slugError) {
|
||||
throw new Error(slugError);
|
||||
}
|
||||
await onSubmit(
|
||||
{
|
||||
id: initial?.id ?? '',
|
||||
slug,
|
||||
label: label.trim() || slug,
|
||||
endpoint: endpoint.trim(),
|
||||
authStyle: initial?.authStyle ?? 'bearer',
|
||||
maskedKey: maskKeyLabel(hasExistingKey || apiKey.length > 0),
|
||||
},
|
||||
apiKey.trim(),
|
||||
opts
|
||||
);
|
||||
} catch (err) {
|
||||
// Surface the failure inline and keep the dialog open so the user can fix
|
||||
// the key/URL and retry. A rejected `/models` probe additionally unlocks
|
||||
// the "add without verifying" button — the listing is a convenience for
|
||||
// the model dropdown, not a precondition for inference.
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.warn('[ai-settings] cloud provider editor submit failed', {
|
||||
slug,
|
||||
probeFailure: err instanceof ProviderProbeError,
|
||||
summary: presentProviderSetupError(message, t).summary,
|
||||
});
|
||||
setSubmitError(message);
|
||||
if (err instanceof ProviderProbeError) {
|
||||
setProbeFailed(true);
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-neutral-900/30 p-4">
|
||||
@@ -3838,6 +3894,24 @@ const CloudProviderEditor = ({
|
||||
className="mt-1"
|
||||
placeholder={t('settings.ai.openAiUrlPlaceholder')}
|
||||
/>
|
||||
{/* Azure routes by deployment name, which is set on the model
|
||||
field rather than here — point the user at it (#5213). */}
|
||||
{isAzureFoundryEndpoint(endpoint) && (
|
||||
<div className="mt-1 text-[11px] text-content-muted">
|
||||
{t('settings.ai.deploymentNameProviderHint')}
|
||||
</div>
|
||||
)}
|
||||
{/* Only Azure's `/openai/v1` base is OpenAI-shaped: it serves a
|
||||
`/models` listing and accepts the resource key as a bearer
|
||||
token, which is the auth style every custom provider is stored
|
||||
with. The older `api-version` surface wants an `api-key` header
|
||||
and no `/models`, so a user who pastes the portal's bare
|
||||
resource URL fails both the probe and inference (#5213). */}
|
||||
{knownUnusableEndpoint && (
|
||||
<div className="mt-1 text-[11px] text-amber-700 dark:text-amber-300">
|
||||
{t('settings.ai.azureV1EndpointHint')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="flex items-center justify-between text-[10px] font-semibold uppercase tracking-wide text-content-muted">
|
||||
@@ -3871,47 +3945,34 @@ const CloudProviderEditor = ({
|
||||
/>
|
||||
</div>
|
||||
{submitError ? <ProviderSetupErrorNotice error={submitError} /> : null}
|
||||
{/* A failed verification is not a failed provider. Explain what the
|
||||
probe does and does not prove, then let the user proceed (#5213).
|
||||
Withheld for an endpoint we already know cannot serve inference. */}
|
||||
{probeFailed && !knownUnusableEndpoint ? (
|
||||
<p className="rounded-lg border border-amber-200 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/10 px-3 py-2 text-[11px] text-amber-800 dark:text-amber-200">
|
||||
{t('settings.ai.probeFailedHint')}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 border-t border-line px-4 py-3">
|
||||
<Button variant="secondary" size="xs" onClick={onClose} disabled={saving}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
{probeFailed && !knownUnusableEndpoint ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="xs"
|
||||
analyticsId="ai-provider-add-without-verifying"
|
||||
disabled={saving || !endpoint.trim() || Boolean(slugError)}
|
||||
onClick={() => void submitProvider({ skipProbe: true })}>
|
||||
{t('settings.ai.probeFailedAddAnyway')}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="primary"
|
||||
size="xs"
|
||||
disabled={saving || !endpoint.trim() || Boolean(slugError)}
|
||||
onClick={async () => {
|
||||
setSaving(true);
|
||||
setSubmitError(null);
|
||||
try {
|
||||
if (slugError) {
|
||||
throw new Error(slugError);
|
||||
}
|
||||
await onSubmit(
|
||||
{
|
||||
id: initial?.id ?? '',
|
||||
slug,
|
||||
label: label.trim() || slug,
|
||||
endpoint: endpoint.trim(),
|
||||
authStyle: initial?.authStyle ?? 'bearer',
|
||||
maskedKey: maskKeyLabel(hasExistingKey || apiKey.length > 0),
|
||||
},
|
||||
apiKey.trim()
|
||||
);
|
||||
} catch (err) {
|
||||
// Caller throws when the live /models probe rejects — surface
|
||||
// the failure inline and keep the dialog open so the user can
|
||||
// fix the key/URL and retry.
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.warn('[ai-settings] cloud provider editor submit failed', {
|
||||
slug,
|
||||
summary: presentProviderSetupError(message, t).summary,
|
||||
});
|
||||
setSubmitError(message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}}>
|
||||
onClick={() => void submitProvider()}>
|
||||
{saving
|
||||
? t('settings.ai.saving')
|
||||
: initial
|
||||
|
||||
@@ -351,6 +351,492 @@ describe('AIPanel', () => {
|
||||
|
||||
// ─── per-model vision flag (BYOK) ───────────────────────────────────────────
|
||||
|
||||
// ─── Azure deployment names (#5213) ─────────────────────────────────────────
|
||||
|
||||
// Regression: Azure's `/models` catalog lists *base model ids*, but Azure
|
||||
// routes inference by the user's *deployment name*. Before the fix a
|
||||
// non-empty catalog forced a closed <select>, so a deployment name that was
|
||||
// not in the catalog could not be entered at all and every request came back
|
||||
// "Model not found".
|
||||
const azureSettings = {
|
||||
...baseSettings,
|
||||
cloudProviders: [
|
||||
...baseSettings.cloudProviders,
|
||||
{
|
||||
id: 'p_azure_x',
|
||||
slug: 'azure-foundry',
|
||||
label: 'Azure Foundry',
|
||||
endpoint: 'https://my-resource.openai.azure.com/openai/v1',
|
||||
auth_style: 'bearer' as const,
|
||||
has_api_key: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it('lets an Azure provider take a deployment name that is absent from the model catalog', async () => {
|
||||
vi.mocked(loadAISettings).mockResolvedValue(azureSettings);
|
||||
// A NON-empty catalog is the pre-fix blocker: it used to force a dropdown.
|
||||
vi.mocked(listProviderModels).mockResolvedValue([
|
||||
{ id: 'gpt-5.6-terra-2026-07-09' },
|
||||
{ id: 'gpt-4o' },
|
||||
]);
|
||||
|
||||
renderWithProviders(<AIPanel />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: /Use Your Own Models/i })).toBeInTheDocument()
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: /Use Your Own Models/i }));
|
||||
|
||||
// The field is a free-text "Deployment name" box, not a catalog dropdown.
|
||||
const deploymentInput = await screen.findByRole('textbox', { name: /Deployment name/i });
|
||||
fireEvent.change(deploymentInput, { target: { value: 'gpt-5.6-terra' } });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Save$/ }));
|
||||
|
||||
await waitFor(() => expect(saveAISettings).toHaveBeenCalled());
|
||||
// The deployment name reaches the persisted routing verbatim, and the base
|
||||
// model id from the catalog is never substituted for it.
|
||||
const [, nextSettings] = vi.mocked(saveAISettings).mock.calls.at(-1) ?? [];
|
||||
expect(nextSettings?.routing.chat).toEqual({
|
||||
kind: 'cloud',
|
||||
providerSlug: 'azure-foundry',
|
||||
model: 'gpt-5.6-terra',
|
||||
});
|
||||
expect(JSON.stringify(nextSettings)).not.toContain('gpt-5.6-terra-2026-07-09');
|
||||
});
|
||||
|
||||
it('does not auto-select a catalog model id for an Azure provider', async () => {
|
||||
vi.mocked(loadAISettings).mockResolvedValue(azureSettings);
|
||||
vi.mocked(listProviderModels).mockResolvedValue([{ id: 'gpt-5.6-terra-2026-07-09' }]);
|
||||
|
||||
renderWithProviders(<AIPanel />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: /Use Your Own Models/i })).toBeInTheDocument()
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: /Use Your Own Models/i }));
|
||||
|
||||
// Seeding the field with a base model id is what produced the bug, so the
|
||||
// deployment field must come up empty and wait for the user.
|
||||
const deploymentInput = await screen.findByRole('textbox', { name: /Deployment name/i });
|
||||
await waitFor(() => expect(deploymentInput).toHaveValue(''));
|
||||
});
|
||||
|
||||
it('keeps the model dropdown for a non-Azure provider, with a manual-entry escape hatch', async () => {
|
||||
vi.mocked(loadAISettings).mockResolvedValue({
|
||||
...baseSettings,
|
||||
cloudProviders: [
|
||||
...baseSettings.cloudProviders,
|
||||
{
|
||||
id: 'p_custom_openai',
|
||||
slug: 'openai',
|
||||
label: 'OpenAI',
|
||||
endpoint: 'https://api.openai.com/v1',
|
||||
auth_style: 'bearer' as const,
|
||||
has_api_key: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(listProviderModels).mockResolvedValue([{ id: 'gpt-4o' }]);
|
||||
|
||||
renderWithProviders(<AIPanel />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: /Use Your Own Models/i })).toBeInTheDocument()
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: /Use Your Own Models/i }));
|
||||
|
||||
// Existing behaviour is unchanged: a populated catalog still renders a
|
||||
// dropdown and there is no Azure-specific labelling.
|
||||
await waitFor(() => expect(screen.queryByText('Deployment name')).not.toBeInTheDocument());
|
||||
const toggle = await screen.findByRole('button', { name: /Enter model ID manually/i });
|
||||
|
||||
// ...but the catalog is no longer a dead end for off-catalog model ids.
|
||||
fireEvent.click(toggle);
|
||||
const manualInput = await screen.findByRole('textbox', { name: /^Model$/i });
|
||||
fireEvent.change(manualInput, { target: { value: 'my-private-model' } });
|
||||
expect(manualInput).toHaveValue('my-private-model');
|
||||
});
|
||||
|
||||
it('warns when a stored Azure value is verbatim a catalog base model id', async () => {
|
||||
// The fingerprint of a PRE-FIX Azure selection: the dropdown was the only
|
||||
// way to set the value, so catalog membership is exactly the signature of a
|
||||
// connection configured the broken way. It stays a hint, never a rewrite —
|
||||
// a user may legitimately name a deployment after its base model.
|
||||
vi.mocked(loadAISettings).mockResolvedValue({
|
||||
...azureSettings,
|
||||
routing: {
|
||||
...azureSettings.routing,
|
||||
chat: {
|
||||
kind: 'cloud' as const,
|
||||
providerSlug: 'azure-foundry',
|
||||
model: 'gpt-5.6-terra-2026-07-09',
|
||||
},
|
||||
},
|
||||
});
|
||||
vi.mocked(listProviderModels).mockResolvedValue([
|
||||
{ id: 'gpt-5.6-terra-2026-07-09' },
|
||||
{ id: 'gpt-4o' },
|
||||
]);
|
||||
|
||||
renderWithProviders(<AIPanel />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: /Use Your Own Models/i })).toBeInTheDocument()
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: /Use Your Own Models/i }));
|
||||
|
||||
expect(
|
||||
await screen.findByText(/confirm this is the name you gave your deployment/i)
|
||||
).toBeInTheDocument();
|
||||
// The always-on explainer sits alongside it for any Azure connection.
|
||||
expect(screen.getByText(/This is not the model ID/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not warn when the Azure deployment name is absent from the catalog', async () => {
|
||||
vi.mocked(loadAISettings).mockResolvedValue({
|
||||
...azureSettings,
|
||||
routing: {
|
||||
...azureSettings.routing,
|
||||
chat: { kind: 'cloud' as const, providerSlug: 'azure-foundry', model: 'my-deployment' },
|
||||
},
|
||||
});
|
||||
vi.mocked(listProviderModels).mockResolvedValue([{ id: 'gpt-5.6-terra-2026-07-09' }]);
|
||||
|
||||
renderWithProviders(<AIPanel />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: /Use Your Own Models/i })).toBeInTheDocument()
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: /Use Your Own Models/i }));
|
||||
|
||||
expect(await screen.findByText(/This is not the model ID/i)).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByText(/confirm this is the name you gave your deployment/i)
|
||||
).not.toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
|
||||
it('can toggle an Azure connection back to the catalog and out again', async () => {
|
||||
// The escape hatch has to work in BOTH directions: Azure opens on free
|
||||
// text, but a user whose deployment IS named after a catalog entry should
|
||||
// still be able to pick it, then return to typing.
|
||||
vi.mocked(loadAISettings).mockResolvedValue(azureSettings);
|
||||
vi.mocked(listProviderModels).mockResolvedValue([{ id: 'gpt-4o' }]);
|
||||
|
||||
renderWithProviders(<AIPanel />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: /Use Your Own Models/i })).toBeInTheDocument()
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: /Use Your Own Models/i }));
|
||||
|
||||
await screen.findByRole('textbox', { name: /Deployment name/i });
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Choose from list/i }));
|
||||
|
||||
// Back on the catalog dropdown, with the manual escape hatch offered again.
|
||||
// The action is labelled for what the field actually holds on Azure — a
|
||||
// deployment name, not a model ID.
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('textbox', { name: /Deployment name/i })).not.toBeInTheDocument()
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /Enter model ID manually/i })
|
||||
).not.toBeInTheDocument();
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Enter deployment name manually/i }));
|
||||
expect(await screen.findByRole('textbox', { name: /Deployment name/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('still lets a provider be added when the live /models probe fails', async () => {
|
||||
// Regression: the `{base}/models` probe used to be a hard gate on creating
|
||||
// a provider. A gateway that serves no OpenAI-shaped listing could never be
|
||||
// connected at all, which put the model / deployment-name field permanently
|
||||
// out of reach. The probe now informs, it does not block.
|
||||
vi.mocked(loadAISettings).mockResolvedValue(baseSettings);
|
||||
vi.mocked(listProviderModels).mockRejectedValue(
|
||||
new Error('provider returned 404: no /models endpoint')
|
||||
);
|
||||
|
||||
renderWithProviders(<AIPanel />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Add Custom Provider/i }));
|
||||
|
||||
fireEvent.change(await screen.findByPlaceholderText('My Provider'), {
|
||||
target: { value: 'Azure Foundry' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('https://api.openai.com/v1'), {
|
||||
target: { value: 'https://my-resource.openai.azure.com/openai/v1' },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Add Provider$/i }));
|
||||
|
||||
// The failure is explained rather than swallowed, and nothing is persisted
|
||||
// behind the user's back on the first attempt.
|
||||
expect(await screen.findByText(/could not read this provider/i)).toBeInTheDocument();
|
||||
expect(saveAISettings).not.toHaveBeenCalled();
|
||||
|
||||
// The escape hatch is what makes the connection reachable at all.
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add without verifying/i }));
|
||||
|
||||
await waitFor(() => expect(saveAISettings).toHaveBeenCalled());
|
||||
const [, nextSettings] = vi.mocked(saveAISettings).mock.calls.at(-1) ?? [];
|
||||
expect(nextSettings?.cloudProviders.map(p => p.slug)).toContain('azure-foundry');
|
||||
});
|
||||
|
||||
it('withholds the verification bypass for a legacy Azure base URL', async () => {
|
||||
// Skipping verification is a bet that the provider works anyway. On an
|
||||
// Azure host that is not the `/openai/v1` base that bet is already lost:
|
||||
// `{base}/chat/completions` is not a route Azure serves there and the
|
||||
// stored bearer auth is the wrong header. Offering the bypass would just
|
||||
// manufacture a dead provider, so the nudge has to be followed instead.
|
||||
vi.mocked(loadAISettings).mockResolvedValue(baseSettings);
|
||||
vi.mocked(listProviderModels).mockRejectedValue(
|
||||
new Error('provider returned 404: no /models endpoint')
|
||||
);
|
||||
|
||||
renderWithProviders(<AIPanel />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Add Custom Provider/i }));
|
||||
|
||||
fireEvent.change(await screen.findByPlaceholderText('My Provider'), {
|
||||
target: { value: 'Azure Legacy' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('https://api.openai.com/v1'), {
|
||||
target: { value: 'https://my-resource.openai.azure.com/openai' },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Add Provider$/i }));
|
||||
|
||||
expect(await screen.findByText(/use the v1 base URL/i)).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /Add without verifying/i })
|
||||
).not.toBeInTheDocument()
|
||||
);
|
||||
expect(saveAISettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('nudges an Azure endpoint that is not the v1 base towards /openai/v1', async () => {
|
||||
// Only `/openai/v1` serves a `/models` listing and accepts the resource key
|
||||
// as a bearer token. A user who pastes the portal's bare resource URL would
|
||||
// otherwise fail the probe and then fail every inference call.
|
||||
vi.mocked(loadAISettings).mockResolvedValue(baseSettings);
|
||||
|
||||
renderWithProviders(<AIPanel />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Add Custom Provider/i }));
|
||||
|
||||
const urlField = screen.getByPlaceholderText('https://api.openai.com/v1');
|
||||
fireEvent.change(urlField, {
|
||||
target: { value: 'https://my-resource.openai.azure.com/openai' },
|
||||
});
|
||||
expect(await screen.findByText(/use the v1 base URL/i)).toBeInTheDocument();
|
||||
|
||||
// Correcting the base URL clears the warning.
|
||||
fireEvent.change(urlField, {
|
||||
target: { value: 'https://my-resource.openai.azure.com/openai/v1' },
|
||||
});
|
||||
await waitFor(() => expect(screen.queryByText(/use the v1 base URL/i)).not.toBeInTheDocument());
|
||||
// The deployment-name pointer stays for any Azure endpoint.
|
||||
expect(screen.getByText(/Set your deployment name in the model field/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('leaves a non-Azure endpoint free of Azure guidance', async () => {
|
||||
vi.mocked(loadAISettings).mockResolvedValue(baseSettings);
|
||||
|
||||
renderWithProviders(<AIPanel />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Add Custom Provider/i }));
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('https://api.openai.com/v1'), {
|
||||
target: { value: 'https://litellm.mycorp.dev/v1' },
|
||||
});
|
||||
expect(screen.queryByText(/use the v1 base URL/i)).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/Set your deployment name in the model field/i)
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('blocks a non-probe submit failure instead of offering to skip verification', async () => {
|
||||
// The escape hatch is scoped to a rejected `/models` probe. A slug that
|
||||
// collides with an existing provider is a different class of failure and
|
||||
// must still block, or the dialog would offer to create a broken entry.
|
||||
vi.mocked(loadAISettings).mockResolvedValue(azureSettings);
|
||||
vi.mocked(listProviderModels).mockResolvedValue([{ id: 'gpt-4o' }]);
|
||||
|
||||
renderWithProviders(<AIPanel />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Add Custom Provider/i }));
|
||||
|
||||
fireEvent.change(await screen.findByPlaceholderText('My Provider'), {
|
||||
target: { value: 'Azure Foundry' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('https://api.openai.com/v1'), {
|
||||
target: { value: 'https://my-resource.openai.azure.com/openai/v1' },
|
||||
});
|
||||
|
||||
// `azure-foundry` is already taken by the fixture, so the slug check trips.
|
||||
expect(screen.getByRole('button', { name: /^Add Provider$/i })).toBeDisabled();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /Add without verifying/i })
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not offer to skip verification when the key write is what failed', async () => {
|
||||
// The slug case above never reaches `submitProvider`'s catch. This one
|
||||
// does: the credential write rejects, so the failure travels the same path
|
||||
// as a probe rejection but must not be mistaken for one — only a typed
|
||||
// `ProviderProbeError` unlocks the bypass.
|
||||
vi.mocked(loadAISettings).mockResolvedValue(baseSettings);
|
||||
vi.mocked(listProviderModels).mockResolvedValue([{ id: 'gpt-4o' }]);
|
||||
vi.mocked(setCloudProviderKey).mockRejectedValueOnce(new Error('keyring is locked'));
|
||||
|
||||
renderWithProviders(<AIPanel />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Add Custom Provider/i }));
|
||||
|
||||
fireEvent.change(await screen.findByPlaceholderText('My Provider'), {
|
||||
target: { value: 'Azure Foundry' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('https://api.openai.com/v1'), {
|
||||
target: { value: 'https://my-resource.openai.azure.com/openai/v1' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/API Key/i), { target: { value: 'sk-test-key' } });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Add Provider$/i }));
|
||||
|
||||
expect(await screen.findByText(/keyring is locked/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /Add without verifying/i })
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/could not read this provider/i)).not.toBeInTheDocument();
|
||||
expect(saveAISettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears the verification bypass once a later attempt fails for another reason', async () => {
|
||||
// `probeFailed` used to persist for the dialog's lifetime, so a probe
|
||||
// rejection left "Add without verifying" on screen even after the next
|
||||
// attempt failed for an unrelated reason.
|
||||
vi.mocked(loadAISettings).mockResolvedValue(baseSettings);
|
||||
vi.mocked(listProviderModels).mockRejectedValueOnce(new Error('provider returned 404'));
|
||||
|
||||
renderWithProviders(<AIPanel />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Add Custom Provider/i }));
|
||||
|
||||
fireEvent.change(await screen.findByPlaceholderText('My Provider'), {
|
||||
target: { value: 'Azure Foundry' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('https://api.openai.com/v1'), {
|
||||
target: { value: 'https://my-resource.openai.azure.com/openai/v1' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Add Provider$/i }));
|
||||
expect(
|
||||
await screen.findByRole('button', { name: /Add without verifying/i })
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Second attempt: the probe would now succeed, but the key write rejects.
|
||||
vi.mocked(setCloudProviderKey).mockRejectedValueOnce(new Error('keyring is locked'));
|
||||
fireEvent.change(screen.getByLabelText(/API Key/i), { target: { value: 'sk-test-key' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Add Provider$/i }));
|
||||
|
||||
expect(await screen.findByText(/keyring is locked/i)).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /Add without verifying/i })
|
||||
).not.toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
|
||||
it('offers a deployment-name field in the per-workload custom routing dialog', async () => {
|
||||
// The workload override dialog is a second, independent model picker. It
|
||||
// needs the same Azure treatment, or per-workload routing stays stuck on
|
||||
// catalog base model ids even after the main selector is fixed.
|
||||
vi.mocked(loadAISettings).mockResolvedValue(azureSettings);
|
||||
vi.mocked(listProviderModels).mockResolvedValue([{ id: 'gpt-5.6-terra-2026-07-09' }]);
|
||||
|
||||
renderWithProviders(<AIPanel />);
|
||||
// Per-workload rows live behind the advanced routing mode.
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Advanced/i }));
|
||||
const chooseButtons = await screen.findAllByRole('button', {
|
||||
name: /Choose Model|Change Model/i,
|
||||
});
|
||||
fireEvent.click(chooseButtons[0]);
|
||||
|
||||
// Selecting the Azure provider flips the dialog to free text and relabels.
|
||||
const providerSelect = await screen.findByDisplayValue(/Azure Foundry|OpenAI|Ollama/i);
|
||||
fireEvent.change(providerSelect, { target: { value: 'cloud:azure-foundry' } });
|
||||
|
||||
const deploymentInput = await screen.findByRole('textbox', { name: /Deployment name/i });
|
||||
fireEvent.change(deploymentInput, { target: { value: 'workload-deployment' } });
|
||||
expect(deploymentInput).toHaveValue('workload-deployment');
|
||||
expect(screen.getByText(/This is not the model ID/i)).toBeInTheDocument();
|
||||
|
||||
// A catalog base model id typed here is the pre-fix fingerprint, so the
|
||||
// dialog raises the same confirmation hint the main selector does.
|
||||
fireEvent.change(deploymentInput, { target: { value: 'gpt-5.6-terra-2026-07-09' } });
|
||||
expect(
|
||||
await screen.findByText(/confirm this is the name you gave your deployment/i)
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Complete the flow: a working text field proves nothing if the
|
||||
// dialog-to-routing handoff drops the value. Put the deployment name back
|
||||
// and assert it reaches the persisted per-workload routing verbatim.
|
||||
fireEvent.change(deploymentInput, { target: { value: 'workload-deployment' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Apply$|^Save$|^Confirm$/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Save$/ }));
|
||||
|
||||
await waitFor(() => expect(saveAISettings).toHaveBeenCalled());
|
||||
const [, nextSettings] = vi.mocked(saveAISettings).mock.calls.at(-1) ?? [];
|
||||
const workloadRefs = Object.values(nextSettings?.routing ?? {});
|
||||
expect(workloadRefs).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'cloud',
|
||||
providerSlug: 'azure-foundry',
|
||||
model: 'workload-deployment',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the catalog dropdown and its manual escape hatch for a non-Azure provider in the dialog', async () => {
|
||||
// The dialog's non-Azure path must be untouched by #5213: a populated
|
||||
// catalog still renders a dropdown, and the escape hatch still reaches an
|
||||
// off-catalog model id — labelled "Model", not "Deployment name".
|
||||
vi.mocked(loadAISettings).mockResolvedValue({
|
||||
...azureSettings,
|
||||
cloudProviders: [
|
||||
...azureSettings.cloudProviders,
|
||||
{
|
||||
id: 'p_custom_openai',
|
||||
slug: 'openai',
|
||||
label: 'OpenAI',
|
||||
endpoint: 'https://api.openai.com/v1',
|
||||
auth_style: 'bearer' as const,
|
||||
has_api_key: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(listProviderModels).mockResolvedValue([{ id: 'gpt-4o' }]);
|
||||
|
||||
renderWithProviders(<AIPanel />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Advanced/i }));
|
||||
const chooseButtons = await screen.findAllByRole('button', {
|
||||
name: /Choose Model|Change Model/i,
|
||||
});
|
||||
fireEvent.click(chooseButtons[0]);
|
||||
|
||||
const providerSelect = await screen.findByDisplayValue(
|
||||
/Azure Foundry|OpenAI|OpenHuman|Ollama/i
|
||||
);
|
||||
fireEvent.change(providerSelect, { target: { value: 'cloud:openai' } });
|
||||
|
||||
// Catalog dropdown, no Azure labelling.
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('textbox', { name: /Deployment name/i })).not.toBeInTheDocument()
|
||||
);
|
||||
expect(screen.queryByText(/This is not the model ID/i)).not.toBeInTheDocument();
|
||||
|
||||
// The escape hatch still works for an off-catalog id.
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Enter model ID manually/i }));
|
||||
const manualInput = await screen.findByRole('textbox', { name: /^Model$/i });
|
||||
fireEvent.change(manualInput, { target: { value: 'my-private-model' } });
|
||||
expect(manualInput).toHaveValue('my-private-model');
|
||||
// ...and back to the catalog.
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Choose from list/i }));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('textbox', { name: /^Model$/i })).not.toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
|
||||
it('flags a custom BYOK model as vision-capable via the Own-model selector', async () => {
|
||||
vi.mocked(loadAISettings).mockResolvedValue({
|
||||
...baseSettings,
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { renderWithProviders } from '../../../../test/test-utils';
|
||||
import { ModelEntryField, useModelEntryMode } from '../ai/ModelEntryField';
|
||||
|
||||
/**
|
||||
* Direct coverage for the model / deployment-name field shared by both AI-panel
|
||||
* pickers (#5213). The panel-level regressions in `AIPanel.test.tsx` prove the
|
||||
* Azure wiring end to end; these exercise the branches that are awkward to
|
||||
* reach from there — a still-loading catalog and a rejected `/models` probe.
|
||||
*/
|
||||
|
||||
/** Thin harness so the hook can drive the component the way the panel does. */
|
||||
const Harness = ({
|
||||
endpoint,
|
||||
model = '',
|
||||
catalog = [],
|
||||
catalogLoading = false,
|
||||
catalogError = null,
|
||||
onRetry,
|
||||
}: {
|
||||
endpoint?: string;
|
||||
model?: string;
|
||||
catalog?: { id: string }[];
|
||||
catalogLoading?: boolean;
|
||||
catalogError?: string | null;
|
||||
onRetry?: () => void;
|
||||
}) => {
|
||||
const mode = useModelEntryMode({ endpoint, model, catalogIds: catalog.map(m => m.id) });
|
||||
return (
|
||||
<ModelEntryField
|
||||
mode={mode}
|
||||
model={model}
|
||||
onModelChange={() => {}}
|
||||
catalog={catalog}
|
||||
catalogLoading={catalogLoading}
|
||||
catalogError={catalogError}
|
||||
onRetry={onRetry}
|
||||
label="Model"
|
||||
placeholder="model-id"
|
||||
analyticsId="test-toggle"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
describe('ModelEntryField', () => {
|
||||
it('relabels the field for an Azure endpoint and opens on free text', () => {
|
||||
renderWithProviders(
|
||||
<Harness
|
||||
endpoint="https://my-resource.openai.azure.com/openai/v1"
|
||||
catalog={[{ id: 'gpt-4o' }]}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('textbox', { name: /Deployment name/i })).toBeInTheDocument();
|
||||
expect(screen.getByText(/This is not the model ID/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps a non-Azure provider on the catalog dropdown', () => {
|
||||
renderWithProviders(
|
||||
<Harness endpoint="https://api.openai.com/v1" catalog={[{ id: 'gpt-4o' }]} />
|
||||
);
|
||||
|
||||
expect(screen.getByRole('combobox', { name: /Model/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/This is not the model ID/i)).not.toBeInTheDocument();
|
||||
// The escape hatch is offered to every provider, not just Azure.
|
||||
expect(screen.getByRole('button', { name: /Enter model ID manually/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not stall an Azure field behind a still-loading catalog', () => {
|
||||
// The listing only fills the dropdown, and its values are the wrong ones for
|
||||
// Azure anyway, so waiting on it would be pure delay.
|
||||
renderWithProviders(
|
||||
<Harness endpoint="https://my-resource.openai.azure.com/openai/v1" catalogLoading />
|
||||
);
|
||||
|
||||
expect(screen.getByRole('textbox', { name: /Deployment name/i })).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Loading models/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the loading placeholder while a catalog-mode provider probes', () => {
|
||||
// The realistic shape: the panel clears the catalog before fetching, so a
|
||||
// provider in dropdown mode is loading with an *empty* catalog. Gating the
|
||||
// placeholder on the effective mode would lose it in exactly this window.
|
||||
renderWithProviders(<Harness endpoint="https://api.openai.com/v1" catalogLoading />);
|
||||
|
||||
expect(screen.getByText(/Loading models/i)).toBeInTheDocument();
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits the model-id retry copy for an Azure provider', () => {
|
||||
// The retry hint names a model id, which is the wrong thing to ask an
|
||||
// Azure user for; they get the deployment-name help under the field.
|
||||
renderWithProviders(
|
||||
<Harness
|
||||
endpoint="https://my-resource.openai.azure.com/openai/v1"
|
||||
catalogError="provider returned 404"
|
||||
onRetry={() => {}}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText(/enter model id manually:/i)).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/This is not the model ID/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('textbox', { name: /Deployment name/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('surfaces a probe error with a retry and still accepts a typed value', () => {
|
||||
const onRetry = vi.fn();
|
||||
renderWithProviders(
|
||||
<Harness
|
||||
endpoint="https://api.openai.com/v1"
|
||||
catalogError="provider returned 404: no /models endpoint"
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/provider returned 404/i)).toBeInTheDocument();
|
||||
// A failed listing must never lock the user out of naming a model.
|
||||
expect(screen.getByRole('textbox', { name: /Model/i })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Try again/i }));
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('flags a stored Azure value that is verbatim a catalog model id', () => {
|
||||
renderWithProviders(
|
||||
<Harness
|
||||
endpoint="https://my-resource.openai.azure.com/openai/v1"
|
||||
model="gpt-5.6-terra-2026-07-09"
|
||||
catalog={[{ id: 'gpt-5.6-terra-2026-07-09' }]}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(/confirm this is the name you gave your deployment/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('leaves an off-catalog Azure value unflagged', () => {
|
||||
renderWithProviders(
|
||||
<Harness
|
||||
endpoint="https://my-resource.openai.azure.com/openai/v1"
|
||||
model="gpt-5.6-terra"
|
||||
catalog={[{ id: 'gpt-5.6-terra-2026-07-09' }]}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByText(/confirm this is the name you gave your deployment/i)
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
endpointHost,
|
||||
isAzureFoundryEndpoint,
|
||||
isAzureV1BaseUrl,
|
||||
looksLikeAzureBaseModelId,
|
||||
} from '../azureDeployment';
|
||||
|
||||
describe('endpointHost', () => {
|
||||
it('strips scheme, path, port and userinfo', () => {
|
||||
expect(endpointHost('https://my-res.openai.azure.com/openai/v1')).toBe(
|
||||
'my-res.openai.azure.com'
|
||||
);
|
||||
expect(endpointHost('MY-RES.OpenAI.Azure.com/openai/v1')).toBe('my-res.openai.azure.com');
|
||||
expect(endpointHost('https://user:pass@my-res.openai.azure.com:443/openai/v1')).toBe(
|
||||
'my-res.openai.azure.com'
|
||||
);
|
||||
expect(endpointHost('http://[::1]:8080/v1')).toBe('::1');
|
||||
});
|
||||
|
||||
it('returns an empty string when there is no host', () => {
|
||||
expect(endpointHost('')).toBe('');
|
||||
expect(endpointHost(' ')).toBe('');
|
||||
expect(endpointHost(null)).toBe('');
|
||||
expect(endpointHost(undefined)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAzureFoundryEndpoint', () => {
|
||||
it('recognises per-resource Azure endpoints', () => {
|
||||
for (const endpoint of [
|
||||
'https://my-res.openai.azure.com/openai/v1',
|
||||
'https://contoso.services.ai.azure.com/models',
|
||||
'https://my-res.cognitiveservices.azure.com/openai/v1',
|
||||
// Case and trailing path must not matter.
|
||||
'HTTPS://My-Res.OPENAI.AZURE.COM/openai/v1/',
|
||||
// Sovereign clouds: Azure Government and Azure operated by 21Vianet.
|
||||
// Separate DNS parents, so the commercial `.com` entries do not cover
|
||||
// them and a tenant there would otherwise hit the very "model not
|
||||
// found" path this module prevents.
|
||||
'https://my-res.openai.azure.us/openai/v1',
|
||||
'https://my-res.openai.azure.cn/openai/v1',
|
||||
]) {
|
||||
expect(isAzureFoundryEndpoint(endpoint)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not match non-Azure providers', () => {
|
||||
for (const endpoint of [
|
||||
'https://api.openai.com/v1',
|
||||
'https://api.groq.com/openai/v1',
|
||||
'http://localhost:11434/v1',
|
||||
'https://litellm.mycorp.dev/v1',
|
||||
// Foundry *serverless* endpoints. They speak the Azure AI Model
|
||||
// Inference API and key `model` on the model name, not a deployment
|
||||
// name, so relabelling their field would mislead rather than help.
|
||||
'https://team.inference.ai.azure.com/v1',
|
||||
'https://team.models.ai.azure.com/v1',
|
||||
'',
|
||||
null,
|
||||
undefined,
|
||||
]) {
|
||||
expect(isAzureFoundryEndpoint(endpoint)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('matches only on a dot boundary, not on a bare substring', () => {
|
||||
// A host that merely *contains* the domain must not match, or a
|
||||
// lookalike like `openai.azure.com.evil.test` would be trusted.
|
||||
expect(isAzureFoundryEndpoint('https://openai.azure.com.evil.test/v1')).toBe(false);
|
||||
// `myopenai.azure.com` is a different host under azure.com, not a
|
||||
// subdomain of `openai.azure.com`, so it must not match either.
|
||||
expect(isAzureFoundryEndpoint('https://myopenai.azure.com/v1')).toBe(false);
|
||||
// The genuine per-resource shape does match.
|
||||
expect(isAzureFoundryEndpoint('https://myopenai.openai.azure.com/v1')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('looksLikeAzureBaseModelId', () => {
|
||||
const catalog = ['gpt-5.6-terra-2026-07-09', 'gpt-4o'];
|
||||
|
||||
it('flags a value taken verbatim from the provider catalog', () => {
|
||||
expect(looksLikeAzureBaseModelId('gpt-5.6-terra-2026-07-09', catalog)).toBe(true);
|
||||
expect(looksLikeAzureBaseModelId(' gpt-4o ', catalog)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not flag a deployment name that is absent from the catalog', () => {
|
||||
// The whole point of the fix: a deployment name is normally NOT in the
|
||||
// catalog, and that state must read as correct rather than suspicious.
|
||||
expect(looksLikeAzureBaseModelId('gpt-5.6-terra', catalog)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not flag empty values or an empty catalog', () => {
|
||||
expect(looksLikeAzureBaseModelId('', catalog)).toBe(false);
|
||||
expect(looksLikeAzureBaseModelId(' ', catalog)).toBe(false);
|
||||
expect(looksLikeAzureBaseModelId(null, catalog)).toBe(false);
|
||||
expect(looksLikeAzureBaseModelId('gpt-4o', [])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAzureV1BaseUrl', () => {
|
||||
it('accepts only the OpenAI-compatible v1 base', () => {
|
||||
expect(isAzureV1BaseUrl('https://my-res.openai.azure.com/openai/v1')).toBe(true);
|
||||
expect(isAzureV1BaseUrl('https://my-res.openai.azure.com/openai/v1/')).toBe(true);
|
||||
expect(isAzureV1BaseUrl('HTTPS://My-Res.OPENAI.AZURE.COM/openai/v1')).toBe(true);
|
||||
expect(isAzureV1BaseUrl('https://contoso.services.ai.azure.com/openai/v1')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects the classic api-version surface and bare resource URLs', () => {
|
||||
// These serve no `{base}/models` listing and want an `api-key` header, so
|
||||
// both the add-provider probe and inference fail on them.
|
||||
expect(isAzureV1BaseUrl('https://my-res.openai.azure.com/openai')).toBe(false);
|
||||
expect(isAzureV1BaseUrl('https://my-res.openai.azure.com')).toBe(false);
|
||||
expect(isAzureV1BaseUrl('https://my-res.openai.azure.com/openai/deployments/my-dep')).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('is false for a non-Azure endpoint', () => {
|
||||
expect(isAzureV1BaseUrl('https://api.openai.com/v1')).toBe(false);
|
||||
expect(isAzureV1BaseUrl('')).toBe(false);
|
||||
expect(isAzureV1BaseUrl(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
/*
|
||||
* The model / deployment-name field shared by both AI-panel model pickers —
|
||||
* the global "Use Your Own Models" card and the per-workload routing dialog
|
||||
* (issue #5213).
|
||||
*
|
||||
* Both pickers used to reimplement the same state machine: derive "is this an
|
||||
* Azure connection" from the endpoint host, seed manual-vs-catalog entry mode
|
||||
* from it, render either a free-text field or the probed `/models` dropdown,
|
||||
* offer an escape hatch between them, and surface the Azure help + legacy-value
|
||||
* hint. The two copies had already begun to diverge (one had a `mono` field and
|
||||
* a "select a model" placeholder option, the other did not), so the behaviour
|
||||
* lives here once.
|
||||
*
|
||||
* Why the mode exists at all: Azure AI Foundry routes inference by the user's
|
||||
* **deployment name**, while `/models` lists the **base model ids** deployments
|
||||
* are created from. A closed dropdown sourced from that catalog therefore makes
|
||||
* the only correct value unreachable. Free text is the default for Azure, and
|
||||
* an explicit toggle exposes it for every other provider too, which also
|
||||
* unblocks any provider whose listing omits a model the user is entitled to.
|
||||
*/
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { useT } from '../../../../lib/i18n/I18nContext';
|
||||
import type { ModelInfo } from '../../../../services/api/aiSettingsApi';
|
||||
import Button from '../../../ui/Button';
|
||||
import { SettingsSelect, SettingsTextField } from '../../controls';
|
||||
import { isAzureFoundryEndpoint, looksLikeAzureBaseModelId } from '../azureDeployment';
|
||||
|
||||
/** Resolved entry-mode state for one picker. Produced by {@link useModelEntryMode}. */
|
||||
export interface ModelEntryMode {
|
||||
/** The selected provider's endpoint host is Azure — this field is a deployment name. */
|
||||
isAzureProvider: boolean;
|
||||
/** The user's explicit choice, or the Azure-derived default when untouched. */
|
||||
manualEntry: boolean;
|
||||
/** Effective mode: free text when asked for, or when there is no catalog to pick from. */
|
||||
useManualEntry: boolean;
|
||||
/** The stored value is verbatim a catalog entry — the fingerprint of a pre-fix selection. */
|
||||
showAzureLegacyHint: boolean;
|
||||
/** Flip between the catalog dropdown and free text. */
|
||||
toggleManualEntry: () => void;
|
||||
/**
|
||||
* Re-derive the default mode for a newly selected provider. Call this from
|
||||
* the provider `<select>` handler with the next provider's endpoint (or
|
||||
* `undefined` for a non-cloud source such as local Ollama / Claude Code).
|
||||
*/
|
||||
syncToEndpoint: (endpoint: string | null | undefined) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Own the Azure detection + entry-mode state for a model picker.
|
||||
*
|
||||
* `endpoint` is the currently selected cloud provider's endpoint; pass
|
||||
* `undefined` when the selected source is not a cloud provider.
|
||||
*/
|
||||
export function useModelEntryMode({
|
||||
endpoint,
|
||||
model,
|
||||
catalogIds,
|
||||
}: {
|
||||
endpoint: string | null | undefined;
|
||||
model: string;
|
||||
catalogIds: readonly string[];
|
||||
}): ModelEntryMode {
|
||||
const isAzureProvider = isAzureFoundryEndpoint(endpoint);
|
||||
// Seeded from the initially selected provider; re-seeded by `syncToEndpoint`
|
||||
// whenever the user picks a different one, and overridden by the toggle.
|
||||
const [manualEntry, setManualEntry] = useState<boolean>(() => isAzureFoundryEndpoint(endpoint));
|
||||
|
||||
const toggleManualEntry = useCallback(() => setManualEntry(v => !v), []);
|
||||
const syncToEndpoint = useCallback((next: string | null | undefined) => {
|
||||
setManualEntry(isAzureFoundryEndpoint(next));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isAzureProvider,
|
||||
manualEntry,
|
||||
// An empty catalog leaves nothing to pick from — that was already the
|
||||
// pre-existing behaviour for a provider whose listing came back empty.
|
||||
useManualEntry: manualEntry || catalogIds.length === 0,
|
||||
showAzureLegacyHint: isAzureProvider && looksLikeAzureBaseModelId(model, catalogIds),
|
||||
toggleManualEntry,
|
||||
syncToEndpoint,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the model / deployment-name field: catalog dropdown or free text, the
|
||||
* mode toggle, the loading + probe-error branches, and the Azure guidance.
|
||||
*
|
||||
* The caller keeps ownership of the non-cloud branches (installed local models,
|
||||
* the Claude Code alias field) — those have their own option sources and are
|
||||
* not part of the Azure story.
|
||||
*/
|
||||
export const ModelEntryField = ({
|
||||
mode,
|
||||
model,
|
||||
onModelChange,
|
||||
catalog,
|
||||
catalogLoading,
|
||||
catalogError,
|
||||
onRetry,
|
||||
label,
|
||||
placeholder,
|
||||
analyticsId,
|
||||
optionLabel,
|
||||
}: {
|
||||
mode: ModelEntryMode;
|
||||
model: string;
|
||||
onModelChange: (next: string) => void;
|
||||
catalog: readonly ModelInfo[];
|
||||
catalogLoading?: boolean;
|
||||
catalogError?: string | null;
|
||||
onRetry?: () => void;
|
||||
/** Field label used when the provider is not Azure. */
|
||||
label: string;
|
||||
/** Free-text placeholder used when the provider is not Azure. */
|
||||
placeholder: string;
|
||||
analyticsId: string;
|
||||
/** Option text for a catalog entry. Defaults to the bare model id. */
|
||||
optionLabel?: (m: ModelInfo) => string;
|
||||
}) => {
|
||||
const { t } = useT();
|
||||
const { isAzureProvider, manualEntry, useManualEntry, showAzureLegacyHint } = mode;
|
||||
const fieldLabel = isAzureProvider ? t('settings.ai.deploymentNameLabel') : label;
|
||||
|
||||
// A still-loading catalog only blocks the dropdown. Gate on the *explicit*
|
||||
// mode rather than the effective one: while the probe is in flight the
|
||||
// catalog is empty, so `useManualEntry` is transiently true for everyone and
|
||||
// gating on it would drop the "loading" affordance entirely. In free-text
|
||||
// mode (every Azure connection) the field is usable immediately — waiting on
|
||||
// a listing whose values are the wrong ones anyway would be pure delay.
|
||||
const showLoadingSelect = Boolean(catalogLoading) && !manualEntry;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-content-secondary">{fieldLabel}</label>
|
||||
|
||||
{catalogError ? (
|
||||
<div className="rounded-lg border border-red-200 dark:border-red-500/30 bg-red-50 dark:bg-red-500/10 px-3 py-2 text-xs text-red-700 dark:text-red-300 font-mono break-all">
|
||||
{catalogError}
|
||||
</div>
|
||||
) : null}
|
||||
{catalogError && onRetry ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="tertiary" size="xs" onClick={onRetry}>
|
||||
{t('common.retry')}
|
||||
</Button>
|
||||
{/* Azure gets `deploymentNameHelp` under the field instead — this
|
||||
copy names a model id, which is the wrong thing to ask for. */}
|
||||
{!isAzureProvider && (
|
||||
<span className="text-xs text-content-faint">
|
||||
{t('settings.ai.enterModelIdManually')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showLoadingSelect ? (
|
||||
<SettingsSelect disabled className="w-full opacity-60 cursor-wait">
|
||||
<option>{t('settings.ai.loadingModels')}</option>
|
||||
</SettingsSelect>
|
||||
) : useManualEntry ? (
|
||||
<SettingsTextField
|
||||
type="text"
|
||||
mono
|
||||
aria-label={fieldLabel}
|
||||
value={model}
|
||||
onChange={e => onModelChange(e.target.value)}
|
||||
placeholder={isAzureProvider ? t('settings.ai.deploymentNamePlaceholder') : placeholder}
|
||||
/>
|
||||
) : (
|
||||
<SettingsSelect
|
||||
aria-label={fieldLabel}
|
||||
value={model}
|
||||
onChange={e => onModelChange(e.target.value)}
|
||||
className="w-full">
|
||||
{!model && <option value="">{t('settings.ai.selectModel')}</option>}
|
||||
{/* Keep an off-catalog value (e.g. a deployment name) selectable so
|
||||
switching to the dropdown can never silently drop it. */}
|
||||
{model && !catalog.some(m => m.id === model) && <option value={model}>{model}</option>}
|
||||
{catalog.map(m => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{optionLabel ? optionLabel(m) : m.id}
|
||||
</option>
|
||||
))}
|
||||
</SettingsSelect>
|
||||
)}
|
||||
|
||||
{/* Escape hatch out of the catalog: a deployment name (Azure) or any model
|
||||
the provider does not advertise is otherwise unreachable. Pointless
|
||||
when there is no catalog — free text is already the only mode. */}
|
||||
{catalog.length > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="tertiary"
|
||||
size="xs"
|
||||
analyticsId={analyticsId}
|
||||
onClick={mode.toggleManualEntry}>
|
||||
{manualEntry
|
||||
? t('settings.ai.chooseModelFromList')
|
||||
: isAzureProvider
|
||||
? t('settings.ai.enterDeploymentNameManuallyAction')
|
||||
: t('settings.ai.enterModelIdManuallyAction')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isAzureProvider && (
|
||||
<p className="text-[11px] text-content-muted">{t('settings.ai.deploymentNameHelp')}</p>
|
||||
)}
|
||||
{showAzureLegacyHint && (
|
||||
<p className="rounded-lg border border-amber-200 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/10 px-3 py-2 text-[11px] text-amber-800 dark:text-amber-200">
|
||||
{t('settings.ai.deploymentNameLegacyHint')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelEntryField;
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Azure AI Foundry deployment-name helpers (issue #5213).
|
||||
*
|
||||
* Azure separates the **base model id** it was deployed from
|
||||
* (`gpt-5.6-terra-2026-07-09`) from the user-chosen **deployment name**
|
||||
* (`gpt-5.6-terra`) that actually routes the request. The OpenAI-compatible
|
||||
* surface keys the request body's `model` field on the *deployment name*, so a
|
||||
* value taken from the provider's `/models` catalog (which lists base model
|
||||
* ids) yields "Model not found".
|
||||
*
|
||||
* OpenHuman's routing already sends the `<model>` half of a `"<slug>:<model>"`
|
||||
* provider string verbatim as that body field, so nothing in the request path
|
||||
* needs to change. The only defect is that the settings UI sourced the value
|
||||
* exclusively from the `/models` catalog, leaving no way to type a deployment
|
||||
* name that is not in it. These helpers let the AI panel recognise an Azure
|
||||
* connection and switch the model field to free text.
|
||||
*
|
||||
* Detection is by endpoint **host**, not slug: Azure is reachable today only
|
||||
* through the generic "Add cloud provider" flow, so the user picks the slug
|
||||
* (`azure`, `azure-foundry`, `my-azure`, …) and the host is the one stable
|
||||
* signal. Every Azure resource has its own subdomain under a small set of
|
||||
* Microsoft-owned parents.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Authority hosts (or parent domains) that serve Azure AI Foundry / Azure
|
||||
* OpenAI inference. A host matches when it equals one of these or is a
|
||||
* subdomain of it, which is how per-resource endpoints like
|
||||
* `my-resource.openai.azure.com` are recognised.
|
||||
*/
|
||||
const AZURE_ENDPOINT_HOSTS = [
|
||||
// The two hosts Microsoft documents for the OpenAI-compatible v1 base URL
|
||||
// (`https://<resource>.<host>/openai/v1/`), plus the resource host the older
|
||||
// `api-version` surface is served from.
|
||||
'openai.azure.com',
|
||||
'services.ai.azure.com',
|
||||
'cognitiveservices.azure.com',
|
||||
// NOT `inference.ai.azure.com` / `models.ai.azure.com`. Those are the Foundry
|
||||
// *serverless* endpoints, which speak the Azure AI Model Inference API at
|
||||
// `{endpoint}/models/chat/completions` and key the `model` field on the model
|
||||
// name, not on a deployment name. Classifying them here would relabel a
|
||||
// correct model id as a "deployment name" and mislead the user in the one
|
||||
// place this module exists to make clear.
|
||||
// Sovereign clouds. Azure OpenAI is offered in Azure Government
|
||||
// (`*.openai.azure.us`) and in Azure operated by 21Vianet / China
|
||||
// (`*.openai.azure.cn`). They are separate DNS parents, so the commercial
|
||||
// `.com` entries above do not cover them, and without these a sovereign
|
||||
// tenant falls through to the exact "model not found" path this module
|
||||
// exists to prevent.
|
||||
'openai.azure.us',
|
||||
'openai.azure.cn',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Extract the lowercased authority host from an endpoint URL, dropping the
|
||||
* scheme, any userinfo, the port and the path. Returns an empty string when no
|
||||
* host can be parsed.
|
||||
*
|
||||
* Mirrors the Rust `endpoint_host` helper in
|
||||
* `src/openhuman/config/schema/cloud_providers.rs` — including its tolerance
|
||||
* for a missing scheme — so both sides classify a stored endpoint identically.
|
||||
*/
|
||||
export function endpointHost(endpoint: string | null | undefined): string {
|
||||
const trimmed = (endpoint ?? '').trim().toLowerCase();
|
||||
if (!trimmed) return '';
|
||||
// Drop the scheme (`https://…`); tolerate a bare `host/path` form.
|
||||
const schemeIdx = trimmed.indexOf('://');
|
||||
const afterScheme = schemeIdx === -1 ? trimmed : trimmed.slice(schemeIdx + 3);
|
||||
// The authority ends at the first path / query / fragment delimiter.
|
||||
const authority = afterScheme.split(/[/?#]/)[0] ?? '';
|
||||
// Strip any `user:pass@` userinfo prefix.
|
||||
const atIdx = authority.lastIndexOf('@');
|
||||
const hostPort = atIdx === -1 ? authority : authority.slice(atIdx + 1);
|
||||
// Strip the port, handling bracketed IPv6 literals (`[::1]:8080`).
|
||||
if (hostPort.startsWith('[')) {
|
||||
const close = hostPort.indexOf(']');
|
||||
return close === -1 ? hostPort.slice(1) : hostPort.slice(1, close);
|
||||
}
|
||||
const colonIdx = hostPort.lastIndexOf(':');
|
||||
return (colonIdx === -1 ? hostPort : hostPort.slice(0, colonIdx)).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `endpoint` points at an Azure AI Foundry / Azure OpenAI resource,
|
||||
* i.e. a provider whose `model` field must carry a deployment name.
|
||||
*/
|
||||
export function isAzureFoundryEndpoint(endpoint: string | null | undefined): boolean {
|
||||
const host = endpointHost(endpoint);
|
||||
if (!host) return false;
|
||||
return AZURE_ENDPOINT_HOSTS.some(known => host === known || host.endsWith(`.${known}`));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an Azure endpoint points at the OpenAI-compatible **v1** base
|
||||
* (`https://<resource>.<host>/openai/v1[/]`).
|
||||
*
|
||||
* This matters because only that base behaves like every other provider
|
||||
* OpenHuman stores: it serves a `GET {base}/models` listing and accepts the
|
||||
* resource key in the `authorization` header (Azure's published v1 spec
|
||||
* declares both an `api-key` and an `authorization` API-key scheme), which is
|
||||
* the `bearer` auth style custom providers are created with. The older
|
||||
* `api-version` surface serves neither, so a bare resource URL copied out of
|
||||
* the portal fails the probe and then fails inference.
|
||||
*
|
||||
* Returns `false` for a non-Azure endpoint — callers pair it with
|
||||
* {@link isAzureFoundryEndpoint}.
|
||||
*/
|
||||
export function isAzureV1BaseUrl(endpoint: string | null | undefined): boolean {
|
||||
if (!isAzureFoundryEndpoint(endpoint)) return false;
|
||||
const trimmed = (endpoint ?? '').trim().toLowerCase();
|
||||
const schemeIdx = trimmed.indexOf('://');
|
||||
const afterScheme = schemeIdx === -1 ? trimmed : trimmed.slice(schemeIdx + 3);
|
||||
const slashIdx = afterScheme.indexOf('/');
|
||||
// No path at all (a bare host) is not the v1 base.
|
||||
const path = slashIdx === -1 ? '' : afterScheme.slice(slashIdx);
|
||||
return /^\/openai\/v1\/?$/.test(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a stored model value carries the fingerprint of a **pre-fix** Azure
|
||||
* selection: it is exactly an entry from the provider's `/models` catalog.
|
||||
*
|
||||
* Before this fix the dropdown was the only way to set the value, so catalog
|
||||
* membership is precisely the signature of a connection configured the broken
|
||||
* way. It stays a *hint* rather than an error because a user is free to name a
|
||||
* deployment after its base model, in which case the value is already correct
|
||||
* and confirming it is a no-op. Nothing is rewritten on the user's behalf.
|
||||
*/
|
||||
export function looksLikeAzureBaseModelId(
|
||||
model: string | null | undefined,
|
||||
catalogModelIds: readonly string[]
|
||||
): boolean {
|
||||
const trimmed = (model ?? '').trim();
|
||||
if (!trimmed) return false;
|
||||
return catalogModelIds.some(id => id.trim() === trimmed);
|
||||
}
|
||||
@@ -4504,6 +4504,8 @@ const messages: TranslationMap = {
|
||||
'settings.ai.claudeCode.signInUnknown': 'حالة تسجيل الدخول غير معروفة',
|
||||
'settings.ai.claudeCode.connectedNotSignedIn': 'متصل · لم يتم تسجيل الدخول',
|
||||
'settings.ai.claudeCode.modalTitle': 'Claude Code CLI',
|
||||
'settings.ai.claudeCode.modelHelp':
|
||||
'معرّف نموذج تقبله أداة claude: اسم مختصر (sonnet أو opus) أو اسم كامل (claude-sonnet-4-5). يُمرَّر كما هو إلى claude --model، لذا تُرفض الصيغ التسويقية مثل sonnet-4-5.',
|
||||
'settings.ai.claudeCode.modalDescription':
|
||||
'يوجّه أعباء المحادثة والمهام الوكيلة والاستدلال عبر واجهة Claude Code CLI المثبَّتة محليًا. لا حاجة لمفتاح API: فهي تستخدم تسجيل الدخول الخاص بها.',
|
||||
'settings.ai.claudeCode.close': 'إغلاق',
|
||||
@@ -4623,6 +4625,22 @@ const messages: TranslationMap = {
|
||||
'settings.ai.modelIdPlaceholderForProvider': '{slug} معرف النموذج',
|
||||
'settings.ai.modelIdPlaceholder': 'model-id',
|
||||
'settings.ai.selectModel': 'حدد نموذجًا...',
|
||||
'settings.ai.deploymentNameLabel': 'اسم النشر',
|
||||
'settings.ai.deploymentNamePlaceholder': 'my-gpt-deployment',
|
||||
'settings.ai.deploymentNameHelp':
|
||||
'أدخل اسم النشر الذي حددته في Azure AI Foundry. هذا ليس معرف النموذج.',
|
||||
'settings.ai.deploymentNameLegacyHint':
|
||||
'تطابق هذه القيمة معرف نموذج أساسي من كتالوج المزود. يوجه Azure الطلبات حسب اسم النشر، لذا تأكد من أن هذا هو الاسم الذي منحته لعملية النشر.',
|
||||
'settings.ai.deploymentNameProviderHint':
|
||||
'تم اكتشاف نقطة نهاية Azure. حدد اسم النشر في حقل النموذج بعد اختيار هذا المزود.',
|
||||
'settings.ai.chooseModelFromList': 'اختر من القائمة',
|
||||
'settings.ai.enterModelIdManuallyAction': 'أدخل معرف النموذج يدويًا',
|
||||
'settings.ai.enterDeploymentNameManuallyAction': 'أدخل اسم النشر يدويًا',
|
||||
'settings.ai.probeFailedHint':
|
||||
'تعذّر علينا قراءة قائمة النماذج لدى هذا المزوّد. تلك القائمة تملأ القائمة المنسدلة فقط، لذا لا يزال بإمكانك إضافة المزوّد وكتابة اسم النموذج أو النشر بنفسك.',
|
||||
'settings.ai.probeFailedAddAnyway': 'أضِف دون التحقق',
|
||||
'settings.ai.azureV1EndpointHint':
|
||||
'مع Azure استخدم عنوان الإصدار v1: https://YOUR-RESOURCE.openai.azure.com/openai/v1. لا يوفّر عنوان المورد الأقدم قائمة نماذج ويتوقّع ترويسة مصادقة مختلفة.',
|
||||
'settings.ai.temperatureOverride': 'تجاوز درجة الحرارة',
|
||||
'settings.ai.temperatureOverrideSlider': 'تجاوز درجة الحرارة (شريط التمرير)',
|
||||
'settings.ai.temperatureOverrideValue': 'تجاوز درجة الحرارة (القيمة)',
|
||||
|
||||
@@ -4618,6 +4618,8 @@ const messages: TranslationMap = {
|
||||
'settings.ai.claudeCode.signInUnknown': 'সাইন-ইন অবস্থা অজানা',
|
||||
'settings.ai.claudeCode.connectedNotSignedIn': 'সংযুক্ত · সাইন-ইন করা হয়নি',
|
||||
'settings.ai.claudeCode.modalTitle': 'Claude Code CLI',
|
||||
'settings.ai.claudeCode.modelHelp':
|
||||
'claude CLI যে মডেল আইডি গ্রহণ করে: একটি উপনাম (sonnet, opus) বা পূর্ণ নাম (claude-sonnet-4-5)। এটি হুবহু claude --model-এ পাঠানো হয়, তাই sonnet-4-5-এর মতো বিপণন নাম গ্রহণ করা হয় না।',
|
||||
'settings.ai.claudeCode.modalDescription':
|
||||
'চ্যাট, এজেন্টিক ও যুক্তিনির্ভর কাজগুলো আপনার স্থানীয়ভাবে ইনস্টল করা Claude Code CLI-এর মাধ্যমে রুট করে। কোনো API কী লাগে না: এটি CLI-এর নিজস্ব লগইন ব্যবহার করে।',
|
||||
'settings.ai.claudeCode.close': 'বন্ধ করুন',
|
||||
@@ -4737,6 +4739,22 @@ const messages: TranslationMap = {
|
||||
'settings.ai.modelIdPlaceholderForProvider': '{slug} মডেল আইডি',
|
||||
'settings.ai.modelIdPlaceholder': 'model-id',
|
||||
'settings.ai.selectModel': 'একটি মডেল নির্বাচন করুন...',
|
||||
'settings.ai.deploymentNameLabel': 'ডিপ্লয়মেন্টের নাম',
|
||||
'settings.ai.deploymentNamePlaceholder': 'my-gpt-deployment',
|
||||
'settings.ai.deploymentNameHelp':
|
||||
'Azure AI Foundry-তে আপনি যে ডিপ্লয়মেন্টের নাম সেট করেছেন তা লিখুন। এটি মডেল আইডি নয়।',
|
||||
'settings.ai.deploymentNameLegacyHint':
|
||||
'এই মানটি প্রদানকারীর তালিকার বেস মডেল আইডির সঙ্গে মেলে। Azure ডিপ্লয়মেন্টের নাম দিয়ে অনুরোধ পাঠায়, তাই নিশ্চিত করুন এটি আপনার ডিপ্লয়মেন্টের নাম।',
|
||||
'settings.ai.deploymentNameProviderHint':
|
||||
'Azure এন্ডপয়েন্ট শনাক্ত হয়েছে। এই প্রদানকারী নির্বাচন করার পর মডেল ফিল্ডে আপনার ডিপ্লয়মেন্টের নাম দিন।',
|
||||
'settings.ai.chooseModelFromList': 'তালিকা থেকে বেছে নিন',
|
||||
'settings.ai.enterModelIdManuallyAction': 'ম্যানুয়ালি মডেল আইডি লিখুন',
|
||||
'settings.ai.enterDeploymentNameManuallyAction': 'ম্যানুয়ালি ডিপ্লয়মেন্ট নাম লিখুন',
|
||||
'settings.ai.probeFailedHint':
|
||||
'এই প্রদানকারীর মডেল তালিকা আমরা পড়তে পারিনি। ওই তালিকা কেবল ড্রপডাউন ভরাট করে, তাই আপনি এখনও প্রদানকারী যোগ করে নিজেই মডেল বা ডিপ্লয়মেন্টের নাম লিখতে পারেন।',
|
||||
'settings.ai.probeFailedAddAnyway': 'যাচাই না করেই যোগ করুন',
|
||||
'settings.ai.azureV1EndpointHint':
|
||||
'Azure-এর জন্য v1 বেস URL ব্যবহার করুন: https://YOUR-RESOURCE.openai.azure.com/openai/v1। পুরোনো রিসোর্স URL কোনো মডেল তালিকা দেয় না এবং ভিন্ন প্রমাণীকরণ হেডার আশা করে।',
|
||||
'settings.ai.temperatureOverride': 'তাপমাত্রা ওভাররাইড',
|
||||
'settings.ai.temperatureOverrideSlider': 'তাপমাত্রা ওভাররাইড (স্লাইডার)',
|
||||
'settings.ai.temperatureOverrideValue': 'তাপমাত্রা ওভাররাইড (মান)',
|
||||
|
||||
@@ -4747,6 +4747,8 @@ const messages: TranslationMap = {
|
||||
'settings.ai.claudeCode.signInUnknown': 'Anmeldestatus unbekannt',
|
||||
'settings.ai.claudeCode.connectedNotSignedIn': 'Verbunden · nicht angemeldet',
|
||||
'settings.ai.claudeCode.modalTitle': 'Claude Code CLI',
|
||||
'settings.ai.claudeCode.modelHelp':
|
||||
'Eine Modell-ID, die die claude-CLI akzeptiert: ein Alias (sonnet, opus) oder ein vollständiger Name (claude-sonnet-4-5). Sie wird unverändert an claude --model übergeben, Marketingnamen wie sonnet-4-5 werden daher abgelehnt.',
|
||||
'settings.ai.claudeCode.modalDescription':
|
||||
'Leitet Chat-, Agenten- und Reasoning-Aufgaben über deine lokal installierte Claude Code CLI. Kein API-Schlüssel: sie nutzt die eigene Anmeldung der CLI.',
|
||||
'settings.ai.claudeCode.close': 'Schließen',
|
||||
@@ -4871,6 +4873,22 @@ const messages: TranslationMap = {
|
||||
'settings.ai.modelIdPlaceholderForProvider': '{slug} Modell-ID',
|
||||
'settings.ai.modelIdPlaceholder': 'model-id',
|
||||
'settings.ai.selectModel': 'Wählen Sie ein Modell...',
|
||||
'settings.ai.deploymentNameLabel': 'Bereitstellungsname',
|
||||
'settings.ai.deploymentNamePlaceholder': 'my-gpt-deployment',
|
||||
'settings.ai.deploymentNameHelp':
|
||||
'Geben Sie den Bereitstellungsnamen ein, den Sie in Azure AI Foundry festgelegt haben. Dies ist nicht die Modell-ID.',
|
||||
'settings.ai.deploymentNameLegacyHint':
|
||||
'Dieser Wert entspricht einer Basismodell-ID aus dem Anbieterkatalog. Azure leitet Anfragen über den Bereitstellungsnamen weiter, bestätigen Sie daher, dass dies der Name Ihrer Bereitstellung ist.',
|
||||
'settings.ai.deploymentNameProviderHint':
|
||||
'Azure-Endpunkt erkannt. Legen Sie Ihren Bereitstellungsnamen im Modellfeld fest, nachdem Sie diesen Anbieter ausgewählt haben.',
|
||||
'settings.ai.chooseModelFromList': 'Aus Liste wählen',
|
||||
'settings.ai.enterModelIdManuallyAction': 'Modell-ID manuell eingeben',
|
||||
'settings.ai.enterDeploymentNameManuallyAction': 'Bereitstellungsnamen manuell eingeben',
|
||||
'settings.ai.probeFailedHint':
|
||||
'Die Modellliste dieses Anbieters konnte nicht gelesen werden. Diese Liste füllt nur das Auswahlmenü, du kannst den Anbieter also trotzdem hinzufügen und den Modell- oder Bereitstellungsnamen selbst eintippen.',
|
||||
'settings.ai.probeFailedAddAnyway': 'Ohne Prüfung hinzufügen',
|
||||
'settings.ai.azureV1EndpointHint':
|
||||
'Verwende für Azure die v1-Basis-URL: https://YOUR-RESOURCE.openai.azure.com/openai/v1. Die ältere Ressourcen-URL liefert keine Modellliste und erwartet einen anderen Auth-Header.',
|
||||
'settings.ai.temperatureOverride': 'Temperatur-Override',
|
||||
'settings.ai.temperatureOverrideSlider': 'Temperatur-Override (Schieberegler)',
|
||||
'settings.ai.temperatureOverrideValue': 'Temperatur-Override (Wert)',
|
||||
|
||||
@@ -5205,6 +5205,8 @@ const en: TranslationMap = {
|
||||
'settings.ai.claudeCode.signInUnknown': 'Sign-in state unknown',
|
||||
'settings.ai.claudeCode.connectedNotSignedIn': 'Connected · not signed in',
|
||||
'settings.ai.claudeCode.modalTitle': 'Claude Code CLI',
|
||||
'settings.ai.claudeCode.modelHelp':
|
||||
'A model id the claude CLI accepts: an alias (sonnet, opus) or a full name (claude-sonnet-4-5). It is passed verbatim to claude --model, so marketing strings like sonnet-4-5 are rejected.',
|
||||
'settings.ai.claudeCode.modalDescription':
|
||||
"Routes chat, agentic and reasoning workloads through your locally-installed Claude Code CLI. No API key: it uses the CLI's own login.",
|
||||
'settings.ai.claudeCode.close': 'Close',
|
||||
@@ -5326,6 +5328,22 @@ const en: TranslationMap = {
|
||||
'settings.ai.modelIdPlaceholderForProvider': '{slug} model id',
|
||||
'settings.ai.modelIdPlaceholder': 'model-id',
|
||||
'settings.ai.selectModel': 'Select a model...',
|
||||
'settings.ai.deploymentNameLabel': 'Deployment name',
|
||||
'settings.ai.deploymentNamePlaceholder': 'my-gpt-deployment',
|
||||
'settings.ai.deploymentNameHelp':
|
||||
'Enter the deployment name you set in Azure AI Foundry. This is not the model ID.',
|
||||
'settings.ai.deploymentNameLegacyHint':
|
||||
'This matches a base model ID from the provider catalog. Azure routes by deployment name, so confirm this is the name you gave your deployment.',
|
||||
'settings.ai.deploymentNameProviderHint':
|
||||
'Azure endpoint detected. Set your deployment name in the model field after choosing this provider.',
|
||||
'settings.ai.chooseModelFromList': 'Choose from list',
|
||||
'settings.ai.enterModelIdManuallyAction': 'Enter model ID manually',
|
||||
'settings.ai.enterDeploymentNameManuallyAction': 'Enter deployment name manually',
|
||||
'settings.ai.probeFailedHint':
|
||||
'We could not read this provider’s model list. That list only fills the dropdown, so you can still add the provider and type the model or deployment name yourself.',
|
||||
'settings.ai.probeFailedAddAnyway': 'Add without verifying',
|
||||
'settings.ai.azureV1EndpointHint':
|
||||
'For Azure, use the v1 base URL: https://YOUR-RESOURCE.openai.azure.com/openai/v1. The older resource URL does not serve a model list and expects a different auth header.',
|
||||
'settings.ai.temperatureOverride': 'Temperature override',
|
||||
'settings.ai.temperatureOverrideSlider': 'Temperature override (slider)',
|
||||
'settings.ai.temperatureOverrideValue': 'Temperature override (value)',
|
||||
|
||||
@@ -4694,6 +4694,8 @@ const messages: TranslationMap = {
|
||||
'settings.ai.claudeCode.signInUnknown': 'Estado de inicio de sesión desconocido',
|
||||
'settings.ai.claudeCode.connectedNotSignedIn': 'Conectado · sin iniciar sesión',
|
||||
'settings.ai.claudeCode.modalTitle': 'Claude Code CLI',
|
||||
'settings.ai.claudeCode.modelHelp':
|
||||
'Un ID de modelo que acepta la CLI de claude: un alias (sonnet, opus) o un nombre completo (claude-sonnet-4-5). Se pasa tal cual a claude --model, así que los nombres comerciales como sonnet-4-5 se rechazan.',
|
||||
'settings.ai.claudeCode.modalDescription':
|
||||
'Enruta las cargas de chat, agénticas y de razonamiento a través de tu Claude Code CLI instalado localmente. Sin clave de API: usa el propio inicio de sesión del CLI.',
|
||||
'settings.ai.claudeCode.close': 'Cerrar',
|
||||
@@ -4818,6 +4820,23 @@ const messages: TranslationMap = {
|
||||
'settings.ai.modelIdPlaceholderForProvider': '{slug} identificación del modelo',
|
||||
'settings.ai.modelIdPlaceholder': 'model-id',
|
||||
'settings.ai.selectModel': 'Seleccione un modelo...',
|
||||
'settings.ai.deploymentNameLabel': 'Nombre de la implementación',
|
||||
'settings.ai.deploymentNamePlaceholder': 'my-gpt-deployment',
|
||||
'settings.ai.deploymentNameHelp':
|
||||
'Introduzca el nombre de la implementación que definió en Azure AI Foundry. No es el ID del modelo.',
|
||||
'settings.ai.deploymentNameLegacyHint':
|
||||
'Este valor coincide con un ID de modelo base del catálogo del proveedor. Azure enruta las solicitudes por nombre de implementación, así que confirme que este es el nombre que dio a su implementación.',
|
||||
'settings.ai.deploymentNameProviderHint':
|
||||
'Punto de conexión de Azure detectado. Defina el nombre de su implementación en el campo de modelo tras elegir este proveedor.',
|
||||
'settings.ai.chooseModelFromList': 'Elegir de la lista',
|
||||
'settings.ai.enterModelIdManuallyAction': 'Introducir el ID del modelo manualmente',
|
||||
'settings.ai.enterDeploymentNameManuallyAction':
|
||||
'Introducir el nombre de implementación manualmente',
|
||||
'settings.ai.probeFailedHint':
|
||||
'No hemos podido leer la lista de modelos de este proveedor. Esa lista solo rellena el desplegable, así que puedes añadir el proveedor igualmente y escribir tú mismo el nombre del modelo o de la implementación.',
|
||||
'settings.ai.probeFailedAddAnyway': 'Añadir sin verificar',
|
||||
'settings.ai.azureV1EndpointHint':
|
||||
'Para Azure, usa la URL base v1: https://YOUR-RESOURCE.openai.azure.com/openai/v1. La URL de recurso antigua no ofrece una lista de modelos y espera otra cabecera de autenticación.',
|
||||
'settings.ai.temperatureOverride': 'Anulación de temperatura',
|
||||
'settings.ai.temperatureOverrideSlider': 'Anulación de temperatura (control deslizante)',
|
||||
'settings.ai.temperatureOverrideValue': 'Anulación de temperatura (valor)',
|
||||
|
||||
@@ -4723,6 +4723,8 @@ const messages: TranslationMap = {
|
||||
'settings.ai.claudeCode.signInUnknown': 'État de connexion inconnu',
|
||||
'settings.ai.claudeCode.connectedNotSignedIn': 'Connecté · non authentifié',
|
||||
'settings.ai.claudeCode.modalTitle': 'Claude Code CLI',
|
||||
'settings.ai.claudeCode.modelHelp':
|
||||
'Un identifiant de modèle accepté par la CLI claude : un alias (sonnet, opus) ou un nom complet (claude-sonnet-4-5). Il est transmis tel quel à claude --model, donc les noms commerciaux comme sonnet-4-5 sont refusés.',
|
||||
'settings.ai.claudeCode.modalDescription':
|
||||
'Achemine les tâches de chat, agentiques et de raisonnement via votre Claude Code CLI installée localement. Aucune clé API: elle utilise sa propre connexion.',
|
||||
'settings.ai.claudeCode.close': 'Fermer',
|
||||
@@ -4848,6 +4850,22 @@ const messages: TranslationMap = {
|
||||
'settings.ai.modelIdPlaceholderForProvider': '{slug} identifiant du modèle',
|
||||
'settings.ai.modelIdPlaceholder': 'model-id',
|
||||
'settings.ai.selectModel': 'Sélectionnez un modèle...',
|
||||
'settings.ai.deploymentNameLabel': 'Nom du déploiement',
|
||||
'settings.ai.deploymentNamePlaceholder': 'my-gpt-deployment',
|
||||
'settings.ai.deploymentNameHelp':
|
||||
"Saisissez le nom du déploiement que vous avez défini dans Azure AI Foundry. Ce n'est pas l'identifiant du modèle.",
|
||||
'settings.ai.deploymentNameLegacyHint':
|
||||
"Cette valeur correspond à un identifiant de modèle de base du catalogue du fournisseur. Azure achemine les requêtes par nom de déploiement, confirmez donc qu'il s'agit bien du nom de votre déploiement.",
|
||||
'settings.ai.deploymentNameProviderHint':
|
||||
'Point de terminaison Azure détecté. Définissez le nom de votre déploiement dans le champ du modèle après avoir choisi ce fournisseur.',
|
||||
'settings.ai.chooseModelFromList': 'Choisir dans la liste',
|
||||
'settings.ai.enterModelIdManuallyAction': "Saisir l'identifiant du modèle manuellement",
|
||||
'settings.ai.enterDeploymentNameManuallyAction': 'Saisir le nom du déploiement manuellement',
|
||||
'settings.ai.probeFailedHint':
|
||||
"Nous n'avons pas pu lire la liste des modèles de ce fournisseur. Cette liste ne sert qu'à remplir le menu déroulant : vous pouvez donc quand même ajouter le fournisseur et saisir vous-même le nom du modèle ou du déploiement.",
|
||||
'settings.ai.probeFailedAddAnyway': 'Ajouter sans vérifier',
|
||||
'settings.ai.azureV1EndpointHint':
|
||||
"Pour Azure, utilisez l'URL de base v1 : https://YOUR-RESOURCE.openai.azure.com/openai/v1. L'ancienne URL de ressource ne fournit pas de liste de modèles et attend un autre en-tête d'authentification.",
|
||||
'settings.ai.temperatureOverride': 'Température override',
|
||||
'settings.ai.temperatureOverrideSlider': 'Override de température (curseur)',
|
||||
'settings.ai.temperatureOverrideValue': 'Override de température (valeur)',
|
||||
|
||||
@@ -4615,6 +4615,8 @@ const messages: TranslationMap = {
|
||||
'settings.ai.claudeCode.signInUnknown': 'साइन-इन स्थिति अज्ञात',
|
||||
'settings.ai.claudeCode.connectedNotSignedIn': 'कनेक्टेड · साइन इन नहीं',
|
||||
'settings.ai.claudeCode.modalTitle': 'Claude Code CLI',
|
||||
'settings.ai.claudeCode.modelHelp':
|
||||
'ऐसी मॉडल आईडी जिसे claude CLI स्वीकार करता है: एक उपनाम (sonnet, opus) या पूरा नाम (claude-sonnet-4-5)। इसे ज्यों का त्यों claude --model को भेजा जाता है, इसलिए sonnet-4-5 जैसे मार्केटिंग नाम अस्वीकार हो जाते हैं।',
|
||||
'settings.ai.claudeCode.modalDescription':
|
||||
'चैट, एजेंटिक और रीज़निंग कार्यभार को आपके स्थानीय रूप से इंस्टॉल किए गए Claude Code CLI के माध्यम से रूट करता है। कोई API कुंजी नहीं: यह CLI के अपने लॉगिन का उपयोग करता है।',
|
||||
'settings.ai.claudeCode.close': 'बंद करें',
|
||||
@@ -4737,6 +4739,22 @@ const messages: TranslationMap = {
|
||||
'settings.ai.modelIdPlaceholderForProvider': '{slug} मॉडल आईडी',
|
||||
'settings.ai.modelIdPlaceholder': 'model-id',
|
||||
'settings.ai.selectModel': 'एक मॉडल चुनें...',
|
||||
'settings.ai.deploymentNameLabel': 'परिनियोजन नाम',
|
||||
'settings.ai.deploymentNamePlaceholder': 'my-gpt-deployment',
|
||||
'settings.ai.deploymentNameHelp':
|
||||
'Azure AI Foundry में आपके द्वारा सेट किया गया परिनियोजन नाम दर्ज करें। यह मॉडल आईडी नहीं है।',
|
||||
'settings.ai.deploymentNameLegacyHint':
|
||||
'यह मान प्रदाता सूची की आधार मॉडल आईडी से मेल खाता है। Azure अनुरोधों को परिनियोजन नाम से भेजता है, इसलिए पुष्टि करें कि यह आपके परिनियोजन का नाम है।',
|
||||
'settings.ai.deploymentNameProviderHint':
|
||||
'Azure एंडपॉइंट मिला। यह प्रदाता चुनने के बाद मॉडल फ़ील्ड में अपना परिनियोजन नाम दर्ज करें।',
|
||||
'settings.ai.chooseModelFromList': 'सूची से चुनें',
|
||||
'settings.ai.enterModelIdManuallyAction': 'मॉडल आईडी मैन्युअल रूप से दर्ज करें',
|
||||
'settings.ai.enterDeploymentNameManuallyAction': 'डिप्लॉयमेंट नाम मैन्युअल रूप से दर्ज करें',
|
||||
'settings.ai.probeFailedHint':
|
||||
'हम इस प्रदाता की मॉडल सूची नहीं पढ़ सके। वह सूची केवल ड्रॉपडाउन भरती है, इसलिए आप प्रदाता को फिर भी जोड़ सकते हैं और मॉडल या डिप्लॉयमेंट का नाम खुद लिख सकते हैं।',
|
||||
'settings.ai.probeFailedAddAnyway': 'सत्यापन के बिना जोड़ें',
|
||||
'settings.ai.azureV1EndpointHint':
|
||||
'Azure के लिए v1 बेस URL का उपयोग करें: https://YOUR-RESOURCE.openai.azure.com/openai/v1. पुराना रिसोर्स URL मॉडल सूची नहीं देता और अलग प्रमाणीकरण हेडर की अपेक्षा करता है।',
|
||||
'settings.ai.temperatureOverride': 'तापमान ओवरराइड',
|
||||
'settings.ai.temperatureOverrideSlider': 'तापमान ओवरराइड (स्लाइडर)',
|
||||
'settings.ai.temperatureOverrideValue': 'तापमान ओवरराइड (मान)',
|
||||
|
||||
@@ -4633,6 +4633,8 @@ const messages: TranslationMap = {
|
||||
'settings.ai.claudeCode.signInUnknown': 'Status masuk tidak diketahui',
|
||||
'settings.ai.claudeCode.connectedNotSignedIn': 'Terhubung · belum masuk',
|
||||
'settings.ai.claudeCode.modalTitle': 'Claude Code CLI',
|
||||
'settings.ai.claudeCode.modelHelp':
|
||||
'ID model yang diterima CLI claude: sebuah alias (sonnet, opus) atau nama lengkap (claude-sonnet-4-5). Nilainya diteruskan apa adanya ke claude --model, sehingga nama pemasaran seperti sonnet-4-5 akan ditolak.',
|
||||
'settings.ai.claudeCode.modalDescription':
|
||||
'Merutekan beban kerja obrolan, agentik, dan penalaran melalui Claude Code CLI yang terpasang secara lokal. Tanpa kunci API: menggunakan login milik CLI itu sendiri.',
|
||||
'settings.ai.claudeCode.close': 'Tutup',
|
||||
@@ -4755,6 +4757,22 @@ const messages: TranslationMap = {
|
||||
'settings.ai.modelIdPlaceholderForProvider': '{slug} ID model',
|
||||
'settings.ai.modelIdPlaceholder': 'model-id',
|
||||
'settings.ai.selectModel': 'Pilih model...',
|
||||
'settings.ai.deploymentNameLabel': 'Nama penerapan',
|
||||
'settings.ai.deploymentNamePlaceholder': 'my-gpt-deployment',
|
||||
'settings.ai.deploymentNameHelp':
|
||||
'Masukkan nama penerapan yang Anda atur di Azure AI Foundry. Ini bukan ID model.',
|
||||
'settings.ai.deploymentNameLegacyHint':
|
||||
'Nilai ini cocok dengan ID model dasar dari katalog penyedia. Azure merutekan permintaan berdasarkan nama penerapan, jadi pastikan ini adalah nama yang Anda berikan untuk penerapan Anda.',
|
||||
'settings.ai.deploymentNameProviderHint':
|
||||
'Endpoint Azure terdeteksi. Atur nama penerapan Anda di bidang model setelah memilih penyedia ini.',
|
||||
'settings.ai.chooseModelFromList': 'Pilih dari daftar',
|
||||
'settings.ai.enterModelIdManuallyAction': 'Masukkan ID model secara manual',
|
||||
'settings.ai.enterDeploymentNameManuallyAction': 'Masukkan nama penerapan secara manual',
|
||||
'settings.ai.probeFailedHint':
|
||||
'Kami tidak dapat membaca daftar model penyedia ini. Daftar itu hanya mengisi menu dropdown, jadi Anda tetap bisa menambahkan penyedia dan mengetik sendiri nama model atau penerapannya.',
|
||||
'settings.ai.probeFailedAddAnyway': 'Tambahkan tanpa verifikasi',
|
||||
'settings.ai.azureV1EndpointHint':
|
||||
'Untuk Azure, gunakan URL dasar v1: https://YOUR-RESOURCE.openai.azure.com/openai/v1. URL sumber daya yang lama tidak menyediakan daftar model dan mengharapkan header autentikasi yang berbeda.',
|
||||
'settings.ai.temperatureOverride': 'Penggantian suhu',
|
||||
'settings.ai.temperatureOverrideSlider': 'Penggantian suhu (slider)',
|
||||
'settings.ai.temperatureOverrideValue': 'Penggantian suhu (nilai)',
|
||||
|
||||
@@ -4689,6 +4689,8 @@ const messages: TranslationMap = {
|
||||
'settings.ai.claudeCode.signInUnknown': 'Stato di accesso sconosciuto',
|
||||
'settings.ai.claudeCode.connectedNotSignedIn': 'Connesso · accesso non effettuato',
|
||||
'settings.ai.claudeCode.modalTitle': 'Claude Code CLI',
|
||||
'settings.ai.claudeCode.modelHelp':
|
||||
'Un ID di modello accettato dalla CLI claude: un alias (sonnet, opus) o un nome completo (claude-sonnet-4-5). Viene passato invariato a claude --model, quindi i nomi commerciali come sonnet-4-5 vengono rifiutati.',
|
||||
'settings.ai.claudeCode.modalDescription':
|
||||
'Instrada i carichi di chat, agentici e di ragionamento tramite la tua Claude Code CLI installata localmente. Nessuna chiave API: usa il login della CLI stessa.',
|
||||
'settings.ai.claudeCode.close': 'Chiudi',
|
||||
@@ -4811,6 +4813,23 @@ const messages: TranslationMap = {
|
||||
'settings.ai.modelIdPlaceholderForProvider': '{slug} ID modello',
|
||||
'settings.ai.modelIdPlaceholder': 'model-id',
|
||||
'settings.ai.selectModel': 'Seleziona un modello...',
|
||||
'settings.ai.deploymentNameLabel': 'Nome della distribuzione',
|
||||
'settings.ai.deploymentNamePlaceholder': 'my-gpt-deployment',
|
||||
'settings.ai.deploymentNameHelp':
|
||||
"Inserisci il nome della distribuzione impostato in Azure AI Foundry. Non è l'ID del modello.",
|
||||
'settings.ai.deploymentNameLegacyHint':
|
||||
'Questo valore corrisponde a un ID di modello base del catalogo del fornitore. Azure instrada le richieste in base al nome della distribuzione, quindi conferma che sia il nome assegnato alla tua distribuzione.',
|
||||
'settings.ai.deploymentNameProviderHint':
|
||||
'Endpoint Azure rilevato. Imposta il nome della distribuzione nel campo del modello dopo aver scelto questo fornitore.',
|
||||
'settings.ai.chooseModelFromList': "Scegli dall'elenco",
|
||||
'settings.ai.enterModelIdManuallyAction': "Inserisci manualmente l'ID del modello",
|
||||
'settings.ai.enterDeploymentNameManuallyAction':
|
||||
'Inserisci manualmente il nome della distribuzione',
|
||||
'settings.ai.probeFailedHint':
|
||||
"Non siamo riusciti a leggere l'elenco dei modelli di questo provider. Quell'elenco riempie solo il menu a tendina, quindi puoi comunque aggiungere il provider e scrivere tu stesso il nome del modello o della distribuzione.",
|
||||
'settings.ai.probeFailedAddAnyway': 'Aggiungi senza verificare',
|
||||
'settings.ai.azureV1EndpointHint':
|
||||
"Per Azure usa l'URL di base v1: https://YOUR-RESOURCE.openai.azure.com/openai/v1. Il vecchio URL della risorsa non espone un elenco di modelli e si aspetta un'intestazione di autenticazione diversa.",
|
||||
'settings.ai.temperatureOverride': 'Ignora temperatura',
|
||||
'settings.ai.temperatureOverrideSlider': 'Ignora temperatura (cursore)',
|
||||
'settings.ai.temperatureOverrideValue': 'Ignora temperatura (valore)',
|
||||
|
||||
@@ -4565,6 +4565,8 @@ const messages: TranslationMap = {
|
||||
'settings.ai.claudeCode.signInUnknown': '로그인 상태 알 수 없음',
|
||||
'settings.ai.claudeCode.connectedNotSignedIn': '연결됨 · 로그인되지 않음',
|
||||
'settings.ai.claudeCode.modalTitle': 'Claude Code CLI',
|
||||
'settings.ai.claudeCode.modelHelp':
|
||||
'claude CLI가 허용하는 모델 ID입니다. 별칭(sonnet, opus) 또는 전체 이름(claude-sonnet-4-5)을 쓸 수 있습니다. 값은 claude --model에 그대로 전달되므로 sonnet-4-5 같은 마케팅 이름은 거부됩니다.',
|
||||
'settings.ai.claudeCode.modalDescription':
|
||||
'채팅, 에이전트, 추론 작업을 로컬에 설치된 Claude Code CLI를 통해 라우팅합니다. API 키가 필요 없으며 CLI 자체 로그인을 사용합니다.',
|
||||
'settings.ai.claudeCode.close': '닫기',
|
||||
@@ -4683,6 +4685,22 @@ const messages: TranslationMap = {
|
||||
'settings.ai.modelIdPlaceholderForProvider': '{slug} 모델 ID',
|
||||
'settings.ai.modelIdPlaceholder': 'model-id',
|
||||
'settings.ai.selectModel': '모델 선택...',
|
||||
'settings.ai.deploymentNameLabel': '배포 이름',
|
||||
'settings.ai.deploymentNamePlaceholder': 'my-gpt-deployment',
|
||||
'settings.ai.deploymentNameHelp':
|
||||
'Azure AI Foundry에서 설정한 배포 이름을 입력하세요. 모델 ID가 아닙니다.',
|
||||
'settings.ai.deploymentNameLegacyHint':
|
||||
'이 값은 공급자 카탈로그의 기본 모델 ID와 일치합니다. Azure는 배포 이름으로 요청을 라우팅하므로 배포에 지정한 이름인지 확인하세요.',
|
||||
'settings.ai.deploymentNameProviderHint':
|
||||
'Azure 엔드포인트가 감지되었습니다. 이 공급자를 선택한 후 모델 필드에 배포 이름을 설정하세요.',
|
||||
'settings.ai.chooseModelFromList': '목록에서 선택',
|
||||
'settings.ai.enterModelIdManuallyAction': '모델 ID 수동 입력',
|
||||
'settings.ai.enterDeploymentNameManuallyAction': '배포 이름 수동 입력',
|
||||
'settings.ai.probeFailedHint':
|
||||
'이 제공업체의 모델 목록을 읽지 못했습니다. 그 목록은 드롭다운을 채우는 용도일 뿐이므로, 제공업체를 그대로 추가한 뒤 모델 또는 배포 이름을 직접 입력해도 됩니다.',
|
||||
'settings.ai.probeFailedAddAnyway': '확인 없이 추가',
|
||||
'settings.ai.azureV1EndpointHint':
|
||||
'Azure에서는 v1 기본 URL을 사용하세요: https://YOUR-RESOURCE.openai.azure.com/openai/v1. 이전 리소스 URL은 모델 목록을 제공하지 않으며 다른 인증 헤더를 요구합니다.',
|
||||
'settings.ai.temperatureOverride': '온도 재정의',
|
||||
'settings.ai.temperatureOverrideSlider': '온도 재정의(슬라이더)',
|
||||
'settings.ai.temperatureOverrideValue': '온도 재정의(값)',
|
||||
|
||||
@@ -4685,6 +4685,8 @@ const messages: TranslationMap = {
|
||||
'settings.ai.claudeCode.signInUnknown': 'Nieznany stan logowania',
|
||||
'settings.ai.claudeCode.connectedNotSignedIn': 'Połączono · nie zalogowano',
|
||||
'settings.ai.claudeCode.modalTitle': 'Claude Code CLI',
|
||||
'settings.ai.claudeCode.modelHelp':
|
||||
'Identyfikator modelu akceptowany przez CLI claude: alias (sonnet, opus) albo pełna nazwa (claude-sonnet-4-5). Jest przekazywany bez zmian do claude --model, więc nazwy marketingowe takie jak sonnet-4-5 są odrzucane.',
|
||||
'settings.ai.claudeCode.modalDescription':
|
||||
'Kieruje zadania czatu, agentowe i wnioskowania przez lokalnie zainstalowane Claude Code CLI. Bez klucza API: używa własnego logowania CLI.',
|
||||
'settings.ai.claudeCode.close': 'Zamknij',
|
||||
@@ -4808,6 +4810,22 @@ const messages: TranslationMap = {
|
||||
'settings.ai.modelIdPlaceholderForProvider': 'ID modelu {slug}',
|
||||
'settings.ai.modelIdPlaceholder': 'model-id',
|
||||
'settings.ai.selectModel': 'Wybierz model...',
|
||||
'settings.ai.deploymentNameLabel': 'Nazwa wdrożenia',
|
||||
'settings.ai.deploymentNamePlaceholder': 'my-gpt-deployment',
|
||||
'settings.ai.deploymentNameHelp':
|
||||
'Wprowadź nazwę wdrożenia ustawioną w Azure AI Foundry. To nie jest identyfikator modelu.',
|
||||
'settings.ai.deploymentNameLegacyHint':
|
||||
'Ta wartość odpowiada identyfikatorowi modelu bazowego z katalogu dostawcy. Azure kieruje żądania według nazwy wdrożenia, więc potwierdź, że jest to nazwa nadana Twojemu wdrożeniu.',
|
||||
'settings.ai.deploymentNameProviderHint':
|
||||
'Wykryto punkt końcowy Azure. Ustaw nazwę wdrożenia w polu modelu po wybraniu tego dostawcy.',
|
||||
'settings.ai.chooseModelFromList': 'Wybierz z listy',
|
||||
'settings.ai.enterModelIdManuallyAction': 'Wpisz identyfikator modelu ręcznie',
|
||||
'settings.ai.enterDeploymentNameManuallyAction': 'Wpisz nazwę wdrożenia ręcznie',
|
||||
'settings.ai.probeFailedHint':
|
||||
'Nie udało się odczytać listy modeli tego dostawcy. Ta lista wypełnia tylko listę rozwijaną, więc nadal możesz dodać dostawcę i samodzielnie wpisać nazwę modelu lub wdrożenia.',
|
||||
'settings.ai.probeFailedAddAnyway': 'Dodaj bez weryfikacji',
|
||||
'settings.ai.azureV1EndpointHint':
|
||||
'W przypadku Azure użyj bazowego adresu v1: https://YOUR-RESOURCE.openai.azure.com/openai/v1. Starszy adres zasobu nie udostępnia listy modeli i oczekuje innego nagłówka uwierzytelniania.',
|
||||
'settings.ai.temperatureOverride': 'Nadpisanie temperatury',
|
||||
'settings.ai.temperatureOverrideSlider': 'Nadpisanie temperatury (suwak)',
|
||||
'settings.ai.temperatureOverrideValue': 'Nadpisanie temperatury (wartość)',
|
||||
|
||||
@@ -4681,6 +4681,8 @@ const messages: TranslationMap = {
|
||||
'settings.ai.claudeCode.signInUnknown': 'Estado de login desconhecido',
|
||||
'settings.ai.claudeCode.connectedNotSignedIn': 'Conectado · não autenticado',
|
||||
'settings.ai.claudeCode.modalTitle': 'Claude Code CLI',
|
||||
'settings.ai.claudeCode.modelHelp':
|
||||
'Um ID de modelo aceito pela CLI claude: um alias (sonnet, opus) ou um nome completo (claude-sonnet-4-5). Ele é repassado sem alterações para claude --model, portanto nomes comerciais como sonnet-4-5 são recusados.',
|
||||
'settings.ai.claudeCode.modalDescription':
|
||||
'Encaminha cargas de chat, agênticas e de raciocínio pela sua Claude Code CLI instalada localmente. Sem chave de API: usa o próprio login da CLI.',
|
||||
'settings.ai.claudeCode.close': 'Fechar',
|
||||
@@ -4804,6 +4806,22 @@ const messages: TranslationMap = {
|
||||
'settings.ai.modelIdPlaceholderForProvider': '{slug} ID do modelo',
|
||||
'settings.ai.modelIdPlaceholder': 'model-id',
|
||||
'settings.ai.selectModel': 'Selecione um modelo...',
|
||||
'settings.ai.deploymentNameLabel': 'Nome da implantação',
|
||||
'settings.ai.deploymentNamePlaceholder': 'my-gpt-deployment',
|
||||
'settings.ai.deploymentNameHelp':
|
||||
'Digite o nome da implantação que você definiu no Azure AI Foundry. Este não é o ID do modelo.',
|
||||
'settings.ai.deploymentNameLegacyHint':
|
||||
'Este valor corresponde a um ID de modelo base do catálogo do provedor. O Azure roteia as solicitações pelo nome da implantação, portanto confirme que este é o nome que você deu à sua implantação.',
|
||||
'settings.ai.deploymentNameProviderHint':
|
||||
'Endpoint do Azure detectado. Defina o nome da sua implantação no campo do modelo depois de escolher este provedor.',
|
||||
'settings.ai.chooseModelFromList': 'Escolher da lista',
|
||||
'settings.ai.enterModelIdManuallyAction': 'Inserir o ID do modelo manualmente',
|
||||
'settings.ai.enterDeploymentNameManuallyAction': 'Inserir o nome da implantação manualmente',
|
||||
'settings.ai.probeFailedHint':
|
||||
'Não foi possível ler a lista de modelos deste provedor. Essa lista só preenche o menu suspenso, portanto você ainda pode adicionar o provedor e digitar o nome do modelo ou da implantação.',
|
||||
'settings.ai.probeFailedAddAnyway': 'Adicionar sem verificar',
|
||||
'settings.ai.azureV1EndpointHint':
|
||||
'No Azure, use o URL base v1: https://YOUR-RESOURCE.openai.azure.com/openai/v1. O URL de recurso antigo não fornece uma lista de modelos e espera outro cabeçalho de autenticação.',
|
||||
'settings.ai.temperatureOverride': 'Substituição de temperatura',
|
||||
'settings.ai.temperatureOverrideSlider': 'Substituição de temperatura (controle deslizante)',
|
||||
'settings.ai.temperatureOverrideValue': 'Substituição de temperatura (valor)',
|
||||
|
||||
@@ -4657,6 +4657,8 @@ const messages: TranslationMap = {
|
||||
'settings.ai.claudeCode.signInUnknown': 'Состояние входа неизвестно',
|
||||
'settings.ai.claudeCode.connectedNotSignedIn': 'Подключено · вход не выполнен',
|
||||
'settings.ai.claudeCode.modalTitle': 'Claude Code CLI',
|
||||
'settings.ai.claudeCode.modelHelp':
|
||||
'Идентификатор модели, который принимает CLI claude: псевдоним (sonnet, opus) или полное имя (claude-sonnet-4-5). Значение передаётся в claude --model без изменений, поэтому маркетинговые названия вроде sonnet-4-5 отклоняются.',
|
||||
'settings.ai.claudeCode.modalDescription':
|
||||
'Направляет задачи чата, агентные и рассуждающие нагрузки через локально установленный Claude Code CLI. Без ключа API: используется собственный вход CLI.',
|
||||
'settings.ai.claudeCode.close': 'Закрыть',
|
||||
@@ -4781,6 +4783,22 @@ const messages: TranslationMap = {
|
||||
'settings.ai.modelIdPlaceholderForProvider': '{slug} идентификатор модели',
|
||||
'settings.ai.modelIdPlaceholder': 'model-id',
|
||||
'settings.ai.selectModel': 'Выберите модель...',
|
||||
'settings.ai.deploymentNameLabel': 'Имя развёртывания',
|
||||
'settings.ai.deploymentNamePlaceholder': 'my-gpt-deployment',
|
||||
'settings.ai.deploymentNameHelp':
|
||||
'Введите имя развёртывания, заданное в Azure AI Foundry. Это не идентификатор модели.',
|
||||
'settings.ai.deploymentNameLegacyHint':
|
||||
'Это значение совпадает с идентификатором базовой модели из каталога поставщика. Azure маршрутизирует запросы по имени развёртывания, поэтому убедитесь, что это имя вашего развёртывания.',
|
||||
'settings.ai.deploymentNameProviderHint':
|
||||
'Обнаружена конечная точка Azure. Укажите имя развёртывания в поле модели после выбора этого поставщика.',
|
||||
'settings.ai.chooseModelFromList': 'Выбрать из списка',
|
||||
'settings.ai.enterModelIdManuallyAction': 'Ввести идентификатор модели вручную',
|
||||
'settings.ai.enterDeploymentNameManuallyAction': 'Ввести имя развёртывания вручную',
|
||||
'settings.ai.probeFailedHint':
|
||||
'Не удалось прочитать список моделей этого провайдера. Этот список только заполняет выпадающее меню, поэтому вы всё равно можете добавить провайдера и ввести имя модели или развёртывания вручную.',
|
||||
'settings.ai.probeFailedAddAnyway': 'Добавить без проверки',
|
||||
'settings.ai.azureV1EndpointHint':
|
||||
'Для Azure используйте базовый адрес v1: https://YOUR-RESOURCE.openai.azure.com/openai/v1. Старый адрес ресурса не отдаёт список моделей и ожидает другой заголовок аутентификации.',
|
||||
'settings.ai.temperatureOverride': 'Переопределение температуры',
|
||||
'settings.ai.temperatureOverrideSlider': 'Переопределение температуры (ползунок)',
|
||||
'settings.ai.temperatureOverrideValue': 'Переопределение температуры (значение)',
|
||||
|
||||
@@ -4371,6 +4371,8 @@ const messages: TranslationMap = {
|
||||
'settings.ai.claudeCode.signInUnknown': '登录状态未知',
|
||||
'settings.ai.claudeCode.connectedNotSignedIn': '已连接 · 未登录',
|
||||
'settings.ai.claudeCode.modalTitle': 'Claude Code CLI',
|
||||
'settings.ai.claudeCode.modelHelp':
|
||||
'claude 命令行接受的模型 ID:别名(sonnet、opus)或完整名称(claude-sonnet-4-5)。该值会原样传给 claude --model,因此 sonnet-4-5 这类营销名称会被拒绝。',
|
||||
'settings.ai.claudeCode.modalDescription':
|
||||
'通过你本地安装的 Claude Code CLI 路由聊天、智能体和推理任务。无需 API 密钥:它使用 CLI 自身的登录。',
|
||||
'settings.ai.claudeCode.close': '关闭',
|
||||
@@ -4479,6 +4481,21 @@ const messages: TranslationMap = {
|
||||
'settings.ai.modelIdPlaceholderForProvider': '{slug} 型号 ID',
|
||||
'settings.ai.modelIdPlaceholder': 'model-id',
|
||||
'settings.ai.selectModel': '选择型号...',
|
||||
'settings.ai.deploymentNameLabel': '部署名称',
|
||||
'settings.ai.deploymentNamePlaceholder': 'my-gpt-deployment',
|
||||
'settings.ai.deploymentNameHelp': '请输入您在 Azure AI Foundry 中设置的部署名称。这不是模型 ID。',
|
||||
'settings.ai.deploymentNameLegacyHint':
|
||||
'此值与提供商目录中的基础模型 ID 相同。Azure 按部署名称路由请求,请确认这是您为部署指定的名称。',
|
||||
'settings.ai.deploymentNameProviderHint':
|
||||
'已检测到 Azure 端点。选择此提供商后,请在模型字段中设置部署名称。',
|
||||
'settings.ai.chooseModelFromList': '从列表中选择',
|
||||
'settings.ai.enterModelIdManuallyAction': '手动输入模型 ID',
|
||||
'settings.ai.enterDeploymentNameManuallyAction': '手动输入部署名称',
|
||||
'settings.ai.probeFailedHint':
|
||||
'我们无法读取该提供方的模型列表。该列表只用于填充下拉菜单,你仍然可以添加该提供方,并自行输入模型或部署名称。',
|
||||
'settings.ai.probeFailedAddAnyway': '不验证直接添加',
|
||||
'settings.ai.azureV1EndpointHint':
|
||||
'在 Azure 上请使用 v1 基础地址:https://YOUR-RESOURCE.openai.azure.com/openai/v1。旧的资源地址不提供模型列表,并且需要不同的认证请求头。',
|
||||
'settings.ai.temperatureOverride': '温度超控',
|
||||
'settings.ai.temperatureOverrideSlider': '温度超控(滑块)',
|
||||
'settings.ai.temperatureOverrideValue': '温度超控(值)',
|
||||
|
||||
@@ -551,10 +551,11 @@ End-to-end coverage of the agent harness via the web-chat RPC surface against an
|
||||
|
||||
### 13.3 AI & Skills
|
||||
|
||||
| ID | Feature | Layer | Test path(s) | Status | Notes |
|
||||
| ------ | ------------------- | ----- | -------------------------------------------------------------------------------------------------------------------------- | ------ | ----------------------- |
|
||||
| 13.3.1 | Model Configuration | VU+WD | `app/src/components/settings/panels/__tests__/AutocompletePanel.test.tsx`, `app/test/e2e/specs/settings-ai-skills.spec.ts` | ✅ | AI-model-switch covered |
|
||||
| 13.3.2 | Skill Toggle | WD | `skill-lifecycle.spec.ts`, `app/test/e2e/specs/settings-ai-skills.spec.ts` | ✅ | |
|
||||
| ID | Feature | Layer | Test path(s) | Status | Notes |
|
||||
| ------ | -------------------------------------------- | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| 13.3.1 | Model Configuration | VU+WD | `app/src/components/settings/panels/__tests__/AutocompletePanel.test.tsx`, `app/test/e2e/specs/settings-ai-skills.spec.ts` | ✅ | AI-model-switch covered |
|
||||
| 13.3.2 | Skill Toggle | WD | `skill-lifecycle.spec.ts`, `app/test/e2e/specs/settings-ai-skills.spec.ts` | ✅ | |
|
||||
| 13.3.3 | Azure deployment name (off-catalog model id) | VU | `app/src/components/settings/panels/__tests__/azureDeployment.test.ts`, `app/src/components/settings/panels/__tests__/ModelEntryField.test.tsx`, `app/src/components/settings/panels/__tests__/AIPanel.test.tsx` | ✅ | Azure routes by deployment name, not the base model id its `/models` catalog lists (#5213). Covers endpoint-host detection (incl. dot-boundary lookalikes and the excluded Foundry serverless hosts), `/openai/v1` base-URL detection and its inline nudge, the shared entry-mode field used by both pickers (free text, catalog, loading, probe-error branches), no auto-seeding of a catalog id for Azure, the deployment name surviving verbatim into persisted routing, and a provider staying creatable when the live `/models` probe fails |
|
||||
|
||||
### 13.4 Developer Options
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ const INTENTIONAL_ENGLISH = new Set([
|
||||
"conversations.backgroundTasks.cronSchedCron", // Cron expression label; Cron is the scheduler name
|
||||
"composio.integrationSlugsExample",
|
||||
"composio.integrationSlugsPlaceholder",
|
||||
"settings.ai.deploymentNamePlaceholder", // Example Azure deployment id; an identifier, not prose
|
||||
"devOptions.toolPolicyDiagnostics.mcpAllowlists.allowDeny",
|
||||
"intelligence.agents.subagentCountOne",
|
||||
"intelligence.diagram.skillInstallCommand",
|
||||
|
||||
Reference in New Issue
Block a user