mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 14:16:43 +00:00
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:
@@ -1,5 +1,6 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { getRequestedModelId } from '@/lib/provider-config';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type {
|
||||
AiProviderEntryView,
|
||||
@@ -241,9 +242,13 @@ export function ProviderEntryCard({
|
||||
key={`${model.name}:${model.alias}`}
|
||||
className="rounded-md border bg-muted/20 px-3 py-2"
|
||||
>
|
||||
<span className="font-medium">{model.name}</span>
|
||||
<span className="mx-2 text-muted-foreground">→</span>
|
||||
<span className="text-muted-foreground">{model.alias}</span>
|
||||
<span className="font-medium">{getRequestedModelId(model)}</span>
|
||||
{model.alias.trim() ? (
|
||||
<>
|
||||
<span className="mx-2 text-muted-foreground">→</span>
|
||||
<span className="text-muted-foreground">{model.name}</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ProviderLogo } from '@/components/cliproxy/provider-logo';
|
||||
import {
|
||||
formatRequestedUpstreamModelRules,
|
||||
getAiProviderFamilyVisual,
|
||||
getRequestedUpstreamModelRuleErrors,
|
||||
parseRequestedUpstreamModelRules,
|
||||
} from '@/lib/provider-config';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -284,6 +285,10 @@ export function ProviderEntryDialog({
|
||||
const [headers, setHeaders] = useState(() => formatHeaders(entry));
|
||||
const [excludedModels, setExcludedModels] = useState(() => formatExcludedModels(entry));
|
||||
const [modelAliases, setModelAliases] = useState(() => formatModelAliases(entry));
|
||||
const modelRuleErrors = useMemo(
|
||||
() => getRequestedUpstreamModelRuleErrors(modelAliases),
|
||||
[modelAliases]
|
||||
);
|
||||
const [advancedOpen, setAdvancedOpen] = useState(() =>
|
||||
Boolean(
|
||||
entry?.headers.length || entry?.excludedModels.length || entry?.proxyUrl || entry?.prefix
|
||||
@@ -298,6 +303,10 @@ export function ProviderEntryDialog({
|
||||
}, [entry?.secretConfigured, isEditing, supportsOpenAiCompat]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (modelRuleErrors.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextApiKey = apiKey.trim();
|
||||
const nextApiKeys = parseDelimitedLines(apiKeys);
|
||||
const preserveSecrets =
|
||||
@@ -459,6 +468,9 @@ export function ProviderEntryDialog({
|
||||
placeholder={guide.aliasesPlaceholder}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{guide.aliasesHelper}</p>
|
||||
{modelRuleErrors.length > 0 ? (
|
||||
<p className="text-xs text-destructive">{modelRuleErrors[0]}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -560,7 +572,11 @@ export function ProviderEntryDialog({
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" onClick={() => void handleSubmit()} disabled={isSaving}>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void handleSubmit()}
|
||||
disabled={isSaving || modelRuleErrors.length > 0}
|
||||
>
|
||||
{isSaving
|
||||
? 'Saving...'
|
||||
: supportsOpenAiCompat
|
||||
|
||||
@@ -144,6 +144,23 @@ export function parseRequestedUpstreamModelRules(value: string): AiProviderModel
|
||||
.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.
|
||||
*/
|
||||
|
||||
@@ -20,6 +20,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
formatRequestedUpstreamModelRules,
|
||||
getAiProviderFamilyVisual,
|
||||
getRequestedUpstreamModelRuleErrors,
|
||||
getRequestedModelId,
|
||||
parseRequestedUpstreamModelRules,
|
||||
} from '@/lib/provider-config';
|
||||
@@ -386,6 +387,12 @@ function buildRawConfigModelArray(value: string) {
|
||||
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) {
|
||||
const parsed = parseDelimitedLines(value);
|
||||
return parsed.length > 0 ? parsed : undefined;
|
||||
@@ -472,16 +479,7 @@ function parseEntryConfigDraft(
|
||||
.join('\n')
|
||||
: '',
|
||||
excludedModelsText: '',
|
||||
modelAliasesText: Array.isArray(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')
|
||||
: '',
|
||||
modelAliasesText: formatRawConfigModelArray(record.models),
|
||||
apiKey: '',
|
||||
apiKeysText: apiKeys.join('\n'),
|
||||
};
|
||||
@@ -503,16 +501,7 @@ function parseEntryConfigDraft(
|
||||
excludedModelsText: Array.isArray(record['excluded-models'])
|
||||
? (record['excluded-models'] as string[]).join('\n')
|
||||
: '',
|
||||
modelAliasesText: Array.isArray(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')
|
||||
: '',
|
||||
modelAliasesText: formatRawConfigModelArray(record.models),
|
||||
apiKey:
|
||||
typeof record['api-key'] === 'string' && record['api-key'] !== STORED_SECRET_PLACEHOLDER
|
||||
? record['api-key']
|
||||
@@ -690,6 +679,10 @@ function EntryInspector({
|
||||
() => parseModelAliasLines(draft.modelAliasesText),
|
||||
[draft.modelAliasesText]
|
||||
);
|
||||
const modelRuleErrors = useMemo(
|
||||
() => getRequestedUpstreamModelRuleErrors(draft.modelAliasesText),
|
||||
[draft.modelAliasesText]
|
||||
);
|
||||
const headerRules = useMemo(() => parseKeyValueLines(draft.headersText), [draft.headersText]);
|
||||
const excludedModelRules = useMemo(
|
||||
() => parseDelimitedLines(draft.excludedModelsText),
|
||||
@@ -741,7 +734,11 @@ function EntryInspector({
|
||||
entry.secretConfigured,
|
||||
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) => {
|
||||
setDraft((current) => updater(current));
|
||||
@@ -1079,6 +1076,11 @@ function EntryInspector({
|
||||
rows={6}
|
||||
/>
|
||||
</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 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 {
|
||||
formatRequestedUpstreamModelRules,
|
||||
getRequestedUpstreamModelRuleErrors,
|
||||
getRequestedModelId,
|
||||
parseRequestedUpstreamModelRules,
|
||||
} from '@/lib/provider-config';
|
||||
@@ -33,4 +34,11 @@ describe('provider model mapping helpers', () => {
|
||||
'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.',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user