fix(ui): harden provider model alias editing

- keep raw JSON model mappings aligned with requested=upstream semantics

- block malformed mapping lines instead of coercing them on save

- render saved mappings as requested to upstream and add regression coverage

Refs #941
This commit is contained in:
Tam Nhu Tran
2026-04-10 17:44:15 -04:00
parent 49c4338f28
commit 92a769d773
6 changed files with 114 additions and 25 deletions
@@ -1,5 +1,6 @@
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { getRequestedModelId } from '@/lib/provider-config';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { import type {
AiProviderEntryView, AiProviderEntryView,
@@ -241,9 +242,13 @@ export function ProviderEntryCard({
key={`${model.name}:${model.alias}`} key={`${model.name}:${model.alias}`}
className="rounded-md border bg-muted/20 px-3 py-2" className="rounded-md border bg-muted/20 px-3 py-2"
> >
<span className="font-medium">{model.name}</span> <span className="font-medium">{getRequestedModelId(model)}</span>
<span className="mx-2 text-muted-foreground"></span> {model.alias.trim() ? (
<span className="text-muted-foreground">{model.alias}</span> <>
<span className="mx-2 text-muted-foreground"></span>
<span className="text-muted-foreground">{model.name}</span>
</>
) : null}
</div> </div>
))} ))}
</div> </div>
@@ -3,6 +3,7 @@ import { ProviderLogo } from '@/components/cliproxy/provider-logo';
import { import {
formatRequestedUpstreamModelRules, formatRequestedUpstreamModelRules,
getAiProviderFamilyVisual, getAiProviderFamilyVisual,
getRequestedUpstreamModelRuleErrors,
parseRequestedUpstreamModelRules, parseRequestedUpstreamModelRules,
} from '@/lib/provider-config'; } from '@/lib/provider-config';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -284,6 +285,10 @@ export function ProviderEntryDialog({
const [headers, setHeaders] = useState(() => formatHeaders(entry)); const [headers, setHeaders] = useState(() => formatHeaders(entry));
const [excludedModels, setExcludedModels] = useState(() => formatExcludedModels(entry)); const [excludedModels, setExcludedModels] = useState(() => formatExcludedModels(entry));
const [modelAliases, setModelAliases] = useState(() => formatModelAliases(entry)); const [modelAliases, setModelAliases] = useState(() => formatModelAliases(entry));
const modelRuleErrors = useMemo(
() => getRequestedUpstreamModelRuleErrors(modelAliases),
[modelAliases]
);
const [advancedOpen, setAdvancedOpen] = useState(() => const [advancedOpen, setAdvancedOpen] = useState(() =>
Boolean( Boolean(
entry?.headers.length || entry?.excludedModels.length || entry?.proxyUrl || entry?.prefix entry?.headers.length || entry?.excludedModels.length || entry?.proxyUrl || entry?.prefix
@@ -298,6 +303,10 @@ export function ProviderEntryDialog({
}, [entry?.secretConfigured, isEditing, supportsOpenAiCompat]); }, [entry?.secretConfigured, isEditing, supportsOpenAiCompat]);
const handleSubmit = async () => { const handleSubmit = async () => {
if (modelRuleErrors.length > 0) {
return;
}
const nextApiKey = apiKey.trim(); const nextApiKey = apiKey.trim();
const nextApiKeys = parseDelimitedLines(apiKeys); const nextApiKeys = parseDelimitedLines(apiKeys);
const preserveSecrets = const preserveSecrets =
@@ -459,6 +468,9 @@ export function ProviderEntryDialog({
placeholder={guide.aliasesPlaceholder} placeholder={guide.aliasesPlaceholder}
/> />
<p className="text-xs text-muted-foreground">{guide.aliasesHelper}</p> <p className="text-xs text-muted-foreground">{guide.aliasesHelper}</p>
{modelRuleErrors.length > 0 ? (
<p className="text-xs text-destructive">{modelRuleErrors[0]}</p>
) : null}
</div> </div>
</section> </section>
@@ -560,7 +572,11 @@ export function ProviderEntryDialog({
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}> <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel Cancel
</Button> </Button>
<Button type="button" onClick={() => void handleSubmit()} disabled={isSaving}> <Button
type="button"
onClick={() => void handleSubmit()}
disabled={isSaving || modelRuleErrors.length > 0}
>
{isSaving {isSaving
? 'Saving...' ? 'Saving...'
: supportsOpenAiCompat : supportsOpenAiCompat
+17
View File
@@ -144,6 +144,23 @@ export function parseRequestedUpstreamModelRules(value: string): AiProviderModel
.filter((item) => item.name.length > 0 || item.alias.length > 0); .filter((item) => item.name.length > 0 || item.alias.length > 0);
} }
export function getRequestedUpstreamModelRuleErrors(value: string): string[] {
return value
.split('\n')
.map((line, index) => ({ line: line.trim(), lineNumber: index + 1 }))
.filter(({ line }) => line.length > 0 && line.includes('='))
.flatMap(({ line, lineNumber }) => {
const separatorIndex = line.indexOf('=');
const requested = line.slice(0, separatorIndex).trim();
const upstream = line.slice(separatorIndex + 1).trim();
if (requested && upstream) {
return [];
}
return [`Line ${lineNumber}: use requested=upstream or a plain model name.`];
});
}
/** /**
* Format stored provider config back into the UI-facing requested=upstream form. * Format stored provider config back into the UI-facing requested=upstream form.
*/ */
+23 -21
View File
@@ -20,6 +20,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { import {
formatRequestedUpstreamModelRules, formatRequestedUpstreamModelRules,
getAiProviderFamilyVisual, getAiProviderFamilyVisual,
getRequestedUpstreamModelRuleErrors,
getRequestedModelId, getRequestedModelId,
parseRequestedUpstreamModelRules, parseRequestedUpstreamModelRules,
} from '@/lib/provider-config'; } from '@/lib/provider-config';
@@ -386,6 +387,12 @@ function buildRawConfigModelArray(value: string) {
return parsed.length > 0 ? parsed : undefined; return parsed.length > 0 ? parsed : undefined;
} }
function formatRawConfigModelArray(value: unknown): string {
return Array.isArray(value)
? formatRequestedUpstreamModelRules(value as Array<{ name?: string; alias?: string }>)
: '';
}
function buildExcludedModelsArray(value: string) { function buildExcludedModelsArray(value: string) {
const parsed = parseDelimitedLines(value); const parsed = parseDelimitedLines(value);
return parsed.length > 0 ? parsed : undefined; return parsed.length > 0 ? parsed : undefined;
@@ -472,16 +479,7 @@ function parseEntryConfigDraft(
.join('\n') .join('\n')
: '', : '',
excludedModelsText: '', excludedModelsText: '',
modelAliasesText: Array.isArray(record.models) modelAliasesText: formatRawConfigModelArray(record.models),
? (record.models as Array<{ name?: string; alias?: string }>)
.map((item) =>
item.alias?.trim()
? `${item.name?.trim() || ''}=${item.alias.trim()}`
: item.name?.trim() || ''
)
.filter(Boolean)
.join('\n')
: '',
apiKey: '', apiKey: '',
apiKeysText: apiKeys.join('\n'), apiKeysText: apiKeys.join('\n'),
}; };
@@ -503,16 +501,7 @@ function parseEntryConfigDraft(
excludedModelsText: Array.isArray(record['excluded-models']) excludedModelsText: Array.isArray(record['excluded-models'])
? (record['excluded-models'] as string[]).join('\n') ? (record['excluded-models'] as string[]).join('\n')
: '', : '',
modelAliasesText: Array.isArray(record.models) modelAliasesText: formatRawConfigModelArray(record.models),
? (record.models as Array<{ name?: string; alias?: string }>)
.map((item) =>
item.alias?.trim()
? `${item.name?.trim() || ''}=${item.alias.trim()}`
: item.name?.trim() || ''
)
.filter(Boolean)
.join('\n')
: '',
apiKey: apiKey:
typeof record['api-key'] === 'string' && record['api-key'] !== STORED_SECRET_PLACEHOLDER typeof record['api-key'] === 'string' && record['api-key'] !== STORED_SECRET_PLACEHOLDER
? record['api-key'] ? record['api-key']
@@ -690,6 +679,10 @@ function EntryInspector({
() => parseModelAliasLines(draft.modelAliasesText), () => parseModelAliasLines(draft.modelAliasesText),
[draft.modelAliasesText] [draft.modelAliasesText]
); );
const modelRuleErrors = useMemo(
() => getRequestedUpstreamModelRuleErrors(draft.modelAliasesText),
[draft.modelAliasesText]
);
const headerRules = useMemo(() => parseKeyValueLines(draft.headersText), [draft.headersText]); const headerRules = useMemo(() => parseKeyValueLines(draft.headersText), [draft.headersText]);
const excludedModelRules = useMemo( const excludedModelRules = useMemo(
() => parseDelimitedLines(draft.excludedModelsText), () => parseDelimitedLines(draft.excludedModelsText),
@@ -741,7 +734,11 @@ function EntryInspector({
entry.secretConfigured, entry.secretConfigured,
family.id, family.id,
]); ]);
const canSave = isRawJsonValid && missingRequiredFields.length === 0 && hasChanges; const canSave =
isRawJsonValid &&
missingRequiredFields.length === 0 &&
modelRuleErrors.length === 0 &&
hasChanges;
const updateDraft = (updater: (current: EntryEditorDraft) => EntryEditorDraft) => { const updateDraft = (updater: (current: EntryEditorDraft) => EntryEditorDraft) => {
setDraft((current) => updater(current)); setDraft((current) => updater(current));
@@ -1079,6 +1076,11 @@ function EntryInspector({
rows={6} rows={6}
/> />
</EntryEditorField> </EntryEditorField>
{modelRuleErrors.length > 0 ? (
<div className="mt-3 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{modelRuleErrors[0]}
</div>
) : null}
</div> </div>
<div className="rounded-xl border bg-background p-4"> <div className="rounded-xl border bg-background p-4">
@@ -0,0 +1,41 @@
import { describe, expect, it, vi } from 'vitest';
import { ProviderEntryCard } from '@/components/cliproxy/ai-providers/provider-entry-card';
import { render, screen } from '../../../setup/test-utils';
describe('ProviderEntryCard', () => {
it('renders mapped models as requested to upstream', () => {
render(
<ProviderEntryCard
family={{
id: 'openai-compatibility',
displayName: 'OpenAI-Compatible',
description: 'Connectors',
authMode: 'connector',
routePath: '/api/provider/openai-compat',
status: 'ready',
supportsNamedEntries: true,
entries: [],
}}
entry={{
id: 'openai-compatibility:0',
index: 0,
name: 'openrouter',
label: 'openrouter',
baseUrl: 'https://openrouter.ai/api/v1',
headers: [],
excludedModels: [],
models: [{ name: 'gpt-5', alias: 'claude-sonnet-4-5' }],
apiKeysMasked: ['...1234'],
secretConfigured: true,
}}
onEdit={vi.fn()}
onDelete={vi.fn()}
/>
);
expect(screen.getByText('claude-sonnet-4-5')).toBeInTheDocument();
expect(screen.getByText('gpt-5')).toBeInTheDocument();
expect(screen.getByText('→')).toBeInTheDocument();
});
});
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import { import {
formatRequestedUpstreamModelRules, formatRequestedUpstreamModelRules,
getRequestedUpstreamModelRuleErrors,
getRequestedModelId, getRequestedModelId,
parseRequestedUpstreamModelRules, parseRequestedUpstreamModelRules,
} from '@/lib/provider-config'; } from '@/lib/provider-config';
@@ -33,4 +34,11 @@ describe('provider model mapping helpers', () => {
'minimax/minimax-m2.7' 'minimax/minimax-m2.7'
); );
}); });
it('rejects malformed requested=upstream lines instead of coercing them', () => {
expect(getRequestedUpstreamModelRuleErrors('claude-sonnet-4-5=\n=gpt-5\nqwen3-coder')).toEqual([
'Line 1: use requested=upstream or a plain model name.',
'Line 2: use requested=upstream or a plain model name.',
]);
});
}); });