mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-17 22:14:08 +00:00
fix(cliproxy): harden routing prefix sync and UI pinning
This commit is contained in:
1 parent
b8a8f84153
commit
0c10cb6f47
13 files changed
+260
-59
No files matched your search
@@ -20,6 +20,25 @@ import { FlexibleModelSelector } from '../provider-model-selector';
|
||||
import type { CustomPresetDialogProps, ModelMappingValues } from './types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function normalizePresetValues(
|
||||
values: ModelMappingValues,
|
||||
routing: CustomPresetDialogProps['routing']
|
||||
): ModelMappingValues {
|
||||
const toPreferredModelId = (modelId: string): string => {
|
||||
const hint = routing?.models.find(
|
||||
(entry) => entry.modelId.toLowerCase() === modelId.toLowerCase()
|
||||
);
|
||||
return hint?.pinnedAvailable ? hint.recommendedModelId : modelId;
|
||||
};
|
||||
|
||||
return {
|
||||
default: toPreferredModelId(values.default),
|
||||
opus: toPreferredModelId(values.opus),
|
||||
sonnet: toPreferredModelId(values.sonnet),
|
||||
haiku: toPreferredModelId(values.haiku),
|
||||
};
|
||||
}
|
||||
|
||||
export function CustomPresetDialog({
|
||||
open,
|
||||
onClose,
|
||||
@@ -32,13 +51,15 @@ export function CustomPresetDialog({
|
||||
routing,
|
||||
}: CustomPresetDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [values, setValues] = useState<ModelMappingValues>(currentValues);
|
||||
const [values, setValues] = useState<ModelMappingValues>(
|
||||
normalizePresetValues(currentValues, routing)
|
||||
);
|
||||
const [presetName, setPresetName] = useState('');
|
||||
|
||||
// Reset values when dialog opens with current values
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
if (isOpen) {
|
||||
setValues(currentValues);
|
||||
setValues(normalizePresetValues(currentValues, routing));
|
||||
setPresetName('');
|
||||
} else {
|
||||
onClose();
|
||||
|
||||
@@ -53,13 +53,16 @@ export function ModelConfigSection({
|
||||
onDeletePreset,
|
||||
isDeletePending,
|
||||
}: ModelConfigSectionProps) {
|
||||
const pinningReady = (routing?.models ?? []).some((hint) => hint.pinnedAvailable);
|
||||
const routingHintMap = useMemo(
|
||||
() =>
|
||||
new Map((routing?.models ?? []).map((hint) => [hint.modelId.toLowerCase(), hint] as const)),
|
||||
[routing]
|
||||
);
|
||||
const toPreferredModelId = (modelId: string): string =>
|
||||
routingHintMap.get(modelId.toLowerCase())?.recommendedModelId ?? modelId;
|
||||
routingHintMap.get(modelId.toLowerCase())?.pinnedAvailable
|
||||
? (routingHintMap.get(modelId.toLowerCase())?.recommendedModelId ?? modelId)
|
||||
: modelId;
|
||||
|
||||
const extendedContextModels = useMemo(() => {
|
||||
if (!catalog) return [];
|
||||
@@ -160,10 +163,10 @@ export function ModelConfigSection({
|
||||
className="text-xs h-7 gap-1 pr-6"
|
||||
onClick={() => {
|
||||
onApplyPreset({
|
||||
ANTHROPIC_MODEL: preset.default,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: preset.opus,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: preset.sonnet,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: preset.haiku,
|
||||
ANTHROPIC_MODEL: toPreferredModelId(preset.default),
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: toPreferredModelId(preset.opus),
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: toPreferredModelId(preset.sonnet),
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: toPreferredModelId(preset.haiku),
|
||||
});
|
||||
}}
|
||||
>
|
||||
@@ -209,8 +212,17 @@ export function ModelConfigSection({
|
||||
</p>
|
||||
{routing ? (
|
||||
<p className="text-[11px] text-muted-foreground mb-3 rounded-md border bg-muted/30 px-2.5 py-2">
|
||||
Preferred pinned model names use the <code>{routing.prefix}/</code> prefix. Unprefixed
|
||||
names can still resolve to a different backend when providers overlap.
|
||||
{pinningReady ? (
|
||||
<>
|
||||
Preferred pinned model names use the <code>{routing.prefix}/</code> prefix.
|
||||
Unprefixed names can still resolve to a different backend when providers overlap.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Managed pinning for <code>{routing.prefix}/</code> is not currently advertised by
|
||||
the proxy. Unprefixed names may still be ambiguous until prefix repair completes.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
{provider === 'codex' && (
|
||||
|
||||
@@ -306,6 +306,16 @@ function normalizeModelValue(
|
||||
return value.startsWith(prefix) ? value.slice(prefix.length) : value;
|
||||
}
|
||||
|
||||
function getPreferredOptionValue(
|
||||
modelId: string,
|
||||
routingHint: CliproxyProviderRoutingHints['models'][number] | undefined
|
||||
): string {
|
||||
if (!routingHint?.pinnedAvailable) {
|
||||
return modelId;
|
||||
}
|
||||
return routingHint.recommendedModelId;
|
||||
}
|
||||
|
||||
export function FlexibleModelSelector({
|
||||
label,
|
||||
description,
|
||||
@@ -334,16 +344,16 @@ export function FlexibleModelSelector({
|
||||
);
|
||||
|
||||
const recommendedOptions = resolvedCatalogModels.map((model) => ({
|
||||
value: routingHints.get(model.id.toLowerCase())?.recommendedModelId ?? model.id,
|
||||
value: getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase())),
|
||||
groupKey: 'recommended',
|
||||
searchText: `${model.id} ${model.name} ${routingHints.get(model.id.toLowerCase())?.recommendedModelId ?? ''}`,
|
||||
keywords: [model.tier ?? '', catalog?.provider ?? ''],
|
||||
triggerContent: (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-mono text-xs">
|
||||
{routingHints.get(model.id.toLowerCase())?.recommendedModelId ?? model.id}
|
||||
{getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))}
|
||||
</span>
|
||||
{routingHints.get(model.id.toLowerCase()) ? (
|
||||
{routingHints.get(model.id.toLowerCase())?.pinnedAvailable ? (
|
||||
<Badge variant="secondary" className="text-[9px] h-4 px-1 uppercase">
|
||||
{routingHints.get(model.id.toLowerCase())?.prefix}
|
||||
</Badge>
|
||||
@@ -354,7 +364,7 @@ export function FlexibleModelSelector({
|
||||
itemContent: (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-mono text-xs">
|
||||
{routingHints.get(model.id.toLowerCase())?.recommendedModelId ?? model.id}
|
||||
{getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))}
|
||||
</span>
|
||||
{model.tier === 'paid' && <PaidBadge label={t('providerModelSelector.paid')} />}
|
||||
{routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'shadowed' ? (
|
||||
@@ -375,19 +385,38 @@ export function FlexibleModelSelector({
|
||||
const allModelOptions = allModels
|
||||
.filter((model) => !catalogModelIds.has(model.id))
|
||||
.map((model) => ({
|
||||
value: model.id,
|
||||
value: getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase())),
|
||||
groupKey: 'all',
|
||||
searchText: model.id,
|
||||
searchText: `${model.id} ${routingHints.get(model.id.toLowerCase())?.recommendedModelId ?? ''}`,
|
||||
keywords: [model.owned_by],
|
||||
triggerContent: (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-mono text-xs">{model.id}</span>
|
||||
<span className="truncate font-mono text-xs">
|
||||
{getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))}
|
||||
</span>
|
||||
{routingHints.get(model.id.toLowerCase())?.pinnedAvailable ? (
|
||||
<Badge variant="secondary" className="text-[9px] h-4 px-1 uppercase">
|
||||
{routingHints.get(model.id.toLowerCase())?.prefix}
|
||||
</Badge>
|
||||
) : null}
|
||||
{isCodexProvider && <CodexEffortBadge modelId={model.id} />}
|
||||
</div>
|
||||
),
|
||||
itemContent: (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-mono text-xs">{model.id}</span>
|
||||
<span className="truncate font-mono text-xs">
|
||||
{getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))}
|
||||
</span>
|
||||
{routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'shadowed' ? (
|
||||
<Badge variant="outline" className="text-[9px] h-4 px-1">
|
||||
Shadowed
|
||||
</Badge>
|
||||
) : null}
|
||||
{routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'prefix-only' ? (
|
||||
<Badge variant="outline" className="text-[9px] h-4 px-1">
|
||||
Prefix only
|
||||
</Badge>
|
||||
) : null}
|
||||
{isCodexProvider && <CodexEffortBadge modelId={model.id} />}
|
||||
</div>
|
||||
),
|
||||
@@ -479,14 +508,21 @@ export function FlexibleModelSelector({
|
||||
)}
|
||||
>
|
||||
<div className="font-medium">
|
||||
Preferred pinned model: <code>{selectedRoutingHint.recommendedModelId}</code>
|
||||
{selectedRoutingHint.pinnedAvailable
|
||||
? 'Preferred pinned model:'
|
||||
: 'Pinned route status:'}{' '}
|
||||
<code>
|
||||
{selectedRoutingHint.pinnedAvailable
|
||||
? selectedRoutingHint.recommendedModelId
|
||||
: selectedRoutingHint.pinnedModelId}
|
||||
</code>
|
||||
</div>
|
||||
<p className="mt-1 leading-5">{selectedRoutingHint.summary}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{value && !selectedRoutingHint && normalizeModelValue(value, routing) !== value ? (
|
||||
<div className="rounded-md border border-border/70 bg-muted/25 px-2.5 py-2 text-[11px] text-muted-foreground">
|
||||
Using pinned model: <code>{value}</code>
|
||||
<div className="rounded-md border border-amber-300/60 bg-amber-50 px-2.5 py-2 text-[11px] text-amber-900 dark:border-amber-500/30 dark:bg-amber-950/25 dark:text-amber-100">
|
||||
Pinned model is not currently advertised by the proxy: <code>{value}</code>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -474,6 +474,7 @@ export interface CliproxyModelRoutingHint {
|
||||
prefix: string;
|
||||
pinnedModelId: string;
|
||||
recommendedModelId: string;
|
||||
pinnedAvailable: boolean;
|
||||
unprefixedStatus: 'safe' | 'shadowed' | 'prefix-only';
|
||||
effectiveProvider: string | null;
|
||||
effectiveDisplayName: string | null;
|
||||
|
||||
@@ -137,6 +137,7 @@ describe('ModelConfigSection presets', () => {
|
||||
prefix: 'gcli',
|
||||
pinnedModelId: 'gcli/gemini-3.1-pro-preview',
|
||||
recommendedModelId: 'gcli/gemini-3.1-pro-preview',
|
||||
pinnedAvailable: true,
|
||||
unprefixedStatus: 'shadowed',
|
||||
effectiveProvider: 'agy',
|
||||
effectiveDisplayName: 'Antigravity',
|
||||
@@ -149,6 +150,7 @@ describe('ModelConfigSection presets', () => {
|
||||
prefix: 'gcli',
|
||||
pinnedModelId: 'gcli/gemini-3-flash-preview',
|
||||
recommendedModelId: 'gcli/gemini-3-flash-preview',
|
||||
pinnedAvailable: true,
|
||||
unprefixedStatus: 'shadowed',
|
||||
effectiveProvider: 'agy',
|
||||
effectiveDisplayName: 'Antigravity',
|
||||
@@ -175,4 +177,79 @@ describe('ModelConfigSection presets', () => {
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gcli/gemini-3-flash-preview',
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes saved presets through preferred pinned model ids when live pinning is available', async () => {
|
||||
const onApplyPreset = vi.fn();
|
||||
|
||||
render(
|
||||
<ModelConfigSection
|
||||
catalog={MODEL_CATALOGS.gemini}
|
||||
savedPresets={[
|
||||
{
|
||||
name: 'legacy',
|
||||
default: 'gemini-3-flash-preview',
|
||||
opus: 'gemini-3.1-pro-preview',
|
||||
sonnet: 'gemini-3.1-pro-preview',
|
||||
haiku: 'gemini-3-flash-preview',
|
||||
},
|
||||
]}
|
||||
currentModel="gemini-3-flash-preview"
|
||||
opusModel="gemini-3.1-pro-preview"
|
||||
sonnetModel="gemini-3.1-pro-preview"
|
||||
haikuModel="gemini-3-flash-preview"
|
||||
providerModels={[]}
|
||||
routing={{
|
||||
provider: 'gemini',
|
||||
displayName: 'Gemini',
|
||||
prefix: 'gcli',
|
||||
safeCount: 0,
|
||||
shadowedCount: 2,
|
||||
prefixOnlyCount: 0,
|
||||
models: [
|
||||
{
|
||||
modelId: 'gemini-3.1-pro-preview',
|
||||
modelName: 'Gemini Pro',
|
||||
prefix: 'gcli',
|
||||
pinnedModelId: 'gcli/gemini-3.1-pro-preview',
|
||||
recommendedModelId: 'gcli/gemini-3.1-pro-preview',
|
||||
pinnedAvailable: true,
|
||||
unprefixedStatus: 'shadowed',
|
||||
effectiveProvider: 'agy',
|
||||
effectiveDisplayName: 'Antigravity',
|
||||
effectiveOwnedBy: 'antigravity',
|
||||
summary: 'shadowed by Antigravity',
|
||||
},
|
||||
{
|
||||
modelId: 'gemini-3-flash-preview',
|
||||
modelName: 'Gemini Flash',
|
||||
prefix: 'gcli',
|
||||
pinnedModelId: 'gcli/gemini-3-flash-preview',
|
||||
recommendedModelId: 'gcli/gemini-3-flash-preview',
|
||||
pinnedAvailable: true,
|
||||
unprefixedStatus: 'shadowed',
|
||||
effectiveProvider: 'agy',
|
||||
effectiveDisplayName: 'Antigravity',
|
||||
effectiveOwnedBy: 'antigravity',
|
||||
summary: 'shadowed by Antigravity',
|
||||
},
|
||||
],
|
||||
}}
|
||||
provider="gemini"
|
||||
onExtendedContextToggle={vi.fn()}
|
||||
onApplyPreset={onApplyPreset}
|
||||
onUpdateEnvValue={vi.fn()}
|
||||
onOpenCustomPreset={vi.fn()}
|
||||
onDeletePreset={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'legacy' }));
|
||||
|
||||
expect(onApplyPreset).toHaveBeenCalledWith({
|
||||
ANTHROPIC_MODEL: 'gcli/gemini-3-flash-preview',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gcli/gemini-3.1-pro-preview',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gcli/gemini-3.1-pro-preview',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gcli/gemini-3-flash-preview',
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user