From 3cd21bb67b1e357992e662fe666bd35a4062de04 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sat, 20 Dec 2025 18:30:20 -0500 Subject: [PATCH] feat(ui): add OpenRouter model picker and tier mapping components - add openrouter-badge.tsx with orange-themed badge - add openrouter-model-picker.tsx with search and category filters - add model-tier-mapping.tsx with collapsible tier editor - update barrel exports in profiles/index.ts --- ui/src/components/profiles/index.ts | 6 + .../profiles/model-tier-mapping.tsx | 114 +++++++++ .../components/profiles/openrouter-badge.tsx | 40 ++++ .../profiles/openrouter-model-picker.tsx | 220 ++++++++++++++++++ 4 files changed, 380 insertions(+) create mode 100644 ui/src/components/profiles/model-tier-mapping.tsx create mode 100644 ui/src/components/profiles/openrouter-badge.tsx create mode 100644 ui/src/components/profiles/openrouter-model-picker.tsx diff --git a/ui/src/components/profiles/index.ts b/ui/src/components/profiles/index.ts index 366de67e..db201102 100644 --- a/ui/src/components/profiles/index.ts +++ b/ui/src/components/profiles/index.ts @@ -12,3 +12,9 @@ export { ProfilesTable } from './profiles-table'; // Profile editor (from subdirectory) export { ProfileEditor } from './editor'; export type { Settings, SettingsResponse, ProfileEditorProps } from './editor'; + +// OpenRouter components +export { OpenRouterBadge } from './openrouter-badge'; +export { OpenRouterModelPicker } from './openrouter-model-picker'; +export { ModelTierMapping } from './model-tier-mapping'; +export type { TierMapping } from './model-tier-mapping'; diff --git a/ui/src/components/profiles/model-tier-mapping.tsx b/ui/src/components/profiles/model-tier-mapping.tsx new file mode 100644 index 00000000..712e43c0 --- /dev/null +++ b/ui/src/components/profiles/model-tier-mapping.tsx @@ -0,0 +1,114 @@ +/** + * Model Tier Mapping Editor + * Configure opus/sonnet/haiku model overrides + */ + +import { useMemo } from 'react'; +import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Wand2, ChevronRight } from 'lucide-react'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { useOpenRouterCatalog } from '@/hooks/use-openrouter-models'; +import { suggestTierMappings } from '@/lib/openrouter-utils'; +import { cn } from '@/lib/utils'; + +export interface TierMapping { + opus?: string; + sonnet?: string; + haiku?: string; +} + +interface ModelTierMappingProps { + selectedModel?: string; + value: TierMapping; + onChange: (mapping: TierMapping) => void; + className?: string; +} + +export function ModelTierMapping({ + selectedModel, + value, + onChange, + className, +}: ModelTierMappingProps) { + const { models } = useOpenRouterCatalog(); + + const suggestions = useMemo(() => { + if (!selectedModel) return {}; + return suggestTierMappings(selectedModel, models); + }, [selectedModel, models]); + + const handleAutoSuggest = () => { + onChange(suggestions); + }; + + const updateTier = (tier: keyof TierMapping, modelId: string) => { + onChange({ ...value, [tier]: modelId || undefined }); + }; + + const hasSuggestions = selectedModel && Object.keys(suggestions).length > 0; + + return ( + + + + Model Tier Mapping + (Advanced) + + +

+ Configure different models for Claude Code's opus/sonnet/haiku tiers. +

+ + {hasSuggestions && ( + + )} + +
+
+ + updateTier('opus', e.target.value)} + placeholder="e.g., anthropic/claude-opus-4" + /> +
+
+ + updateTier('sonnet', e.target.value)} + placeholder="e.g., anthropic/claude-sonnet-4" + /> +
+
+ + updateTier('haiku', e.target.value)} + placeholder="e.g., anthropic/claude-3.5-haiku" + /> +
+
+ +

+ These set ANTHROPIC_DEFAULT_OPUS_MODEL, ANTHROPIC_DEFAULT_SONNET_MODEL, + ANTHROPIC_DEFAULT_HAIKU_MODEL. +

+
+
+ ); +} diff --git a/ui/src/components/profiles/openrouter-badge.tsx b/ui/src/components/profiles/openrouter-badge.tsx new file mode 100644 index 00000000..54610e48 --- /dev/null +++ b/ui/src/components/profiles/openrouter-badge.tsx @@ -0,0 +1,40 @@ +/** + * OpenRouter Badge Component + * Visual indicator for OpenRouter-configured profiles + */ + +import { Badge } from '@/components/ui/badge'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; + +interface OpenRouterBadgeProps { + className?: string; + showTooltip?: boolean; +} + +export function OpenRouterBadge({ className, showTooltip = true }: OpenRouterBadgeProps) { + const badge = ( + + OpenRouter + OpenRouter + + ); + + if (!showTooltip) return badge; + + return ( + + {badge} + +

Access 349+ models via OpenRouter

+
+
+ ); +} diff --git a/ui/src/components/profiles/openrouter-model-picker.tsx b/ui/src/components/profiles/openrouter-model-picker.tsx new file mode 100644 index 00000000..da985040 --- /dev/null +++ b/ui/src/components/profiles/openrouter-model-picker.tsx @@ -0,0 +1,220 @@ +/** + * OpenRouter Model Picker Component + * Searchable model selector with categories and pricing + */ + +import { useState, useMemo, useCallback } from 'react'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Search, RefreshCw, Loader2 } from 'lucide-react'; +import { useOpenRouterCatalog, useRefreshOpenRouterModels } from '@/hooks/use-openrouter-models'; +import { + searchModels, + formatPricingPair, + formatContextLength, + CATEGORY_LABELS, +} from '@/lib/openrouter-utils'; +import type { CategorizedModel, ModelCategory } from '@/lib/openrouter-types'; +import { cn } from '@/lib/utils'; + +interface OpenRouterModelPickerProps { + value?: string; + onChange: (modelId: string) => void; + placeholder?: string; + className?: string; +} + +export function OpenRouterModelPicker({ + value, + onChange, + placeholder = 'Search models...', + className, +}: OpenRouterModelPickerProps) { + const [search, setSearch] = useState(''); + const [selectedCategory, setSelectedCategory] = useState(null); + + const { models, isLoading, isError, isFetching } = useOpenRouterCatalog(); + const refreshModels = useRefreshOpenRouterModels(); + + // Filter and group models + const filteredModels = useMemo(() => { + return searchModels(models, search, { + category: selectedCategory ?? undefined, + }); + }, [models, search, selectedCategory]); + + // Group by category + const groupedModels = useMemo(() => { + const groups: Record = { + anthropic: [], + openai: [], + google: [], + meta: [], + mistral: [], + opensource: [], + other: [], + }; + + filteredModels.forEach((model) => { + groups[model.category].push(model); + }); + + return groups; + }, [filteredModels]); + + const handleRefresh = useCallback(() => { + refreshModels(); + }, [refreshModels]); + + const selectedModel = models.find((m) => m.id === value); + + if (isLoading && models.length === 0) { + return ( +
+ + +
+ ); + } + + return ( +
+ {/* Search Header */} +
+
+ + setSearch(e.target.value)} + placeholder={placeholder} + className="pl-9" + /> +
+ +
+ + {/* Category Filters */} +
+ setSelectedCategory(null)} + > + All ({models.length}) + + {(Object.keys(CATEGORY_LABELS) as ModelCategory[]).map((cat) => { + const count = groupedModels[cat].length; + if (count === 0) return null; + return ( + setSelectedCategory(cat)} + > + {CATEGORY_LABELS[cat]} ({count}) + + ); + })} +
+ + {/* Selected Model Display */} + {selectedModel && ( +
+ {selectedModel.name} + + {formatPricingPair(selectedModel.pricing)} |{' '} + {formatContextLength(selectedModel.context_length)} + +
+ )} + + {/* Model List */} + + {isError ? ( +
+ Failed to load models.{' '} + +
+ ) : filteredModels.length === 0 ? ( +
+ No models found matching "{search}" +
+ ) : ( +
+ {(Object.keys(CATEGORY_LABELS) as ModelCategory[]).map((category) => { + const categoryModels = groupedModels[category]; + if (categoryModels.length === 0) return null; + + return ( +
+
+ {CATEGORY_LABELS[category]} +
+ {categoryModels.map((model) => ( + onChange(model.id)} + /> + ))} +
+ ); + })} +
+ )} +
+
+ ); +} + +function ModelItem({ + model, + isSelected, + onClick, +}: { + model: CategorizedModel; + isSelected: boolean; + onClick: () => void; +}) { + return ( + + ); +}