mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 16:19:27 +00:00
fix(ui): reflect cliproxy preset plan tiers
This commit is contained in:
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { Sparkles, Zap, Star, X, Plus } from 'lucide-react';
|
import { Sparkles, Zap, Star, X, Plus } from 'lucide-react';
|
||||||
import { FlexibleModelSelector } from '../provider-model-selector';
|
import { FlexibleModelSelector } from '../provider-model-selector';
|
||||||
@@ -12,6 +13,24 @@ import { ExtendedContextToggle } from '../extended-context-toggle';
|
|||||||
import { stripExtendedContextSuffix } from '@/lib/extended-context-utils';
|
import { stripExtendedContextSuffix } from '@/lib/extended-context-utils';
|
||||||
import type { ModelConfigSectionProps } from './types';
|
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({
|
export function ModelConfigSection({
|
||||||
catalog,
|
catalog,
|
||||||
savedPresets,
|
savedPresets,
|
||||||
@@ -29,8 +48,6 @@ export function ModelConfigSection({
|
|||||||
onDeletePreset,
|
onDeletePreset,
|
||||||
isDeletePending,
|
isDeletePending,
|
||||||
}: ModelConfigSectionProps) {
|
}: ModelConfigSectionProps) {
|
||||||
const showPresets = (catalog && catalog.models.length > 0) || savedPresets.length > 0;
|
|
||||||
|
|
||||||
// Find current model entry to check for extended context support
|
// 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
|
// Strip [1m] suffix when looking up in catalog since catalog IDs don't have suffix
|
||||||
const currentModelEntry = useMemo(() => {
|
const currentModelEntry = useMemo(() => {
|
||||||
@@ -39,6 +56,37 @@ export function ModelConfigSection({
|
|||||||
return catalog.models.find((m) => m.id === baseModelId);
|
return catalog.models.find((m) => m.id === baseModelId);
|
||||||
}, [catalog, currentModel]);
|
}, [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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Quick Presets */}
|
{/* Quick Presets */}
|
||||||
@@ -49,77 +97,81 @@ export function ModelConfigSection({
|
|||||||
Presets
|
Presets
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-muted-foreground mb-3">Apply pre-configured model mappings</p>
|
<p className="text-xs text-muted-foreground mb-3">Apply pre-configured model mappings</p>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="space-y-4">
|
||||||
{/* Recommended presets from catalog */}
|
{presetGroups.map((group) => (
|
||||||
{catalog?.models.slice(0, 4).map((model) => (
|
<div key={group.key}>
|
||||||
<Button
|
{'label' in group && group.label && (
|
||||||
key={model.id}
|
<div className="flex items-center gap-2 mb-2">
|
||||||
variant="outline"
|
<Badge variant="outline" className={group.badgeClassName}>
|
||||||
size="sm"
|
{group.label}
|
||||||
className="text-xs h-7 gap-1"
|
</Badge>
|
||||||
onClick={() => {
|
<span className="text-[10px] text-muted-foreground">{group.description}</span>
|
||||||
const mapping = model.presetMapping || {
|
</div>
|
||||||
default: model.id,
|
)}
|
||||||
opus: model.id,
|
<div className="flex flex-wrap gap-2">
|
||||||
sonnet: model.id,
|
{group.models.map((model) => (
|
||||||
haiku: model.id,
|
<Button
|
||||||
};
|
key={model.id}
|
||||||
onApplyPreset({
|
variant="outline"
|
||||||
ANTHROPIC_MODEL: mapping.default,
|
size="sm"
|
||||||
ANTHROPIC_DEFAULT_OPUS_MODEL: mapping.opus,
|
className="text-xs h-7 gap-1"
|
||||||
ANTHROPIC_DEFAULT_SONNET_MODEL: mapping.sonnet,
|
onClick={() => onApplyPreset(getPresetUpdates(model))}
|
||||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: mapping.haiku,
|
>
|
||||||
});
|
<Zap
|
||||||
}}
|
className={`w-3 h-3 ${'iconClassName' in group ? group.iconClassName : ''}`}
|
||||||
>
|
/>
|
||||||
<Zap className="w-3 h-3" />
|
{model.name}
|
||||||
{model.name}
|
</Button>
|
||||||
</Button>
|
))}
|
||||||
))}
|
</div>
|
||||||
|
|
||||||
{/* 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>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<Button
|
<div className="flex flex-wrap gap-2">
|
||||||
variant="outline"
|
{/* User saved presets */}
|
||||||
size="sm"
|
{savedPresets.map((preset) => (
|
||||||
className="text-xs h-7 gap-1 border-primary/50 text-primary hover:bg-primary/10 hover:border-primary"
|
<div key={preset.name} className="group relative">
|
||||||
onClick={onOpenCustomPreset}
|
<Button
|
||||||
>
|
variant="secondary"
|
||||||
<Plus className="w-3 h-3" />
|
size="sm"
|
||||||
Custom
|
className="text-xs h-7 gap-1 pr-6"
|
||||||
</Button>
|
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>
|
||||||
</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';
|
import { MODEL_CATALOGS } from '@/lib/model-catalogs';
|
||||||
|
|
||||||
describe('codex model catalog defaults', () => {
|
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 codexCatalog = MODEL_CATALOGS.codex;
|
||||||
const codex53 = codexCatalog.models.find((model) => model.id === 'gpt-5.3-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');
|
const codex52 = codexCatalog.models.find((model) => model.id === 'gpt-5.2-codex');
|
||||||
|
|
||||||
expect(codex53?.presetMapping?.haiku).toBe('gpt-5.1-codex-mini');
|
expect(codex53?.presetMapping?.haiku).toBe('gpt-5-codex-mini');
|
||||||
expect(codex52?.presetMapping?.haiku).toBe('gpt-5.1-codex-mini');
|
expect(codex52?.presetMapping?.haiku).toBe('gpt-5-codex-mini');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user