Files
ccs/ui/src/components/cliproxy/provider-model-selector.tsx
T

531 lines
17 KiB
TypeScript

/**
* Provider Model Selector Component
* Per-provider model selection using model-catalog.ts
* Includes tier badges, broken/deprecated status indicators
*/
import { useMemo } from 'react';
import { AlertCircle, AlertTriangle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge';
import { SearchableSelect } from '@/components/ui/searchable-select';
import { Skeleton } from '@/components/ui/skeleton';
import type { CliproxyProviderRoutingHints } from '@/lib/api-client';
import { getCodexEffortDisplay } from '@/lib/codex-effort';
import { getResolvedCatalogModels } from '@/lib/model-catalogs';
import { cn } from '@/lib/utils';
/** Model entry from catalog */
export interface ModelEntry {
id: string;
name: string;
tier?: 'free' | 'paid';
description?: string;
broken?: boolean;
issueUrl?: string;
deprecated?: boolean;
deprecationReason?: string;
/** Whether model supports 1M extended context window */
extendedContext?: boolean;
/** Optional preset mapping for different tiers (if different from id) */
presetMapping?: {
default: string;
opus: string;
sonnet: string;
haiku: string;
};
}
/** Provider catalog */
export interface ProviderCatalog {
provider: string;
displayName: string;
models: ModelEntry[];
defaultModel: string;
}
interface ProviderModelSelectorProps {
/** Provider catalog data */
catalog: ProviderCatalog | undefined;
/** Loading state */
isLoading?: boolean;
/** Currently selected model */
value: string | undefined;
/** Callback when model changes */
onChange: (model: string) => void;
/** Disabled state */
disabled?: boolean;
/** Placeholder text */
placeholder?: string;
/** Custom className */
className?: string;
}
function PaidBadge({ label }: { label: string }) {
return (
<Badge variant="outline" className="text-[9px] h-4 px-1">
{label}
</Badge>
);
}
function StatusBadges({
model,
brokenLabel,
deprecatedLabel,
}: {
model: Pick<ModelEntry, 'broken' | 'deprecated'>;
brokenLabel: string;
deprecatedLabel: string;
}) {
return (
<>
{model.broken && (
<Badge variant="destructive" className="text-[9px] h-4 px-1">
{brokenLabel}
</Badge>
)}
{model.deprecated && (
<Badge variant="secondary" className="text-[9px] h-4 px-1">
{deprecatedLabel}
</Badge>
)}
</>
);
}
function CodexEffortBadge({ modelId }: { modelId: string | undefined }) {
const codexEffort = getCodexEffortDisplay(modelId);
if (!codexEffort) return null;
return (
<Badge
variant={codexEffort.explicit ? 'secondary' : 'outline'}
className="text-[9px] h-4 px-1 uppercase"
>
{codexEffort.label}
</Badge>
);
}
export function ProviderModelSelector({
catalog,
isLoading,
value,
onChange,
disabled,
placeholder,
className,
}: ProviderModelSelectorProps) {
const { t } = useTranslation();
const resolvedPlaceholder = placeholder ?? t('providerModelSelector.selectModel');
const groupedModels = useMemo(() => {
if (!catalog?.models) return { free: [], paid: [] };
return {
free: catalog.models.filter((model) => !model.tier || model.tier === 'free'),
paid: catalog.models.filter((model) => model.tier === 'paid'),
};
}, [catalog]);
const selectedModel = useMemo(
() => catalog?.models.find((model) => model.id === value),
[catalog, value]
);
if (isLoading) {
return <Skeleton className={cn('h-9 w-full', className)} />;
}
if (!catalog || catalog.models.length === 0) {
return (
<div className={cn('py-2 text-sm text-muted-foreground', className)}>
{t('providerModelSelector.noModelsForProvider')}
</div>
);
}
return (
<div className={cn('space-y-2', className)}>
<SearchableSelect
value={value || undefined}
onChange={onChange}
disabled={disabled}
placeholder={resolvedPlaceholder}
searchPlaceholder={t('searchableSelect.searchModels')}
emptyText={t('searchableSelect.noResults')}
groups={[
{
key: 'free',
label: (
<span className="text-xs text-muted-foreground">
{t('providerModelSelector.freeTier')}
</span>
),
},
{
key: 'paid',
label: (
<span className="text-xs text-amber-600">{t('providerModelSelector.paidTier')}</span>
),
},
]}
options={[...groupedModels.free, ...groupedModels.paid].map((model) => ({
value: model.id,
groupKey: model.tier === 'paid' ? 'paid' : 'free',
searchText: `${model.name} ${model.id}`,
keywords: [model.tier ?? 'free'],
triggerContent: (
<div className="flex min-w-0 items-center gap-2">
<span className="truncate">{model.name}</span>
{model.tier === 'paid' && <PaidBadge label={t('providerModelSelector.paid')} />}
</div>
),
itemContent: (
<div
className={cn(
'flex min-w-0 items-center gap-2',
(model.broken || model.deprecated) && 'opacity-60'
)}
>
<span className="truncate">{model.name}</span>
<StatusBadges
model={model}
brokenLabel={t('providerModelSelector.broken')}
deprecatedLabel={t('providerModelSelector.deprecated')}
/>
</div>
),
}))}
/>
{selectedModel?.broken && (
<div className="bg-destructive/10 text-destructive flex items-start gap-2 rounded-md p-2 text-xs">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<div>
<p className="font-medium">{t('providerModelSelector.modelKnownIssues')}</p>
{selectedModel.issueUrl && (
<a
href={selectedModel.issueUrl}
target="_blank"
rel="noopener noreferrer"
className="underline hover:no-underline"
>
{t('providerModelSelector.viewIssueDetails')}
</a>
)}
</div>
</div>
)}
{selectedModel?.deprecated && (
<div className="flex items-start gap-2 rounded-md bg-amber-500/10 p-2 text-xs text-amber-700">
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<div>
<p className="font-medium">{t('providerModelSelector.modelDeprecated')}</p>
{selectedModel.deprecationReason && (
<p className="opacity-80">{selectedModel.deprecationReason}</p>
)}
</div>
</div>
)}
{selectedModel?.description && !selectedModel.broken && !selectedModel.deprecated && (
<p className="text-xs text-muted-foreground">{selectedModel.description}</p>
)}
</div>
);
}
/** Model Mapping Selector - For Opus/Sonnet/Haiku mapping */
interface ModelMappingSelectorProps {
catalog: ProviderCatalog | undefined;
label: string;
value: string | undefined;
onChange: (model: string) => void;
disabled?: boolean;
}
export function ModelMappingSelector({
catalog,
label,
value,
onChange,
disabled,
}: ModelMappingSelectorProps) {
const { t } = useTranslation();
if (!catalog) return null;
return (
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">{label}</label>
<SearchableSelect
value={value || undefined}
onChange={onChange}
disabled={disabled}
placeholder={t('providerModelSelector.selectModel')}
searchPlaceholder={t('searchableSelect.searchModels')}
emptyText={t('searchableSelect.noResults')}
triggerClassName="h-8 text-sm"
options={catalog.models.map((model) => ({
value: model.id,
searchText: `${model.name} ${model.id}`,
triggerContent: <span className="truncate font-mono text-xs">{model.id}</span>,
itemContent: (
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm">{model.name}</span>
{model.tier === 'paid' && <PaidBadge label={t('providerModelSelector.paid')} />}
</div>
),
}))}
/>
</div>
);
}
/** Flexible Model Selector - Combines catalog recommendations with full model list */
interface FlexibleModelSelectorProps {
label: string;
description?: string;
value: string | undefined;
onChange: (model: string) => void;
catalog?: ProviderCatalog;
allModels: { id: string; owned_by: string }[];
routing?: CliproxyProviderRoutingHints;
disabled?: boolean;
}
function normalizeModelValue(
value: string | undefined,
routing?: CliproxyProviderRoutingHints
): string {
if (!value) return '';
if (!routing?.prefix) return value;
const prefix = `${routing.prefix}/`;
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,
value,
onChange,
catalog,
allModels,
routing,
disabled,
}: FlexibleModelSelectorProps) {
const { t } = useTranslation();
const isCodexProvider = catalog?.provider === 'codex';
const resolvedCatalogModels = useMemo(
() => getResolvedCatalogModels(catalog, allModels),
[allModels, catalog]
);
const catalogModelIds = new Set(resolvedCatalogModels.map((model) => model.id));
const routingHints = useMemo(
() =>
new Map((routing?.models ?? []).map((hint) => [hint.modelId.toLowerCase(), hint] as const)),
[routing]
);
const selectedRoutingHint = useMemo(
() => routingHints.get(normalizeModelValue(value, routing).toLowerCase()),
[routing, routingHints, value]
);
const recommendedOptions = resolvedCatalogModels.map((model) => ({
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">
{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">
{getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))}
</span>
{model.tier === 'paid' && <PaidBadge label={t('providerModelSelector.paid')} />}
{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>
),
}));
const allModelOptions = allModels
.filter((model) => !catalogModelIds.has(model.id))
.map((model) => ({
value: getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase())),
groupKey: 'all',
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">
{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">
{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>
),
}));
const selectedValueMissing =
Boolean(value) &&
!recommendedOptions.some((option) => option.value === value) &&
!allModelOptions.some((option) => option.value === value);
const legacySelectedOption = value
? {
value,
groupKey: 'current',
searchText: value,
triggerContent: (
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-mono text-xs">{value}</span>
<Badge variant="outline" className="text-[9px] h-4 px-1">
Current
</Badge>
</div>
),
itemContent: (
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-mono text-xs">{value}</span>
<Badge variant="outline" className="text-[9px] h-4 px-1">
Current
</Badge>
</div>
),
}
: null;
const hasAvailableModels = recommendedOptions.length + allModelOptions.length > 0;
return (
<div className="space-y-1.5">
<div>
<label className="text-xs font-medium">{label}</label>
{description && <p className="text-[10px] text-muted-foreground">{description}</p>}
</div>
<SearchableSelect
value={value || undefined}
onChange={onChange}
disabled={disabled}
placeholder={t('providerModelSelector.selectModel')}
searchPlaceholder={t('searchableSelect.searchModels')}
emptyText={
hasAvailableModels
? t('searchableSelect.noResults')
: t('providerModelSelector.noModelsAvailable')
}
triggerClassName="h-9"
groups={[
...(selectedValueMissing && legacySelectedOption
? [
{
key: 'current',
label: <span className="text-xs text-muted-foreground">Current value</span>,
},
]
: []),
{
key: 'recommended',
label: (
<span className="text-xs text-primary">{t('providerModelSelector.recommended')}</span>
),
},
{
key: 'all',
label: (
<span className="text-xs text-muted-foreground">
{t('providerModelSelector.allModelsCount', { count: allModels.length })}
</span>
),
},
]}
options={[
...(selectedValueMissing && legacySelectedOption ? [legacySelectedOption] : []),
...recommendedOptions,
...allModelOptions,
]}
/>
{selectedRoutingHint ? (
<div
className={cn(
'rounded-md border px-2.5 py-2 text-[11px]',
selectedRoutingHint.unprefixedStatus === 'safe'
? 'border-border/70 bg-muted/25 text-muted-foreground'
: 'border-amber-300/60 bg-amber-50 text-amber-900 dark:border-amber-500/30 dark:bg-amber-950/25 dark:text-amber-100'
)}
>
<div className="font-medium">
{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-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>
);
}