mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 16:14:31 +00:00
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:
6 files changed
+219
-67
No files matched your search
@@ -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>
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { ProviderLogo } from '@/components/cliproxy/provider-logo';
|
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 { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
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) {
|
function parseModelAliasLines(value: string) {
|
||||||
return value
|
return parseRequestedUpstreamModelRules(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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function TextArea({
|
function TextArea({
|
||||||
@@ -241,9 +231,7 @@ function formatExcludedModels(entry?: AiProviderEntryView | null): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatModelAliases(entry?: AiProviderEntryView | null): string {
|
function formatModelAliases(entry?: AiProviderEntryView | null): string {
|
||||||
return (entry?.models || [])
|
return formatRequestedUpstreamModelRules(entry?.models);
|
||||||
.map((item) => (item.alias.trim() ? `${item.name}=${item.alias}` : item.name))
|
|
||||||
.join('\n');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChecklistCard({
|
function ChecklistCard({
|
||||||
@@ -297,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
|
||||||
@@ -311,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 =
|
||||||
@@ -472,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>
|
||||||
|
|
||||||
@@ -573,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
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
PROVIDER_CAPABILITIES,
|
PROVIDER_CAPABILITIES,
|
||||||
getProvidersByOAuthFlow,
|
getProvidersByOAuthFlow,
|
||||||
} from '../../../src/cliproxy/provider-capabilities';
|
} 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
|
// Monorepo contract: UI consumes provider capability constants directly from backend
|
||||||
// to enforce one source of truth and prevent provider drift across surfaces.
|
// 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 {
|
export function getProviderLogoAsset(provider: unknown): string | undefined {
|
||||||
const normalized = normalizeProviderInput(provider);
|
const normalized = normalizeProviderInput(provider);
|
||||||
if (!isProviderVisualId(normalized)) {
|
if (!isProviderVisualId(normalized)) {
|
||||||
|
|||||||
@@ -17,7 +17,13 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
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 { cn } from '@/lib/utils';
|
||||||
import { FamilyRail, ProviderEntryDialog } from '@/components/cliproxy/ai-providers';
|
import { FamilyRail, ProviderEntryDialog } from '@/components/cliproxy/ai-providers';
|
||||||
import {
|
import {
|
||||||
@@ -339,22 +345,7 @@ function parseKeyValueLines(value: string): Array<{ key: string; value: string }
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseModelAliasLines(value: string) {
|
function parseModelAliasLines(value: string) {
|
||||||
return value
|
return parseRequestedUpstreamModelRules(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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatHeaders(entry?: AiProviderEntryView | null): string {
|
function formatHeaders(entry?: AiProviderEntryView | null): string {
|
||||||
@@ -366,9 +357,7 @@ function formatExcludedModels(entry?: AiProviderEntryView | null): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatModelAliases(entry?: AiProviderEntryView | null): string {
|
function formatModelAliases(entry?: AiProviderEntryView | null): string {
|
||||||
return (entry?.models || [])
|
return formatRequestedUpstreamModelRules(entry?.models);
|
||||||
.map((item) => (item.alias.trim() ? `${item.name}=${item.alias}` : item.name))
|
|
||||||
.join('\n');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildEntryEditorDraft(entry: AiProviderEntryView): EntryEditorDraft {
|
function buildEntryEditorDraft(entry: AiProviderEntryView): EntryEditorDraft {
|
||||||
@@ -398,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;
|
||||||
@@ -484,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'),
|
||||||
};
|
};
|
||||||
@@ -515,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']
|
||||||
@@ -579,7 +556,7 @@ function buildSettingsPreview(
|
|||||||
item.name.trim()
|
item.name.trim()
|
||||||
);
|
);
|
||||||
if (primaryModel?.name) {
|
if (primaryModel?.name) {
|
||||||
env.ANTHROPIC_MODEL = primaryModel.name;
|
env.ANTHROPIC_MODEL = getRequestedModelId(primaryModel);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { env };
|
return { env };
|
||||||
@@ -702,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),
|
||||||
@@ -753,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));
|
||||||
@@ -1091,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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
formatRequestedUpstreamModelRules,
|
||||||
|
getRequestedUpstreamModelRuleErrors,
|
||||||
|
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'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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