fix(ui): reflect cliproxy preset plan tiers

This commit is contained in:
Tam Nhu Tran
2026-03-18 12:15:30 -04:00
parent 2114a4b96e
commit ef36ad4600
3 changed files with 198 additions and 73 deletions
@@ -5,6 +5,7 @@
import { useMemo } from 'react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { Sparkles, Zap, Star, X, Plus } from 'lucide-react';
import { FlexibleModelSelector } from '../provider-model-selector';
@@ -12,6 +13,24 @@ import { ExtendedContextToggle } from '../extended-context-toggle';
import { stripExtendedContextSuffix } from '@/lib/extended-context-utils';
import type { ModelConfigSectionProps } from './types';
type CatalogPresetModel = NonNullable<ModelConfigSectionProps['catalog']>['models'][number];
function getPresetUpdates(model: CatalogPresetModel): Record<string, string> {
const mapping = model.presetMapping || {
default: model.id,
opus: model.id,
sonnet: model.id,
haiku: model.id,
};
return {
ANTHROPIC_MODEL: mapping.default,
ANTHROPIC_DEFAULT_OPUS_MODEL: mapping.opus,
ANTHROPIC_DEFAULT_SONNET_MODEL: mapping.sonnet,
ANTHROPIC_DEFAULT_HAIKU_MODEL: mapping.haiku,
};
}
export function ModelConfigSection({
catalog,
savedPresets,
@@ -29,8 +48,6 @@ export function ModelConfigSection({
onDeletePreset,
isDeletePending,
}: ModelConfigSectionProps) {
const showPresets = (catalog && catalog.models.length > 0) || savedPresets.length > 0;
// Find current model entry to check for extended context support
// Strip [1m] suffix when looking up in catalog since catalog IDs don't have suffix
const currentModelEntry = useMemo(() => {
@@ -39,6 +56,37 @@ export function ModelConfigSection({
return catalog.models.find((m) => m.id === baseModelId);
}, [catalog, currentModel]);
const presetGroups = useMemo(() => {
const presetModels = (catalog?.models ?? []).filter((model) => model.presetMapping);
if (presetModels.length === 0) return [];
const hasPaidPresets = presetModels.some((model) => model.tier === 'paid');
if (!hasPaidPresets) {
return [{ key: 'default', models: presetModels.slice(0, 4) }];
}
return [
{
key: 'free',
label: 'Free Tier',
description: 'Available on free or paid plans',
badgeClassName: 'text-[10px] bg-green-100 text-green-700 border-green-200',
iconClassName: 'text-green-600',
models: presetModels.filter((model) => model.tier !== 'paid'),
},
{
key: 'paid',
label: 'Paid Tier',
description: 'Requires paid access',
badgeClassName: 'text-[10px] bg-amber-100 text-amber-700 border-amber-200',
iconClassName: 'text-amber-700',
models: presetModels.filter((model) => model.tier === 'paid'),
},
].filter((group) => group.models.length > 0);
}, [catalog]);
const showPresets = presetGroups.length > 0 || savedPresets.length > 0;
return (
<>
{/* Quick Presets */}
@@ -49,77 +97,81 @@ export function ModelConfigSection({
Presets
</h3>
<p className="text-xs text-muted-foreground mb-3">Apply pre-configured model mappings</p>
<div className="flex flex-wrap gap-2">
{/* Recommended presets from catalog */}
{catalog?.models.slice(0, 4).map((model) => (
<Button
key={model.id}
variant="outline"
size="sm"
className="text-xs h-7 gap-1"
onClick={() => {
const mapping = model.presetMapping || {
default: model.id,
opus: model.id,
sonnet: model.id,
haiku: model.id,
};
onApplyPreset({
ANTHROPIC_MODEL: mapping.default,
ANTHROPIC_DEFAULT_OPUS_MODEL: mapping.opus,
ANTHROPIC_DEFAULT_SONNET_MODEL: mapping.sonnet,
ANTHROPIC_DEFAULT_HAIKU_MODEL: mapping.haiku,
});
}}
>
<Zap className="w-3 h-3" />
{model.name}
</Button>
))}
{/* User saved presets */}
{savedPresets.map((preset) => (
<div key={preset.name} className="group relative">
<Button
variant="secondary"
size="sm"
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,
});
}}
>
<Star className="w-3 h-3 fill-current" />
{preset.name}
</Button>
<Button
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-7 w-5 opacity-0 group-hover:opacity-100 hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
onDeletePreset(preset.name);
}}
disabled={isDeletePending}
>
<X className="w-3 h-3" />
</Button>
<div className="space-y-4">
{presetGroups.map((group) => (
<div key={group.key}>
{'label' in group && group.label && (
<div className="flex items-center gap-2 mb-2">
<Badge variant="outline" className={group.badgeClassName}>
{group.label}
</Badge>
<span className="text-[10px] text-muted-foreground">{group.description}</span>
</div>
)}
<div className="flex flex-wrap gap-2">
{group.models.map((model) => (
<Button
key={model.id}
variant="outline"
size="sm"
className="text-xs h-7 gap-1"
onClick={() => onApplyPreset(getPresetUpdates(model))}
>
<Zap
className={`w-3 h-3 ${'iconClassName' in group ? group.iconClassName : ''}`}
/>
{model.name}
</Button>
))}
</div>
</div>
))}
<Button
variant="outline"
size="sm"
className="text-xs h-7 gap-1 border-primary/50 text-primary hover:bg-primary/10 hover:border-primary"
onClick={onOpenCustomPreset}
>
<Plus className="w-3 h-3" />
Custom
</Button>
<div className="flex flex-wrap gap-2">
{/* User saved presets */}
{savedPresets.map((preset) => (
<div key={preset.name} className="group relative">
<Button
variant="secondary"
size="sm"
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,
});
}}
>
<Star className="w-3 h-3 fill-current" />
{preset.name}
</Button>
<Button
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-7 w-5 opacity-0 group-hover:opacity-100 hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
onDeletePreset(preset.name);
}}
disabled={isDeletePending}
>
<X className="w-3 h-3" />
</Button>
</div>
))}
<Button
variant="outline"
size="sm"
className="text-xs h-7 gap-1 border-primary/50 text-primary hover:bg-primary/10 hover:border-primary"
onClick={onOpenCustomPreset}
>
<Plus className="w-3 h-3" />
Custom
</Button>
</div>
</div>
</div>
)}
@@ -0,0 +1,73 @@
import { describe, expect, it, vi } from 'vitest';
import { render, screen, userEvent } from '@tests/setup/test-utils';
vi.mock('@/components/cliproxy/provider-model-selector', () => ({
FlexibleModelSelector: () => <div data-testid="flexible-model-selector" />,
}));
vi.mock('@/components/cliproxy/extended-context-toggle', () => ({
ExtendedContextToggle: () => <div data-testid="extended-context-toggle" />,
}));
import { ModelConfigSection } from '@/components/cliproxy/provider-editor/model-config-section';
import { MODEL_CATALOGS } from '@/lib/model-catalogs';
describe('ModelConfigSection presets', () => {
it('groups codex presets by free and paid tiers', async () => {
const onApplyPreset = vi.fn();
render(
<ModelConfigSection
catalog={MODEL_CATALOGS.codex}
savedPresets={[]}
currentModel="gpt-5-codex"
opusModel="gpt-5-codex"
sonnetModel="gpt-5-codex"
haikuModel="gpt-5-codex-mini"
providerModels={[]}
provider="codex"
onApplyPreset={onApplyPreset}
onUpdateEnvValue={vi.fn()}
onOpenCustomPreset={vi.fn()}
onDeletePreset={vi.fn()}
/>
);
expect(screen.getByText('Free Tier')).toBeInTheDocument();
expect(screen.getByText('Paid Tier')).toBeInTheDocument();
expect(screen.getByText('Available on free or paid plans')).toBeInTheDocument();
expect(screen.getByText('Requires paid access')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'GPT-5.4' }));
expect(onApplyPreset).toHaveBeenCalledWith({
ANTHROPIC_MODEL: 'gpt-5.4',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.4',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.4',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5-codex-mini',
});
});
it('keeps non-tiered provider presets ungrouped', () => {
render(
<ModelConfigSection
catalog={MODEL_CATALOGS.agy}
savedPresets={[]}
currentModel="claude-opus-4-6-thinking"
opusModel="claude-opus-4-6-thinking"
sonnetModel="claude-sonnet-4-6"
haikuModel="claude-sonnet-4-6"
providerModels={[]}
provider="agy"
onApplyPreset={vi.fn()}
onUpdateEnvValue={vi.fn()}
onOpenCustomPreset={vi.fn()}
onDeletePreset={vi.fn()}
/>
);
expect(screen.queryByText('Free Tier')).not.toBeInTheDocument();
expect(screen.queryByText('Paid Tier')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Claude Opus 4.6 Thinking' })).toBeInTheDocument();
});
});
@@ -2,12 +2,12 @@ import { describe, expect, it } from 'vitest';
import { MODEL_CATALOGS } from '@/lib/model-catalogs';
describe('codex model catalog defaults', () => {
it('uses gpt-5.1-codex-mini as the haiku mapping for codex presets', () => {
it('uses gpt-5-codex-mini as the haiku mapping for cross-plan codex presets', () => {
const codexCatalog = MODEL_CATALOGS.codex;
const codex53 = codexCatalog.models.find((model) => model.id === 'gpt-5.3-codex');
const codex52 = codexCatalog.models.find((model) => model.id === 'gpt-5.2-codex');
expect(codex53?.presetMapping?.haiku).toBe('gpt-5.1-codex-mini');
expect(codex52?.presetMapping?.haiku).toBe('gpt-5.1-codex-mini');
expect(codex53?.presetMapping?.haiku).toBe('gpt-5-codex-mini');
expect(codex52?.presetMapping?.haiku).toBe('gpt-5-codex-mini');
});
});