From 677f9d1e72990e51ed88e00b228369c8be520bbe Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sat, 20 Dec 2025 18:31:00 -0500 Subject: [PATCH] feat(ui): integrate OpenRouter model picker into profile editor - add isOpenRouterProfile() detection in utils.ts - show OpenRouterBadge in header when detected - replace model input with picker for OpenRouter profiles - add tier mapping section in friendly-ui-section.tsx - show OpenRouter icon in profile-card.tsx - prefetch models on api.tsx page load --- .../profiles/editor/friendly-ui-section.tsx | 65 +++++++++++++++++++ .../profiles/editor/header-section.tsx | 6 ++ ui/src/components/profiles/editor/index.tsx | 3 +- ui/src/components/profiles/editor/utils.ts | 57 ++++++++++++++++ ui/src/components/profiles/profile-card.tsx | 17 ++++- ui/src/pages/api.tsx | 4 ++ 6 files changed, 150 insertions(+), 2 deletions(-) diff --git a/ui/src/components/profiles/editor/friendly-ui-section.tsx b/ui/src/components/profiles/editor/friendly-ui-section.tsx index d9af4c00..2c7e6c08 100644 --- a/ui/src/components/profiles/editor/friendly-ui-section.tsx +++ b/ui/src/components/profiles/editor/friendly-ui-section.tsx @@ -1,11 +1,16 @@ /** * Friendly UI Section * Left column with environment variables and info tabs + * Enhanced with OpenRouter model picker when applicable */ +import { useMemo } from 'react'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { EnvEditorSection } from './env-editor-section'; import { InfoSection } from './info-section'; +import { OpenRouterModelPicker } from '@/components/profiles/openrouter-model-picker'; +import { ModelTierMapping, type TierMapping } from '@/components/profiles/model-tier-mapping'; +import { isOpenRouterProfile, extractTierMapping, applyTierMapping } from './utils'; import type { Settings, SettingsResponse } from './types'; interface FriendlyUISectionProps { @@ -16,6 +21,7 @@ interface FriendlyUISectionProps { onNewEnvKeyChange: (key: string) => void; onEnvValueChange: (key: string, value: string) => void; onAddEnvVar: () => void; + onEnvBulkChange?: (env: Record) => void; } export function FriendlyUISection({ @@ -26,7 +32,45 @@ export function FriendlyUISection({ onNewEnvKeyChange, onEnvValueChange, onAddEnvVar, + onEnvBulkChange, }: FriendlyUISectionProps) { + const isOpenRouter = isOpenRouterProfile(currentSettings); + const settingsEnv = currentSettings?.env; + + // Derive tier mapping from env vars (no local state to sync) + const tierMapping = useMemo( + () => extractTierMapping(settingsEnv ?? {}), + [settingsEnv] + ); + + // Memoize currentEnv for consistent reference + const currentEnv = settingsEnv ?? {}; + + // Handle model selection from OpenRouter picker + const handleModelChange = (modelId: string) => { + onEnvValueChange('ANTHROPIC_MODEL', modelId); + }; + + // Handle tier mapping change + const handleTierMappingChange = (mapping: TierMapping) => { + // Apply tier mapping to env vars + if (onEnvBulkChange) { + const newEnv = applyTierMapping(currentEnv, mapping); + onEnvBulkChange(newEnv); + } else { + // Fallback: update one by one + if (mapping.opus !== undefined) { + onEnvValueChange('ANTHROPIC_DEFAULT_OPUS_MODEL', mapping.opus || ''); + } + if (mapping.sonnet !== undefined) { + onEnvValueChange('ANTHROPIC_DEFAULT_SONNET_MODEL', mapping.sonnet || ''); + } + if (mapping.haiku !== undefined) { + onEnvValueChange('ANTHROPIC_DEFAULT_HAIKU_MODEL', mapping.haiku || ''); + } + } + }; + return (
@@ -46,6 +90,27 @@ export function FriendlyUISection({ value="env" className="flex-1 mt-0 border-0 p-0 data-[state=inactive]:hidden flex flex-col overflow-hidden" > + {/* OpenRouter Model Picker Section */} + {isOpenRouter && ( +
+
+ + +
+ +
+ )} + )} + {isOpenRouterProfile(settings) && }
{data && (

diff --git a/ui/src/components/profiles/editor/index.tsx b/ui/src/components/profiles/editor/index.tsx index d15fb411..49e071f4 100644 --- a/ui/src/components/profiles/editor/index.tsx +++ b/ui/src/components/profiles/editor/index.tsx @@ -139,6 +139,7 @@ export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) { pattern.test(key)); } + +/** + * Check if settings indicate an OpenRouter profile + */ +export function isOpenRouterProfile(settings: Settings | undefined): boolean { + if (!settings?.env) return false; + const baseUrl = settings.env.ANTHROPIC_BASE_URL || ''; + return baseUrl.toLowerCase().includes('openrouter.ai'); +} + +/** + * Extract tier mapping from settings env vars + */ +export function extractTierMapping(env: Record): { + opus?: string; + sonnet?: string; + haiku?: string; +} { + return { + opus: env.ANTHROPIC_DEFAULT_OPUS_MODEL || undefined, + sonnet: env.ANTHROPIC_DEFAULT_SONNET_MODEL || undefined, + haiku: env.ANTHROPIC_DEFAULT_HAIKU_MODEL || undefined, + }; +} + +/** + * Merge tier mapping into env vars + */ +export function applyTierMapping( + env: Record, + mapping: { opus?: string; sonnet?: string; haiku?: string } +): Record { + const result = { ...env }; + + // Set or remove tier overrides + if (mapping.opus) { + result.ANTHROPIC_DEFAULT_OPUS_MODEL = mapping.opus; + } else { + delete result.ANTHROPIC_DEFAULT_OPUS_MODEL; + } + + if (mapping.sonnet) { + result.ANTHROPIC_DEFAULT_SONNET_MODEL = mapping.sonnet; + } else { + delete result.ANTHROPIC_DEFAULT_SONNET_MODEL; + } + + if (mapping.haiku) { + result.ANTHROPIC_DEFAULT_HAIKU_MODEL = mapping.haiku; + } else { + delete result.ANTHROPIC_DEFAULT_HAIKU_MODEL; + } + + return result; +} diff --git a/ui/src/components/profiles/profile-card.tsx b/ui/src/components/profiles/profile-card.tsx index b760c392..ff18790c 100644 --- a/ui/src/components/profiles/profile-card.tsx +++ b/ui/src/components/profiles/profile-card.tsx @@ -1,7 +1,10 @@ import { Card, CardContent, CardHeader } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { SettingsIcon, PlayIcon } from 'lucide-react'; +import { isOpenRouterProfile } from './editor/utils'; +import type { Settings } from './editor/types'; interface ProfileCardProps { profile: { @@ -12,18 +15,30 @@ interface ProfileCardProps { lastUsed?: string; model?: string; }; + /** Optional settings for OpenRouter detection */ + settings?: Settings; onSwitch?: () => void; onConfig?: () => void; onTest?: () => void; } -export function ProfileCard({ profile, onSwitch, onConfig, onTest }: ProfileCardProps) { +export function ProfileCard({ profile, settings, onSwitch, onConfig, onTest }: ProfileCardProps) { + const showOpenRouterIcon = isOpenRouterProfile(settings); + return (

{profile.name}

+ {showOpenRouterIcon && ( + + + OpenRouter + + OpenRouter profile + + )} {profile.isActive && ( Active diff --git a/ui/src/pages/api.tsx b/ui/src/pages/api.tsx index 27de98a5..64642395 100644 --- a/ui/src/pages/api.tsx +++ b/ui/src/pages/api.tsx @@ -24,6 +24,7 @@ import { import { ProfileEditor } from '@/components/profile-editor'; import { ProfileCreateDialog } from '@/components/profiles/profile-create-dialog'; import { useProfiles, useDeleteProfile } from '@/hooks/use-profiles'; +import { useOpenRouterModels } from '@/hooks/use-openrouter-models'; import { ConfirmDialog } from '@/components/shared/confirm-dialog'; import type { Profile } from '@/lib/api-client'; import { cn } from '@/lib/utils'; @@ -37,6 +38,9 @@ export function ApiPage() { const [isCreateDialogOpen, setCreateDialogOpen] = useState(false); const [deleteConfirm, setDeleteConfirm] = useState(null); + // Prefetch OpenRouter models when page loads (lazy - won't block render) + useOpenRouterModels(); + // Memoize profiles to maintain stable reference const profiles = useMemo(() => data?.profiles || [], [data?.profiles]);