Merge pull request #950 from kaitranntt/kai/fix/941-provider-model-alias-routing

fix: preserve requested provider model aliases in AI Providers
This commit is contained in:
Kai (Tam Nhu) Tran
2026-04-10 20:26:01 -04:00
committed by GitHub
6 changed files with 219 additions and 67 deletions
@@ -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>
@@ -1,6 +1,11 @@
import { useMemo, useState } from 'react';
import { ProviderLogo } from '@/components/cliproxy/provider-logo';
import { getAiProviderFamilyVisual } from '@/lib/provider-config';
import {
formatRequestedUpstreamModelRules,
getAiProviderFamilyVisual,
getRequestedUpstreamModelRuleErrors,
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 +197,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 +231,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({
@@ -297,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
@@ -311,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 =
@@ -472,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>
@@ -573,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
+70 -1
View File
@@ -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,75 @@ 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);
}
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.
*/
export function formatRequestedUpstreamModelRules(
models: Array<Partial<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)) {
+32 -42
View File
@@ -17,7 +17,13 @@ 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,
getRequestedUpstreamModelRuleErrors,
getRequestedModelId,
parseRequestedUpstreamModelRules,
} from '@/lib/provider-config';
import { cn } from '@/lib/utils';
import { FamilyRail, ProviderEntryDialog } from '@/components/cliproxy/ai-providers';
import {
@@ -339,22 +345,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 +357,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 {
@@ -398,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;
@@ -484,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'),
};
@@ -515,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']
@@ -579,7 +556,7 @@ function buildSettingsPreview(
item.name.trim()
);
if (primaryModel?.name) {
env.ANTHROPIC_MODEL = primaryModel.name;
env.ANTHROPIC_MODEL = getRequestedModelId(primaryModel);
}
return { env };
@@ -702,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),
@@ -753,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));
@@ -1091,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">