From 49c4338f28f5b22ea8834d103582eccd2ad54c62 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 10 Apr 2026 17:28:18 -0400 Subject: [PATCH 1/4] fix(ui): preserve requested provider model aliases - keep requested=upstream semantics stable in the AI Providers editors - preview the client-visible ANTHROPIC_MODEL instead of the upstream target - add UI coverage for alias parsing and formatting helpers Refs #941 --- .../ai-providers/provider-entry-dialog.tsx | 27 +++------- ui/src/lib/provider-config.ts | 54 ++++++++++++++++++- ui/src/pages/cliproxy-ai-providers.tsx | 30 ++++------- ui/tests/unit/ui/lib/provider-config.test.ts | 36 +++++++++++++ 4 files changed, 105 insertions(+), 42 deletions(-) create mode 100644 ui/tests/unit/ui/lib/provider-config.test.ts diff --git a/ui/src/components/cliproxy/ai-providers/provider-entry-dialog.tsx b/ui/src/components/cliproxy/ai-providers/provider-entry-dialog.tsx index d9d602d7..e6b54c0b 100644 --- a/ui/src/components/cliproxy/ai-providers/provider-entry-dialog.tsx +++ b/ui/src/components/cliproxy/ai-providers/provider-entry-dialog.tsx @@ -1,6 +1,10 @@ import { useMemo, useState } from 'react'; import { ProviderLogo } from '@/components/cliproxy/provider-logo'; -import { getAiProviderFamilyVisual } from '@/lib/provider-config'; +import { + formatRequestedUpstreamModelRules, + getAiProviderFamilyVisual, + parseRequestedUpstreamModelRules, +} from '@/lib/provider-config'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; @@ -192,22 +196,7 @@ function parseKeyValueLines(value: string): Array<{ key: string; value: string } } function parseModelAliasLines(value: string) { - return value - .split('\n') - .map((line) => line.trim()) - .filter((line) => line.length > 0) - .map((line) => { - const separatorIndex = line.indexOf('='); - if (separatorIndex === -1) { - return { name: line.trim(), alias: '' }; - } - - return { - name: line.slice(0, separatorIndex).trim(), - alias: line.slice(separatorIndex + 1).trim(), - }; - }) - .filter((item) => item.name.length > 0 || item.alias.length > 0); + return parseRequestedUpstreamModelRules(value); } function TextArea({ @@ -241,9 +230,7 @@ function formatExcludedModels(entry?: AiProviderEntryView | null): string { } function formatModelAliases(entry?: AiProviderEntryView | null): string { - return (entry?.models || []) - .map((item) => (item.alias.trim() ? `${item.name}=${item.alias}` : item.name)) - .join('\n'); + return formatRequestedUpstreamModelRules(entry?.models); } function ChecklistCard({ diff --git a/ui/src/lib/provider-config.ts b/ui/src/lib/provider-config.ts index 516b85e8..554441f7 100644 --- a/ui/src/lib/provider-config.ts +++ b/ui/src/lib/provider-config.ts @@ -9,7 +9,7 @@ import { PROVIDER_CAPABILITIES, getProvidersByOAuthFlow, } from '../../../src/cliproxy/provider-capabilities'; -import type { AiProviderFamilyId } from '../../../src/cliproxy/ai-providers'; +import type { AiProviderFamilyId, AiProviderModelAlias } from '../../../src/cliproxy/ai-providers'; // Monorepo contract: UI consumes provider capability constants directly from backend // to enforce one source of truth and prevent provider drift across surfaces. @@ -115,6 +115,58 @@ export function getAiProviderFamilyVisual(familyId: AiProviderFamilyId): Provide } } +/** + * Parse UI model rules that use the requested=upstream convention into the + * provider config shape where `name` is upstream and `alias` is client-visible. + */ +export function parseRequestedUpstreamModelRules(value: string): AiProviderModelAlias[] { + return value + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => { + const separatorIndex = line.indexOf('='); + if (separatorIndex === -1) { + return { name: line.trim(), alias: '' }; + } + + const requested = line.slice(0, separatorIndex).trim(); + const upstream = line.slice(separatorIndex + 1).trim(); + if (!upstream) { + return { name: requested, alias: '' }; + } + + return { + name: upstream, + alias: requested, + }; + }) + .filter((item) => item.name.length > 0 || item.alias.length > 0); +} + +/** + * Format stored provider config back into the UI-facing requested=upstream form. + */ +export function formatRequestedUpstreamModelRules( + models: AiProviderModelAlias[] | null | undefined +): string { + return (models || []) + .map((item) => { + const requested = item.alias.trim(); + const upstream = item.name.trim(); + return requested ? `${requested}=${upstream}` : upstream; + }) + .join('\n'); +} + +/** + * Return the client-visible model ID for previews and generated settings. + */ +export function getRequestedModelId(model: AiProviderModelAlias): string { + const requested = model.alias.trim(); + return requested || model.name.trim(); +} + export function getProviderLogoAsset(provider: unknown): string | undefined { const normalized = normalizeProviderInput(provider); if (!isProviderVisualId(normalized)) { diff --git a/ui/src/pages/cliproxy-ai-providers.tsx b/ui/src/pages/cliproxy-ai-providers.tsx index b7a8c79e..300e35bb 100644 --- a/ui/src/pages/cliproxy-ai-providers.tsx +++ b/ui/src/pages/cliproxy-ai-providers.tsx @@ -17,7 +17,12 @@ import { Input } from '@/components/ui/input'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Skeleton } from '@/components/ui/skeleton'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { getAiProviderFamilyVisual } from '@/lib/provider-config'; +import { + formatRequestedUpstreamModelRules, + getAiProviderFamilyVisual, + getRequestedModelId, + parseRequestedUpstreamModelRules, +} from '@/lib/provider-config'; import { cn } from '@/lib/utils'; import { FamilyRail, ProviderEntryDialog } from '@/components/cliproxy/ai-providers'; import { @@ -339,22 +344,7 @@ function parseKeyValueLines(value: string): Array<{ key: string; value: string } } function parseModelAliasLines(value: string) { - return value - .split('\n') - .map((line) => line.trim()) - .filter((line) => line.length > 0) - .map((line) => { - const separatorIndex = line.indexOf('='); - if (separatorIndex === -1) { - return { name: line.trim(), alias: '' }; - } - - return { - name: line.slice(0, separatorIndex).trim(), - alias: line.slice(separatorIndex + 1).trim(), - }; - }) - .filter((item) => item.name.length > 0 || item.alias.length > 0); + return parseRequestedUpstreamModelRules(value); } function formatHeaders(entry?: AiProviderEntryView | null): string { @@ -366,9 +356,7 @@ function formatExcludedModels(entry?: AiProviderEntryView | null): string { } function formatModelAliases(entry?: AiProviderEntryView | null): string { - return (entry?.models || []) - .map((item) => (item.alias.trim() ? `${item.name}=${item.alias}` : item.name)) - .join('\n'); + return formatRequestedUpstreamModelRules(entry?.models); } function buildEntryEditorDraft(entry: AiProviderEntryView): EntryEditorDraft { @@ -579,7 +567,7 @@ function buildSettingsPreview( item.name.trim() ); if (primaryModel?.name) { - env.ANTHROPIC_MODEL = primaryModel.name; + env.ANTHROPIC_MODEL = getRequestedModelId(primaryModel); } return { env }; diff --git a/ui/tests/unit/ui/lib/provider-config.test.ts b/ui/tests/unit/ui/lib/provider-config.test.ts new file mode 100644 index 00000000..21514da1 --- /dev/null +++ b/ui/tests/unit/ui/lib/provider-config.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; + +import { + formatRequestedUpstreamModelRules, + getRequestedModelId, + parseRequestedUpstreamModelRules, +} from '@/lib/provider-config'; + +describe('provider model mapping helpers', () => { + it('parses requested=upstream rules into stored upstream+alias pairs', () => { + expect( + parseRequestedUpstreamModelRules('claude-sonnet-4-5=gpt-4.1\nminimax/minimax-m2.7') + ).toEqual([ + { name: 'gpt-4.1', alias: 'claude-sonnet-4-5' }, + { name: 'minimax/minimax-m2.7', alias: '' }, + ]); + }); + + it('formats stored model rules back into requested=upstream text', () => { + expect( + formatRequestedUpstreamModelRules([ + { name: 'gpt-4.1', alias: 'claude-sonnet-4-5' }, + { name: 'minimax/minimax-m2.7', alias: '' }, + ]) + ).toBe('claude-sonnet-4-5=gpt-4.1\nminimax/minimax-m2.7'); + }); + + it('prefers the requested alias for generated settings previews', () => { + expect(getRequestedModelId({ name: 'gpt-4.1', alias: 'claude-sonnet-4-5' })).toBe( + 'claude-sonnet-4-5' + ); + expect(getRequestedModelId({ name: 'minimax/minimax-m2.7', alias: '' })).toBe( + 'minimax/minimax-m2.7' + ); + }); +}); From 92a769d773a8ec63fd02744aff60d3691df7cf94 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 10 Apr 2026 17:44:15 -0400 Subject: [PATCH 2/4] 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 --- .../ai-providers/provider-entry-card.tsx | 11 +++-- .../ai-providers/provider-entry-dialog.tsx | 18 +++++++- ui/src/lib/provider-config.ts | 17 +++++++ ui/src/pages/cliproxy-ai-providers.tsx | 44 ++++++++++--------- .../cliproxy/provider-entry-card.test.tsx | 41 +++++++++++++++++ ui/tests/unit/ui/lib/provider-config.test.ts | 8 ++++ 6 files changed, 114 insertions(+), 25 deletions(-) create mode 100644 ui/tests/unit/components/cliproxy/provider-entry-card.test.tsx diff --git a/ui/src/components/cliproxy/ai-providers/provider-entry-card.tsx b/ui/src/components/cliproxy/ai-providers/provider-entry-card.tsx index 7457caae..941ac32b 100644 --- a/ui/src/components/cliproxy/ai-providers/provider-entry-card.tsx +++ b/ui/src/components/cliproxy/ai-providers/provider-entry-card.tsx @@ -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" > - {model.name} - - {model.alias} + {getRequestedModelId(model)} + {model.alias.trim() ? ( + <> + + {model.name} + + ) : null} ))} diff --git a/ui/src/components/cliproxy/ai-providers/provider-entry-dialog.tsx b/ui/src/components/cliproxy/ai-providers/provider-entry-dialog.tsx index e6b54c0b..d3dfc74c 100644 --- a/ui/src/components/cliproxy/ai-providers/provider-entry-dialog.tsx +++ b/ui/src/components/cliproxy/ai-providers/provider-entry-dialog.tsx @@ -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} />

{guide.aliasesHelper}

+ {modelRuleErrors.length > 0 ? ( +

{modelRuleErrors[0]}

+ ) : null} @@ -560,7 +572,11 @@ export function ProviderEntryDialog({ -