mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 06:16:37 +00:00
fix(ui): reflect cliproxy preset plan tiers
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user