From 49c4338f28f5b22ea8834d103582eccd2ad54c62 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 10 Apr 2026 17:28:18 -0400 Subject: [PATCH] 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' + ); + }); +});