From d750a2241f34c9a341939a87ae2c9448ad604208 Mon Sep 17 00:00:00 2001 From: marko-kraemer Date: Sun, 21 Sep 2025 18:36:47 +0200 Subject: [PATCH] frontend model selection slight cleanup --- backend/README.md | 1 - backend/core/ai_models/registry.py | 2 +- .../app/(dashboard)/model-pricing/page.tsx | 26 +- .../agents/config/model-selector.tsx | 65 +-- .../src/components/billing/usage-logs.tsx | 375 ------------- .../src/components/thread/ThreadComponent.tsx | 2 +- .../chat-input/_use-model-selection-new.ts | 224 -------- .../thread/chat-input/_use-model-selection.ts | 504 ------------------ .../thread/chat-input/chat-input.tsx | 10 +- .../thread/chat-input/message-input.tsx | 3 +- .../thread/chat-input/unified-config-menu.tsx | 6 +- .../react-query/subscriptions/use-billing.ts | 22 +- .../react-query/subscriptions/use-model.ts | 21 - frontend/src/hooks/use-model-selection.ts | 136 +++++ frontend/src/lib/api-enhanced.ts | 454 ---------------- frontend/src/lib/api.ts | 5 +- frontend/src/lib/stores/model-store.ts | 119 +---- 17 files changed, 180 insertions(+), 1795 deletions(-) delete mode 100644 frontend/src/components/billing/usage-logs.tsx delete mode 100644 frontend/src/components/thread/chat-input/_use-model-selection-new.ts delete mode 100644 frontend/src/components/thread/chat-input/_use-model-selection.ts delete mode 100644 frontend/src/hooks/react-query/subscriptions/use-model.ts create mode 100644 frontend/src/hooks/use-model-selection.ts delete mode 100644 frontend/src/lib/api-enhanced.ts diff --git a/backend/README.md b/backend/README.md index 14c4194b..726376b9 100644 --- a/backend/README.md +++ b/backend/README.md @@ -84,7 +84,6 @@ ANTHROPIC_API_KEY=your-anthropic-key OPENAI_API_KEY=your-openai-key OPENROUTER_API_KEY=your-openrouter-key GEMINI_API_KEY=your-gemini-api-key -MODEL_TO_USE=openrouter/moonshotai/kimi-k2 # Search and Web Scraping TAVILY_API_KEY=your-tavily-key diff --git a/backend/core/ai_models/registry.py b/backend/core/ai_models/registry.py index b041c14b..bc1611b2 100644 --- a/backend/core/ai_models/registry.py +++ b/backend/core/ai_models/registry.py @@ -57,7 +57,7 @@ class ModelRegistry: id="xai/grok-4-fast-non-reasoning", name="Grok 4 Fast", provider=ModelProvider.XAI, - aliases=["grok-4-fast-non-reasoning", "x-ai/grok-4-fast-non-reasoning", "openrouter/x-ai/grok-4-fast-non-reasoning", "Grok 4 Fast Non Reasoning"], + aliases=["grok-4-fast-non-reasoning", "x-ai/grok-4-fast-non-reasoning", "openrouter/x-ai/grok-4-fast-non-reasoning", "Grok 4 Fast"], context_window=2_000_000, capabilities=[ ModelCapability.CHAT, diff --git a/frontend/src/app/(dashboard)/model-pricing/page.tsx b/frontend/src/app/(dashboard)/model-pricing/page.tsx index c18c8eda..9f3c16e8 100644 --- a/frontend/src/app/(dashboard)/model-pricing/page.tsx +++ b/frontend/src/app/(dashboard)/model-pricing/page.tsx @@ -16,11 +16,11 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { useAvailableModels } from '@/hooks/react-query/subscriptions/use-billing'; +// Models now fetched via useModelSelection hook import type { Model } from '@/lib/api'; import { Loader2 } from 'lucide-react'; import { useMemo, useState } from 'react'; -import { useModelSelection } from '@/components/thread/chat-input/_use-model-selection'; +import { useModelSelection } from '@/hooks/use-model-selection'; // Example task data with token usage const exampleTasks = [ @@ -137,14 +137,11 @@ const exampleTasks = [ const DISABLE_EXAMPLES = true; export default function PricingPage() { - const { - data: modelsResponse, - isLoading: loading, - error, - refetch, - } = useAvailableModels(); - - const { allModels } = useModelSelection(); + const { + allModels, + modelsData: modelsResponse, + isLoading: loading + } = useModelSelection(); const [selectedModelId, setSelectedModelId] = useState( 'anthropic/claude-sonnet-4-20250514', @@ -223,7 +220,7 @@ export default function PricingPage() { ); } - if (error) { + if (!modelsResponse && !loading) { return (
@@ -233,14 +230,9 @@ export default function PricingPage() { Pricing Unavailable

- {error instanceof Error - ? error.message - : 'Failed to fetch model pricing'} + Failed to fetch model pricing. Please refresh the page.

-
); diff --git a/frontend/src/components/agents/config/model-selector.tsx b/frontend/src/components/agents/config/model-selector.tsx index f4d2ce18..0055990a 100644 --- a/frontend/src/components/agents/config/model-selector.tsx +++ b/frontend/src/components/agents/config/model-selector.tsx @@ -16,14 +16,8 @@ import { TooltipTrigger, } from '@/components/ui/tooltip'; import { cn } from '@/lib/utils'; -import { - useModelSelection, - MODELS, - DEFAULT_FREE_MODEL_ID, - DEFAULT_PREMIUM_MODEL_ID -} from '@/components/thread/chat-input/_use-model-selection'; -import { formatModelName, getPrefixedModelId } from '@/lib/stores/model-store'; -import { useAvailableModels } from '@/hooks/react-query/subscriptions/use-billing'; +import { useModelSelection } from '@/hooks/use-model-selection'; +import { formatModelName } from '@/lib/stores/model-store'; import { isLocalMode } from '@/lib/config'; import { CustomModelDialog, CustomModelFormData } from '@/components/thread/chat-input/custom-model-dialog'; import { PaywallDialog } from '@/components/payment/paywall-dialog'; @@ -59,9 +53,9 @@ export function AgentModelSelector({ customModels: storeCustomModels, addCustomModel: storeAddCustomModel, updateCustomModel: storeUpdateCustomModel, - removeCustomModel: storeRemoveCustomModel + removeCustomModel: storeRemoveCustomModel, + modelsData // Now available directly from the hook } = useModelSelection(); - const { data: modelsData } = useAvailableModels(); const [isOpen, setIsOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [highlightedIndex, setHighlightedIndex] = useState(-1); @@ -78,30 +72,8 @@ export function AgentModelSelector({ const customModels = storeCustomModels; - const normalizeModelId = (modelId?: string): string => { - if (!modelId) return subscriptionStatus === 'active' ? DEFAULT_PREMIUM_MODEL_ID : DEFAULT_FREE_MODEL_ID; - - if (modelsData?.models) { - const exactMatch = modelsData.models.find(m => m.short_name === modelId); - if (exactMatch) return exactMatch.short_name; - - const fullMatch = modelsData.models.find(m => m.id === modelId); - if (fullMatch) return fullMatch.short_name || fullMatch.id; - - if (modelId.startsWith('openrouter/')) { - const shortName = modelId.replace('openrouter/', ''); - const shortMatch = modelsData.models.find(m => m.short_name === shortName); - if (shortMatch) return shortMatch.short_name; - } - } - - return modelId; - }; - - const normalizedValue = normalizeModelId(value); - // Use the prop value if provided, otherwise fall back to store value - const selectedModel = normalizedValue || storeSelectedModel; + const selectedModel = value || storeSelectedModel; const enhancedModelOptions = useMemo(() => { const modelMap = new Map(); @@ -261,14 +233,12 @@ export function AgentModelSelector({ const handleSaveCustomModel = (formData: CustomModelFormData) => { const modelId = formData.id.trim(); - const displayId = modelId.startsWith('openrouter/') ? modelId.replace('openrouter/', '') : modelId; - const modelLabel = formData.label.trim() || formatModelName(displayId); + const modelLabel = formData.label.trim() || formatModelName(modelId); if (!modelId) return; - const checkId = modelId; if (customModels.some(model => - model.id === checkId && (dialogMode === 'add' || model.id !== editingModelId))) { + model.id === modelId && (dialogMode === 'add' || model.id !== editingModelId))) { console.error('A model with this ID already exists'); return; } @@ -302,8 +272,11 @@ export function AgentModelSelector({ storeRemoveCustomModel(modelId); if (selectedModel === modelId) { - const defaultModel = subscriptionStatus === 'active' ? DEFAULT_PREMIUM_MODEL_ID : DEFAULT_FREE_MODEL_ID; - onChange(defaultModel); + // When deleting the currently selected custom model, let the hook determine the new default + const firstAvailableModel = allModels.find(m => canAccessModel(m.id)); + if (firstAvailableModel) { + onChange(firstAvailableModel.id); + } } }; @@ -313,8 +286,8 @@ export function AgentModelSelector({ const accessible = isCustom ? true : (isLocalMode() || canAccessModel(model.id)); const isHighlighted = index === highlightedIndex; const isPremium = model.requiresSubscription; - const isLowQuality = MODELS[model.id]?.lowQuality || false; - const isRecommended = MODELS[model.id]?.recommended || false; + const isLowQuality = false; // API models are quality controlled + const isRecommended = model.recommended || false; return ( @@ -411,14 +384,12 @@ export function AgentModelSelector({
- {MODELS[selectedModel]?.lowQuality && ( - - )} + {/* API models are quality controlled - no low quality warning needed */}
{selectedModelDisplay}
- {MODELS[selectedModel]?.recommended && ( + {allModels.find(m => m.id === selectedModel)?.recommended && ( Recommended @@ -438,9 +409,7 @@ export function AgentModelSelector({ >
- {MODELS[selectedModel]?.lowQuality && ( - - )} + {/* API models are quality controlled - no low quality warning needed */}
{selectedModelDisplay} diff --git a/frontend/src/components/billing/usage-logs.tsx b/frontend/src/components/billing/usage-logs.tsx deleted file mode 100644 index e487c339..00000000 --- a/frontend/src/components/billing/usage-logs.tsx +++ /dev/null @@ -1,375 +0,0 @@ -'use client'; - -import { useState, useEffect } from 'react'; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@/components/ui/card'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table'; -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from '@/components/ui/accordion'; -import { Badge } from '@/components/ui/badge'; -import { Skeleton } from '@/components/ui/skeleton'; -import { Button } from '@/components/ui/button'; -import { ExternalLink, Loader2, AlertCircle } from 'lucide-react'; -import Link from 'next/link'; -import { OpenInNewWindowIcon } from '@radix-ui/react-icons'; -import { useUsageLogs } from '@/hooks/react-query/subscriptions/use-billing'; -import { UsageLogEntry } from '@/lib/api'; -import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; - - -interface DailyUsage { - date: string; - logs: UsageLogEntry[]; - totalTokens: number; - totalCost: number; - requestCount: number; - models: string[]; -} - -interface Props { - accountId: string; -} - -export default function UsageLogs({ accountId }: Props) { - const [page, setPage] = useState(0); - const [allLogs, setAllLogs] = useState([]); - const [hasMore, setHasMore] = useState(true); - - const ITEMS_PER_PAGE = 1000; - - // Use React Query hook for the current page - const { data: currentPageData, isLoading, error, refetch } = useUsageLogs(page, ITEMS_PER_PAGE); - - // Update accumulated logs when new data arrives - useEffect(() => { - if (currentPageData) { - if (page === 0) { - // First page - replace all logs - setAllLogs(currentPageData.logs || []); - } else { - // Subsequent pages - append to existing logs - setAllLogs(prev => [...prev, ...(currentPageData.logs || [])]); - } - setHasMore(currentPageData.has_more || false); - } - }, [currentPageData, page]); - - const loadMore = () => { - const nextPage = page + 1; - setPage(nextPage); - }; - - const formatDate = (dateString: string) => { - return new Date(dateString).toLocaleString(); - }; - - const formatCost = (cost: number | string) => { - if (typeof cost === 'string' || cost === 0) { - return typeof cost === 'string' ? cost : '$0.0000'; - } - return `$${cost.toFixed(4)}`; - }; - - const formatCreditAmount = (amount: number) => { - if (amount === 0) return null; - return `$${amount.toFixed(4)}`; - }; - - const formatDateOnly = (dateString: string) => { - return new Date(dateString).toLocaleDateString('en-US', { - weekday: 'long', - year: 'numeric', - month: 'long', - day: 'numeric', - }); - }; - - const handleThreadClick = (threadId: string, projectId: string) => { - // Navigate to the thread using the correct project_id - const threadUrl = `/projects/${projectId}/thread/${threadId}`; - window.open(threadUrl, '_blank'); - }; - - // Group usage logs by date - const groupLogsByDate = (logs: UsageLogEntry[]): DailyUsage[] => { - const grouped = logs.reduce( - (acc, log) => { - const date = new Date(log.created_at).toDateString(); - - if (!acc[date]) { - acc[date] = { - date, - logs: [], - totalTokens: 0, - totalCost: 0, - requestCount: 0, - models: [], - }; - } - - acc[date].logs.push(log); - acc[date].totalTokens += log.total_tokens; - acc[date].totalCost += - typeof log.estimated_cost === 'number' ? log.estimated_cost : 0; - acc[date].requestCount += 1; - - if (!acc[date].models.includes(log.content.model)) { - acc[date].models.push(log.content.model); - } - - return acc; - }, - {} as Record, - ); - - return Object.values(grouped).sort( - (a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(), - ); - }; - - - - if (isLoading && page === 0) { - return ( - - - Usage Logs - Loading your token usage history... - - -
- {Array.from({ length: 5 }).map((_, i) => ( - - ))} -
-
-
- ); - } - - if (error) { - return ( - - - Usage Logs - - -
-

- Error: {error.message || 'Failed to load usage logs'} -

-
-
-
- ); - } - - // Handle local development mode message - if (currentPageData?.message) { - return ( - - - Usage Logs - - -
-

- {currentPageData.message} -

-
-
-
- ); - } - - const dailyUsage = groupLogsByDate(allLogs); - const totalUsage = allLogs.reduce( - (sum, log) => - sum + (typeof log.estimated_cost === 'number' ? log.estimated_cost : 0), - 0, - ); - - // Get subscription limit from the first page data - const subscriptionLimit = currentPageData?.subscription_limit || 0; - - return ( -
- {/* Show credit usage info if user has gone over limit */} - {subscriptionLimit > 0 && totalUsage > subscriptionLimit && ( - - - Credits Being Used - - You've exceeded your monthly subscription limit of ${subscriptionLimit.toFixed(2)}. - Additional usage is being deducted from your credit balance. - - - )} - - {/* Usage Logs Accordion */} - - - Daily Usage Logs - -
- Your token usage organized by day, sorted by most recent.{" "} - -
-
-
- - {dailyUsage.length === 0 ? ( -
-

No usage logs found.

-
- ) : ( - <> - - {dailyUsage.map((day) => ( - - -
-
-
- {formatDateOnly(day.date)} -
-
- {day.requestCount} request - {day.requestCount !== 1 ? 's' : ''} •{' '} - {day.models.join(', ')} -
-
-
-
- {formatCost(day.totalCost)} -
-
- {day.totalTokens.toLocaleString()} tokens -
-
-
-
- -
- - - - Time - Model - Prompt - Completion - Total - Cost - Payment - Thread - - - - {day.logs.map((log, index) => ( - - - {new Date(log.created_at).toLocaleTimeString()} - - - - {log.content.model.replace('openrouter/', '').replace('anthropic/', '')} - - - - {log.content.usage.prompt_tokens.toLocaleString()} - - - {log.content.usage.completion_tokens.toLocaleString()} - - - {log.total_tokens.toLocaleString()} - - - {formatCost(log.estimated_cost)} - - - {log.payment_method === 'credits' ? ( -
- - Credits - - {log.credit_used && log.credit_used > 0 && ( - - -{formatCreditAmount(log.credit_used)} - - )} -
- ) : ( - - Subscription - - )} -
- - - -
- ))} -
-
-
-
-
- ))} -
- - {hasMore && ( -
- -
- )} - - )} -
-
-
- ); -} diff --git a/frontend/src/components/thread/ThreadComponent.tsx b/frontend/src/components/thread/ThreadComponent.tsx index f5d330f2..266fa17e 100644 --- a/frontend/src/components/thread/ThreadComponent.tsx +++ b/frontend/src/components/thread/ThreadComponent.tsx @@ -23,7 +23,7 @@ import { useStopAgentMutation, } from '@/hooks/react-query/threads/use-agent-run'; import { useSharedSubscription } from '@/contexts/SubscriptionContext'; -import { SubscriptionStatus } from '@/components/thread/chat-input/_use-model-selection'; +export type SubscriptionStatus = 'no_subscription' | 'active'; import { UnifiedMessage, diff --git a/frontend/src/components/thread/chat-input/_use-model-selection-new.ts b/frontend/src/components/thread/chat-input/_use-model-selection-new.ts deleted file mode 100644 index 25d7b424..00000000 --- a/frontend/src/components/thread/chat-input/_use-model-selection-new.ts +++ /dev/null @@ -1,224 +0,0 @@ -'use client'; - -import { useSubscriptionData } from '@/contexts/SubscriptionContext'; -import { useEffect, useMemo } from 'react'; -import { isLocalMode } from '@/lib/config'; -import { useAvailableModels } from '@/hooks/react-query/subscriptions/use-model'; -import { - useModelStore, - canAccessModel, - formatModelName, - getPrefixedModelId, - type SubscriptionStatus, - type ModelOption, - type CustomModel -} from '@/lib/stores/model-store'; - -export const useModelSelection = () => { - const { data: subscriptionData } = useSubscriptionData(); - const { data: modelsData, isLoading: isLoadingModels } = useAvailableModels({ - refetchOnMount: false, - }); - - const { - selectedModel, - customModels, - hasHydrated, - setSelectedModel, - addCustomModel, - updateCustomModel, - removeCustomModel, - setCustomModels, - setHasHydrated, - getDefaultModel, - resetToDefault, - } = useModelStore(); - - const subscriptionStatus: SubscriptionStatus = (subscriptionData?.subscription?.status === 'active' || subscriptionData?.subscription?.status === 'trialing') - ? 'active' - : 'no_subscription'; - - useEffect(() => { - if (isLocalMode() && hasHydrated && typeof window !== 'undefined') { - try { - const storedModels = localStorage.getItem('customModels'); - if (storedModels) { - const parsedModels = JSON.parse(storedModels); - if (Array.isArray(parsedModels)) { - const validModels = parsedModels.filter((model: any) => - model && typeof model === 'object' && - typeof model.id === 'string' && - typeof model.label === 'string' - ); - setCustomModels(validModels); - } - } - } catch (e) { - console.error('Error loading custom models:', e); - } - } - }, [isLocalMode, hasHydrated, setCustomModels]); - - const MODEL_OPTIONS = useMemo(() => { - let models: ModelOption[] = []; - if (!modelsData?.models || isLoadingModels) { - models = [ - { - id: 'Kimi K2', - label: 'Kimi K2', - requiresSubscription: false, - priority: 100, - recommended: true - }, - { - id: 'Claude Sonnet 4', - label: 'Claude Sonnet 4', - requiresSubscription: true, - priority: 100, - recommended: true - }, - ]; - } else { - models = modelsData.models.map(model => { - const shortName = model.short_name || model.id; - const displayName = model.display_name || shortName; - - return { - id: shortName, - label: displayName, - requiresSubscription: model.requires_subscription || false, - priority: model.priority || 0, - recommended: model.recommended || false, - top: (model.priority || 0) >= 90, - capabilities: model.capabilities || [], - contextWindow: model.context_window || 128000 - }; - }); - } - - if (isLocalMode() && customModels.length > 0) { - const customModelOptions = customModels.map(model => ({ - id: model.id, - label: model.label || formatModelName(model.id), - requiresSubscription: false, - top: false, - isCustom: true, - priority: 30, - })); - - models = [...models, ...customModelOptions]; - } - - const sortedModels = models.sort((a, b) => { - if (a.recommended !== b.recommended) { - return a.recommended ? -1 : 1; - } - - if (a.priority !== b.priority) { - return (b.priority || 0) - (a.priority || 0); - } - - return a.label.localeCompare(b.label); - }); - - return sortedModels; - }, [modelsData, isLoadingModels, customModels]); - - const availableModels = useMemo(() => { - return isLocalMode() - ? MODEL_OPTIONS - : MODEL_OPTIONS.filter(model => - canAccessModel(subscriptionStatus, model.requiresSubscription) - ); - }, [MODEL_OPTIONS, subscriptionStatus]); - - useEffect(() => { - if (!hasHydrated || isLoadingModels || typeof window === 'undefined') { - return; - } - - const isValidModel = MODEL_OPTIONS.some(model => model.id === selectedModel) || - (isLocalMode() && customModels.some(model => model.id === selectedModel)); - - if (!isValidModel) { - console.log('🔧 ModelSelection: Invalid model detected, resetting to default'); - resetToDefault(subscriptionStatus); - return; - } - - if (!isLocalMode()) { - const modelOption = MODEL_OPTIONS.find(m => m.id === selectedModel); - if (modelOption && !canAccessModel(subscriptionStatus, modelOption.requiresSubscription)) { - console.log('🔧 ModelSelection: User lost access to model, resetting to default'); - resetToDefault(subscriptionStatus); - } - } - }, [hasHydrated, selectedModel, subscriptionStatus, MODEL_OPTIONS, customModels, isLoadingModels, resetToDefault]); - - const handleModelChange = (modelId: string) => { - const isCustomModel = isLocalMode() && customModels.some(model => model.id === modelId); - - const modelOption = MODEL_OPTIONS.find(option => option.id === modelId); - - if (!modelOption && !isCustomModel) { - resetToDefault(subscriptionStatus); - return; - } - - if (!isCustomModel && !isLocalMode() && - !canAccessModel(subscriptionStatus, modelOption?.requiresSubscription ?? false)) { - return; - } - - setSelectedModel(modelId); - }; - - const getActualModelId = (modelId: string): string => { - const isCustomModel = isLocalMode() && customModels.some(model => model.id === modelId); - return isCustomModel ? getPrefixedModelId(modelId, true) : modelId; - }; - - const refreshCustomModels = () => { - if (isLocalMode() && typeof window !== 'undefined') { - try { - const storedModels = localStorage.getItem('customModels'); - if (storedModels) { - const parsedModels = JSON.parse(storedModels); - if (Array.isArray(parsedModels)) { - const validModels = parsedModels.filter((model: any) => - model && typeof model === 'object' && - typeof model.id === 'string' && - typeof model.label === 'string' - ); - setCustomModels(validModels); - } - } - } catch (e) { - console.error('Error loading custom models:', e); - } - } - }; - - return { - selectedModel, - handleModelChange, - setSelectedModel: handleModelChange, - availableModels, - allModels: MODEL_OPTIONS, - customModels, - addCustomModel, - updateCustomModel, - removeCustomModel, - refreshCustomModels, - getActualModelId, - canAccessModel: (modelId: string) => { - if (isLocalMode()) return true; - const model = MODEL_OPTIONS.find(m => m.id === modelId); - return model ? canAccessModel(subscriptionStatus, model.requiresSubscription) : false; - }, - isSubscriptionRequired: (modelId: string) => { - return MODEL_OPTIONS.find(m => m.id === modelId)?.requiresSubscription || false; - }, - subscriptionStatus, - }; -}; \ No newline at end of file diff --git a/frontend/src/components/thread/chat-input/_use-model-selection.ts b/frontend/src/components/thread/chat-input/_use-model-selection.ts deleted file mode 100644 index 066dede8..00000000 --- a/frontend/src/components/thread/chat-input/_use-model-selection.ts +++ /dev/null @@ -1,504 +0,0 @@ -'use client'; - -import { useSubscriptionData } from '@/contexts/SubscriptionContext'; -import { useState, useEffect, useMemo } from 'react'; -import { isLocalMode } from '@/lib/config'; -import { useAvailableModels } from '@/hooks/react-query/subscriptions/use-model'; - -export const STORAGE_KEY_MODEL = 'suna-preferred-model-v3'; -export const STORAGE_KEY_CUSTOM_MODELS = 'customModels'; -export const DEFAULT_PREMIUM_MODEL_ID = 'claude-sonnet-4'; -export const DEFAULT_FREE_MODEL_ID = 'moonshotai/kimi-k2'; - -export const testLocalStorage = (): boolean => { - if (typeof window === 'undefined') return false; - try { - const testKey = 'test-storage'; - const testValue = 'test-value'; - localStorage.setItem(testKey, testValue); - const retrieved = localStorage.getItem(testKey); - localStorage.removeItem(testKey); - return retrieved === testValue; - } catch (error) { - console.error('localStorage test failed:', error); - return false; - } -}; - -export type SubscriptionStatus = 'no_subscription' | 'active'; - -export interface ModelOption { - id: string; - label: string; - requiresSubscription: boolean; - description?: string; - top?: boolean; - isCustom?: boolean; - priority?: number; -} - -export interface CustomModel { - id: string; - label: string; -} - -export const MODELS = { - 'claude-sonnet-4': { - tier: 'none', - priority: 100, - recommended: true, - lowQuality: false - }, - 'gpt-5': { - tier: 'premium', - priority: 99, - recommended: false, - lowQuality: false - }, - 'google/gemini-2.5-pro': { - tier: 'premium', - priority: 96, - recommended: false, - lowQuality: false - }, - 'grok-4': { - tier: 'premium', - priority: 94, - recommended: false, - lowQuality: false - }, - 'sonnet-3.7': { - tier: 'premium', - priority: 93, - recommended: false, - lowQuality: false - }, - 'sonnet-3.5': { - tier: 'premium', - priority: 90, - recommended: false, - lowQuality: false - }, - - 'moonshotai/kimi-k2': { - tier: 'none', - priority: 100, - recommended: true, - lowQuality: false - }, - 'deepseek': { - tier: 'none', - priority: 95, - recommended: false, - lowQuality: false - }, - 'qwen3': { - tier: 'none', - priority: 90, - recommended: false, - lowQuality: false - }, - 'gpt-5-mini': { - tier: 'none', - priority: 85, - recommended: false, - lowQuality: false - }, -}; - -export const canAccessModel = ( - subscriptionStatus: SubscriptionStatus, - requiresSubscription: boolean, -): boolean => { - if (isLocalMode()) { - return true; - } - return subscriptionStatus === 'active' || !requiresSubscription; -}; - -export const formatModelName = (name: string): string => { - return name - .split('-') - .map(word => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' '); -}; - -export const getPrefixedModelId = (modelId: string, isCustom: boolean): string => { - if (isCustom && !modelId.startsWith('openrouter/')) { - return `openrouter/${modelId}`; - } - return modelId; -}; - -// Helper to get custom models from localStorage -export const getCustomModels = (): CustomModel[] => { - if (!isLocalMode() || typeof window === 'undefined') return []; - - try { - const storedModels = localStorage.getItem(STORAGE_KEY_CUSTOM_MODELS); - if (!storedModels) return []; - - const parsedModels = JSON.parse(storedModels); - if (!Array.isArray(parsedModels)) return []; - - return parsedModels - .filter((model: any) => - model && typeof model === 'object' && - typeof model.id === 'string' && - typeof model.label === 'string'); - } catch (e) { - console.error('Error parsing custom models:', e); - return []; - } -}; - -// Helper to save model preference to localStorage safely -const saveModelPreference = (modelId: string): void => { - try { - localStorage.setItem(STORAGE_KEY_MODEL, modelId); - console.log('✅ useModelSelection: Saved model preference to localStorage:', modelId); - } catch (error) { - console.warn('❌ useModelSelection: Failed to save model preference to localStorage:', error); - } -}; - -export const useModelSelectionOld = () => { - const [selectedModel, setSelectedModel] = useState(DEFAULT_FREE_MODEL_ID); - const [customModels, setCustomModels] = useState([]); - const [hasInitialized, setHasInitialized] = useState(false); - - const { data: subscriptionData } = useSubscriptionData(); - const { data: modelsData, isLoading: isLoadingModels } = useAvailableModels({ - refetchOnMount: false, - }); - - const subscriptionStatus: SubscriptionStatus = (subscriptionData?.status === 'active' || subscriptionData?.status === 'trialing') - ? 'active' - : 'no_subscription'; - - // Function to refresh custom models from localStorage - const refreshCustomModels = () => { - if (isLocalMode() && typeof window !== 'undefined') { - const freshCustomModels = getCustomModels(); - setCustomModels(freshCustomModels); - } - }; - - // Load custom models from localStorage - useEffect(() => { - refreshCustomModels(); - }, []); - - // Generate model options list with consistent structure - const MODEL_OPTIONS = useMemo(() => { - let models = []; - - // Default models if API data not available - if (!modelsData?.models || isLoadingModels) { - models = [ - { - id: DEFAULT_FREE_MODEL_ID, - label: 'KIMI K2', - requiresSubscription: false, - priority: 100, - recommended: true - }, - { - id: DEFAULT_PREMIUM_MODEL_ID, - label: 'Claude Sonnet 4', - requiresSubscription: true, - priority: 100, - recommended: true - }, - ]; - } else { - // Process API-provided models - use clean data from new backend system - models = modelsData.models.map(model => { - // Use the clean data directly from the API (no more duplicates!) - const shortName = model.short_name || model.id; - const displayName = model.display_name || shortName; - - return { - id: shortName, - label: displayName, - requiresSubscription: model.requires_subscription || false, - priority: model.priority || 0, - recommended: model.recommended || false, - top: (model.priority || 0) >= 90, // Mark high-priority models as "top" - lowQuality: false, // All models in new system are quality controlled - capabilities: model.capabilities || [], - contextWindow: model.context_window || 128000 - }; - }); - } - - // Add custom models if in local mode - if (isLocalMode() && customModels.length > 0) { - const customModelOptions = customModels.map(model => ({ - id: model.id, - label: model.label || formatModelName(model.id), - requiresSubscription: false, - top: false, - isCustom: true, - priority: 30, // Low priority by default - lowQuality: false, - recommended: false - })); - - models = [...models, ...customModelOptions]; - } - - // Sort models consistently in one place: - // 1. First by recommended (recommended first) - // 2. Then by priority (higher first) - // 3. Finally by name (alphabetical) - const sortedModels = models.sort((a, b) => { - // First by recommended status - if (a.recommended !== b.recommended) { - return a.recommended ? -1 : 1; - } - - // Then by priority (higher first) - if (a.priority !== b.priority) { - return b.priority - a.priority; - } - - // Finally by name - return a.label.localeCompare(b.label); - }); - return sortedModels; - }, [modelsData, isLoadingModels, customModels]); - - // Get filtered list of models the user can access (no additional sorting) - const availableModels = useMemo(() => { - return isLocalMode() - ? MODEL_OPTIONS - : MODEL_OPTIONS.filter(model => - canAccessModel(subscriptionStatus, model.requiresSubscription) - ); - }, [MODEL_OPTIONS, subscriptionStatus]); - - // Initialize selected model from localStorage ONLY ONCE - useEffect(() => { - if (typeof window === 'undefined' || hasInitialized) return; - - console.log('🔧 useModelSelection: Initializing model selection...'); - console.log('🔧 useModelSelection: isLoadingModels:', isLoadingModels); - console.log('🔧 useModelSelection: subscriptionStatus:', subscriptionStatus); - console.log('🔧 useModelSelection: localStorage test passed:', testLocalStorage()); - - try { - const savedModel = localStorage.getItem(STORAGE_KEY_MODEL); - console.log('🔧 useModelSelection: Saved model from localStorage:', savedModel); - - // If we have a saved model, validate it's still available and accessible - if (savedModel) { - // Wait for models to load before validating - if (isLoadingModels) { - console.log('🔧 useModelSelection: Models still loading, using saved model temporarily:', savedModel); - // Use saved model immediately while waiting for validation - setSelectedModel(savedModel); - setHasInitialized(true); - return; - } - - console.log('🔧 useModelSelection: Available MODEL_OPTIONS:', MODEL_OPTIONS.map(m => ({ id: m.id, requiresSubscription: m.requiresSubscription }))); - - const modelOption = MODEL_OPTIONS.find(option => option.id === savedModel); - const isCustomModel = isLocalMode() && customModels.some(model => model.id === savedModel); - - console.log('🔧 useModelSelection: modelOption found:', modelOption); - console.log('🔧 useModelSelection: isCustomModel:', isCustomModel); - - // Check if saved model is still valid and accessible - if (modelOption || isCustomModel) { - const isAccessible = isLocalMode() || - canAccessModel(subscriptionStatus, modelOption?.requiresSubscription ?? false); - - console.log('🔧 useModelSelection: isAccessible:', isAccessible); - - if (isAccessible) { - console.log('✅ useModelSelection: Using saved model:', savedModel); - setSelectedModel(savedModel); - setHasInitialized(true); - return; - } else { - console.warn('⚠️ useModelSelection: Saved model not accessible with current subscription'); - } - } else { - // Model not found in current options, but preserve it anyway in case it's valid - // This can happen during loading or if the API returns different models - console.warn('⚠️ useModelSelection: Saved model not found in available options, but preserving:', savedModel); - setSelectedModel(savedModel); - setHasInitialized(true); - return; - } - } - - // Fallback to default model - const defaultModel = subscriptionStatus === 'active' ? DEFAULT_PREMIUM_MODEL_ID : DEFAULT_FREE_MODEL_ID; - console.log('🔧 useModelSelection: Using default model:', defaultModel); - console.log('🔧 useModelSelection: Subscription status:', subscriptionStatus, '-> Default:', subscriptionStatus === 'active' ? 'PREMIUM (Claude Sonnet 4)' : 'FREE (KIMi K2)'); - setSelectedModel(defaultModel); - saveModelPreference(defaultModel); - setHasInitialized(true); - - } catch (error) { - console.warn('❌ useModelSelection: Failed to load preferences from localStorage:', error); - const defaultModel = subscriptionStatus === 'active' ? DEFAULT_PREMIUM_MODEL_ID : DEFAULT_FREE_MODEL_ID; - console.log('🔧 useModelSelection: Using fallback default model:', defaultModel); - console.log('🔧 useModelSelection: Subscription status:', subscriptionStatus, '-> Fallback:', subscriptionStatus === 'active' ? 'PREMIUM (Claude Sonnet 4)' : 'FREE (KIMi K2)'); - setSelectedModel(defaultModel); - saveModelPreference(defaultModel); - setHasInitialized(true); - } - }, [subscriptionStatus, isLoadingModels, hasInitialized]); - - // Re-validate saved model after loading completes - useEffect(() => { - if (!hasInitialized || typeof window === 'undefined' || isLoadingModels) return; - - const savedModel = localStorage.getItem(STORAGE_KEY_MODEL); - if (!savedModel || savedModel === selectedModel) return; - - console.log('🔧 useModelSelection: Re-validating saved model after loading:', savedModel); - - const modelOption = MODEL_OPTIONS.find(option => option.id === savedModel); - const isCustomModel = isLocalMode() && customModels.some(model => model.id === savedModel); - - // If the saved model is now invalid, switch to default - if (!modelOption && !isCustomModel) { - console.warn('⚠️ useModelSelection: Saved model is invalid after loading, switching to default'); - const defaultModel = subscriptionStatus === 'active' ? DEFAULT_PREMIUM_MODEL_ID : DEFAULT_FREE_MODEL_ID; - setSelectedModel(defaultModel); - saveModelPreference(defaultModel); - } else if (modelOption && !isLocalMode()) { - // Check subscription access for non-custom models - const isAccessible = canAccessModel(subscriptionStatus, modelOption.requiresSubscription); - if (!isAccessible) { - console.warn('⚠️ useModelSelection: Saved model not accessible after subscription check, switching to default'); - const defaultModel = subscriptionStatus === 'active' ? DEFAULT_PREMIUM_MODEL_ID : DEFAULT_FREE_MODEL_ID; - setSelectedModel(defaultModel); - saveModelPreference(defaultModel); - } - } - }, [isLoadingModels, hasInitialized, MODEL_OPTIONS, customModels, subscriptionStatus]); - - // Re-validate current model when subscription status changes - useEffect(() => { - if (!hasInitialized || typeof window === 'undefined') return; - - console.log('🔧 useModelSelection: Subscription status changed, re-validating current model...'); - console.log('🔧 useModelSelection: Current selected model:', selectedModel); - console.log('🔧 useModelSelection: New subscription status:', subscriptionStatus); - - // Skip validation if models are still loading - if (isLoadingModels) return; - - // Check if current model is still accessible - const modelOption = MODEL_OPTIONS.find(option => option.id === selectedModel); - const isCustomModel = isLocalMode() && customModels.some(model => model.id === selectedModel); - - if (modelOption && !isCustomModel && !isLocalMode()) { - const isAccessible = canAccessModel(subscriptionStatus, modelOption.requiresSubscription); - - if (!isAccessible) { - console.warn('⚠️ useModelSelection: Current model no longer accessible, switching to default'); - const defaultModel = subscriptionStatus === 'active' ? DEFAULT_PREMIUM_MODEL_ID : DEFAULT_FREE_MODEL_ID; - console.log('🔧 useModelSelection: Subscription-based default switch:', subscriptionStatus === 'active' ? 'PREMIUM (Claude Sonnet 4)' : 'FREE (KIMi K2)'); - setSelectedModel(defaultModel); - saveModelPreference(defaultModel); - } else { - console.log('✅ useModelSelection: Current model still accessible'); - } - } - }, [subscriptionStatus, selectedModel, hasInitialized, isLoadingModels]); - - // Handle model selection change - const handleModelChange = (modelId: string) => { - console.log('🔧 useModelSelection: handleModelChange called with:', modelId); - console.log('🔧 useModelSelection: Available MODEL_OPTIONS:', MODEL_OPTIONS.map(m => m.id)); - - // Refresh custom models from localStorage to ensure we have the latest - if (isLocalMode()) { - refreshCustomModels(); - } - - // First check if it's a custom model in local mode - const isCustomModel = isLocalMode() && customModels.some(model => model.id === modelId); - - // Then check if it's in standard MODEL_OPTIONS - const modelOption = MODEL_OPTIONS.find(option => option.id === modelId); - - console.log('🔧 useModelSelection: modelOption found:', modelOption); - console.log('🔧 useModelSelection: isCustomModel:', isCustomModel); - - // Check if model exists in either custom models or standard options - if (!modelOption && !isCustomModel) { - console.warn('🔧 useModelSelection: Model not found in options:', modelId, MODEL_OPTIONS, isCustomModel, customModels); - - // Reset to default model when the selected model is not found - const defaultModel = isLocalMode() ? DEFAULT_PREMIUM_MODEL_ID : DEFAULT_FREE_MODEL_ID; - console.log('🔧 useModelSelection: Resetting to default model:', defaultModel); - setSelectedModel(defaultModel); - saveModelPreference(defaultModel); - return; - } - - // Check access permissions (except for custom models in local mode) - if (!isCustomModel && !isLocalMode() && - !canAccessModel(subscriptionStatus, modelOption?.requiresSubscription ?? false)) { - console.warn('🔧 useModelSelection: Model not accessible:', modelId); - return; - } - - console.log('✅ useModelSelection: Setting model to:', modelId); - setSelectedModel(modelId); - saveModelPreference(modelId); - console.log('✅ useModelSelection: Model change completed successfully'); - }; - - // Get the actual model ID to send to the backend - const getActualModelId = (modelId: string): string => { - // No need for automatic prefixing in most cases - just return as is - return modelId; - }; - - return { - selectedModel, - setSelectedModel: (modelId: string) => { - handleModelChange(modelId); - }, - subscriptionStatus, - availableModels, - allModels: MODEL_OPTIONS, // Already pre-sorted - customModels, - getActualModelId, - refreshCustomModels, - canAccessModel: (modelId: string) => { - if (isLocalMode()) return true; - const model = MODEL_OPTIONS.find(m => m.id === modelId); - return model ? canAccessModel(subscriptionStatus, model.requiresSubscription) : false; - }, - isSubscriptionRequired: (modelId: string) => { - return MODEL_OPTIONS.find(m => m.id === modelId)?.requiresSubscription || false; - }, - // Debug utility to check current state - debugState: () => { - console.log('🔧 useModelSelection Debug State:'); - console.log(' selectedModel:', selectedModel); - console.log(' hasInitialized:', hasInitialized); - console.log(' subscriptionStatus:', subscriptionStatus); - console.log(' isLoadingModels:', isLoadingModels); - console.log(' localStorage value:', localStorage.getItem(STORAGE_KEY_MODEL)); - console.log(' localStorage test passes:', testLocalStorage()); - console.log(' defaultModel would be:', subscriptionStatus === 'active' ? `${DEFAULT_PREMIUM_MODEL_ID} (Claude Sonnet 4)` : `${DEFAULT_FREE_MODEL_ID} (KIMi K2)`); - console.log(' availableModels:', availableModels.map(m => ({ id: m.id, requiresSubscription: m.requiresSubscription }))); - } - }; -}; - -// Export the new model selection hook -export { useModelSelection } from './_use-model-selection-new'; - -// Export the hook but not any sorting logic - sorting is handled internally \ No newline at end of file diff --git a/frontend/src/components/thread/chat-input/chat-input.tsx b/frontend/src/components/thread/chat-input/chat-input.tsx index cbe3a445..869c03f9 100644 --- a/frontend/src/components/thread/chat-input/chat-input.tsx +++ b/frontend/src/components/thread/chat-input/chat-input.tsx @@ -14,7 +14,7 @@ import { Card, CardContent } from '@/components/ui/card'; import { handleFiles } from './file-upload-handler'; import { MessageInput } from './message-input'; import { AttachmentGroup } from '../attachment-group'; -import { useModelSelection } from './_use-model-selection-new'; +import { useModelSelection } from '@/hooks/use-model-selection'; import { useFileDelete } from '@/hooks/react-query/files'; import { useQueryClient } from '@tanstack/react-query'; import { ToolCallInput } from './floating-tool-preview'; @@ -239,12 +239,8 @@ export const ChatInput = forwardRef( message = message ? `${message}\n\n${fileInfo}` : fileInfo; } - let baseModelName = getActualModelId(selectedModel); - let thinkingEnabled = false; - if (selectedModel.endsWith('-thinking')) { - baseModelName = getActualModelId(selectedModel.replace(/-thinking$/, '')); - thinkingEnabled = true; - } + const baseModelName = getActualModelId(selectedModel); + const thinkingEnabled = false; // Thinking mode removed - use API models directly posthog.capture("task_prompt_submitted", { message }); diff --git a/frontend/src/components/thread/chat-input/message-input.tsx b/frontend/src/components/thread/chat-input/message-input.tsx index 3141ad0e..5830fd85 100644 --- a/frontend/src/components/thread/chat-input/message-input.tsx +++ b/frontend/src/components/thread/chat-input/message-input.tsx @@ -7,7 +7,8 @@ import { UploadedFile } from './chat-input'; import { FileUploadHandler } from './file-upload-handler'; import { VoiceRecorder } from './voice-recorder'; import { UnifiedConfigMenu } from './unified-config-menu'; -import { canAccessModel, SubscriptionStatus } from './_use-model-selection'; +// Note: canAccessModel is now part of useModelSelection hook +export type SubscriptionStatus = 'no_subscription' | 'active'; import { isLocalMode } from '@/lib/config'; import { TooltipContent } from '@/components/ui/tooltip'; import { Tooltip } from '@/components/ui/tooltip'; diff --git a/frontend/src/components/thread/chat-input/unified-config-menu.tsx b/frontend/src/components/thread/chat-input/unified-config-menu.tsx index bc17b2d0..76cd40b1 100644 --- a/frontend/src/components/thread/chat-input/unified-config-menu.tsx +++ b/frontend/src/components/thread/chat-input/unified-config-menu.tsx @@ -17,8 +17,10 @@ import { cn } from '@/lib/utils'; import { Cpu, Search, Check, ChevronDown, Plus, ExternalLink } from 'lucide-react'; import { useAgents } from '@/hooks/react-query/agents/use-agents'; import { KortixLogo } from '@/components/sidebar/kortix-logo'; -import type { ModelOption, SubscriptionStatus } from './_use-model-selection'; -import { MODELS } from './_use-model-selection'; +import type { ModelOption } from '@/hooks/use-model-selection'; + +export type SubscriptionStatus = 'no_subscription' | 'active'; + import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { IntegrationsRegistry } from '@/components/agents/integrations-registry'; diff --git a/frontend/src/hooks/react-query/subscriptions/use-billing.ts b/frontend/src/hooks/react-query/subscriptions/use-billing.ts index 5183bf87..6e6d0d6c 100644 --- a/frontend/src/hooks/react-query/subscriptions/use-billing.ts +++ b/frontend/src/hooks/react-query/subscriptions/use-billing.ts @@ -4,20 +4,10 @@ import { createMutationHook, createQueryHook } from '@/hooks/use-query'; import { createCheckoutSession, checkBillingStatus, - getAvailableModels, CreateCheckoutSessionRequest } from '@/lib/api'; -import { billingApi } from '@/lib/api-enhanced'; -import { modelKeys, usageKeys } from './keys'; -export const useAvailableModels = createQueryHook( - modelKeys.available, - getAvailableModels, - { - staleTime: 10 * 60 * 1000, - refetchOnWindowFocus: false, - } -); +// useAvailableModels has been moved to use-model-selection.ts for better consolidation export const useBillingStatus = createQueryHook( ['billing', 'status'], @@ -46,13 +36,3 @@ export const useCreateCheckoutSession = createMutationHook( } ); -export const useUsageLogs = (page: number = 0, itemsPerPage: number = 1000) => - createQueryHook( - usageKeys.logs(page, itemsPerPage), - () => billingApi.getUsageLogs(page, itemsPerPage), - { - staleTime: 30 * 1000, // 30 seconds - refetchOnMount: true, - refetchOnWindowFocus: false, - } - )(); \ No newline at end of file diff --git a/frontend/src/hooks/react-query/subscriptions/use-model.ts b/frontend/src/hooks/react-query/subscriptions/use-model.ts deleted file mode 100644 index 60a4162e..00000000 --- a/frontend/src/hooks/react-query/subscriptions/use-model.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { createQueryHook } from "@/hooks/use-query"; -import { AvailableModelsResponse, getAvailableModels } from "@/lib/api"; -import { modelKeys } from "./keys"; - -export const useAvailableModels = createQueryHook( - modelKeys.available, - getAvailableModels, - { - staleTime: 5 * 60 * 1000, - refetchOnWindowFocus: false, - retry: 2, - select: (data) => { - return { - ...data, - models: [...data.models].sort((a, b) => - a.display_name.localeCompare(b.display_name) - ), - }; - }, - } - ); \ No newline at end of file diff --git a/frontend/src/hooks/use-model-selection.ts b/frontend/src/hooks/use-model-selection.ts new file mode 100644 index 00000000..0635b602 --- /dev/null +++ b/frontend/src/hooks/use-model-selection.ts @@ -0,0 +1,136 @@ +'use client'; + +import { useModelStore } from '@/lib/stores/model-store'; +import { useSubscriptionData } from '@/contexts/SubscriptionContext'; +import { useEffect, useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { getAvailableModels } from '@/lib/api'; + +export interface ModelOption { + id: string; + label: string; + requiresSubscription: boolean; + description?: string; + priority?: number; + recommended?: boolean; + capabilities?: string[]; + contextWindow?: number; +} + +// Helper function to get default model from API data +const getDefaultModel = (models: ModelOption[], hasActiveSubscription: boolean): string => { + if (hasActiveSubscription) { + // For premium users, find the first recommended model + const recommendedModel = models.find(m => m.recommended); + if (recommendedModel) return recommendedModel.id; + } + + // For free users, find the first non-subscription model with highest priority + const freeModels = models.filter(m => !m.requiresSubscription); + if (freeModels.length > 0) { + const sortedFreeModels = freeModels.sort((a, b) => (b.priority || 0) - (a.priority || 0)); + return sortedFreeModels[0].id; + } + + // Fallback to first available model + return models.length > 0 ? models[0].id : ''; +}; + +export const useModelSelection = () => { + // Fetch models directly in this hook + const { data: modelsData, isLoading } = useQuery({ + queryKey: ['models', 'available'], + queryFn: getAvailableModels, + staleTime: 5 * 60 * 1000, // 5 minutes + refetchOnWindowFocus: false, + retry: 2, + }); + + const { data: subscriptionData } = useSubscriptionData(); + const { selectedModel, setSelectedModel } = useModelStore(); + + // Transform API data to ModelOption format + const availableModels = useMemo(() => { + if (!modelsData?.models) return []; + + return modelsData.models.map(model => ({ + id: model.short_name || model.id, + label: model.display_name || model.short_name || model.id, + requiresSubscription: model.requires_subscription || false, + priority: model.priority || 0, + recommended: model.recommended || false, + capabilities: model.capabilities || [], + contextWindow: model.context_window || 128000, + })).sort((a, b) => { + // Sort by recommended first, then priority, then name + if (a.recommended !== b.recommended) return a.recommended ? -1 : 1; + if (a.priority !== b.priority) return b.priority - a.priority; + return a.label.localeCompare(b.label); + }); + }, [modelsData]); + + // Get accessible models based on subscription + const accessibleModels = useMemo(() => { + const hasActiveSubscription = subscriptionData?.status === 'active' || subscriptionData?.status === 'trialing'; + return availableModels.filter(model => hasActiveSubscription || !model.requiresSubscription); + }, [availableModels, subscriptionData]); + + // Initialize selected model when data loads + useEffect(() => { + if (isLoading || !accessibleModels.length) return; + + // If no model selected or selected model is not accessible, pick default from API data + if (!selectedModel || !accessibleModels.some(m => m.id === selectedModel)) { + const hasActiveSubscription = subscriptionData?.status === 'active' || subscriptionData?.status === 'trialing'; + const defaultModelId = getDefaultModel(availableModels, hasActiveSubscription); + + // Make sure the default model is accessible + const finalModel = accessibleModels.some(m => m.id === defaultModelId) + ? defaultModelId + : accessibleModels[0]?.id; + + if (finalModel) { + console.log('🔧 useModelSelection: Setting API-determined default model:', finalModel); + setSelectedModel(finalModel); + } + } + }, [selectedModel, accessibleModels, availableModels, isLoading, setSelectedModel, subscriptionData]); + + const handleModelChange = (modelId: string) => { + const model = accessibleModels.find(m => m.id === modelId); + if (model) { + console.log('🔧 useModelSelection: Changing model to:', modelId); + setSelectedModel(modelId); + } + }; + + return { + selectedModel, + setSelectedModel: handleModelChange, + availableModels: accessibleModels, + allModels: availableModels, // For compatibility + isLoading, + modelsData, // Expose raw API data for components that need it + subscriptionStatus: (subscriptionData?.status === 'active' || subscriptionData?.status === 'trialing') ? 'active' as const : 'no_subscription' as const, + canAccessModel: (modelId: string) => { + return accessibleModels.some(m => m.id === modelId); + }, + isSubscriptionRequired: (modelId: string) => { + const model = availableModels.find(m => m.id === modelId); + return model?.requiresSubscription || false; + }, + + // Compatibility stubs for custom models (not needed with API-driven approach) + handleModelChange, + customModels: [] as any[], // Empty array since we're not using custom models + addCustomModel: (_model: any) => {}, // No-op + updateCustomModel: (_id: string, _model: any) => {}, // No-op + removeCustomModel: (_id: string) => {}, // No-op + + // Get the actual model ID to send to the backend (no transformation needed now) + getActualModelId: (modelId: string) => modelId, + + // Refresh function for compatibility (no-op since we use API) + refreshCustomModels: () => {}, + }; +}; diff --git a/frontend/src/lib/api-enhanced.ts b/frontend/src/lib/api-enhanced.ts deleted file mode 100644 index 63f84992..00000000 --- a/frontend/src/lib/api-enhanced.ts +++ /dev/null @@ -1,454 +0,0 @@ -import { createClient } from '@/lib/supabase/client'; -import { backendApi, supabaseClient } from './api-client'; -import { handleApiSuccess } from './error-handler'; -import { - Project, - Thread, - Message, - AgentRun, - InitiateAgentResponse, - HealthCheckResponse, - FileInfo, - CreateCheckoutSessionRequest, - CreateCheckoutSessionResponse, - CreatePortalSessionRequest, - SubscriptionStatus, - AvailableModelsResponse, - BillingStatusResponse, - BillingError, - UsageLogsResponse -} from './api'; - -export * from './api'; - -export const projectsApi = { - async getAll(): Promise { - const result = await supabaseClient.execute( - async () => { - const supabase = createClient(); - const { data: userData, error: userError } = await supabase.auth.getUser(); - - if (userError) { - return { data: null, error: userError }; - } - - if (!userData.user) { - return { data: [], error: null }; - } - - const { data, error } = await supabase - .from('projects') - .select('*') - .eq('account_id', userData.user.id); - - if (error) { - if (error.code === '42501' && error.message.includes('has_role_on_account')) { - return { data: [], error: null }; - } - return { data: null, error }; - } - - const mappedProjects: Project[] = (data || []).map((project) => ({ - id: project.project_id, - name: project.name || '', - description: project.description || '', - created_at: project.created_at, - updated_at: project.updated_at, - sandbox: project.sandbox || { - id: '', - pass: '', - vnc_preview: '', - sandbox_url: '', - }, - })); - - return { data: mappedProjects, error: null }; - }, - { operation: 'load projects', resource: 'projects' } - ); - - return result.data || []; - }, - - async getById(projectId: string): Promise { - const result = await supabaseClient.execute( - async () => { - const supabase = createClient(); - const { data, error } = await supabase - .from('projects') - .select('*') - .eq('project_id', projectId) - .single(); - - if (error) { - if (error.code === 'PGRST116') { - return { data: null, error: new Error(`Project not found: ${projectId}`) }; - } - return { data: null, error }; - } - - // Ensure sandbox is active if it exists - if (data.sandbox?.id) { - backendApi.post(`/project/${projectId}/sandbox/ensure-active`, undefined, { - showErrors: false, - errorContext: { silent: true } - }); - } - - const mappedProject: Project = { - id: data.project_id, - name: data.name || '', - description: data.description || '', - is_public: data.is_public || false, - created_at: data.created_at, - sandbox: data.sandbox || { - id: '', - pass: '', - vnc_preview: '', - sandbox_url: '', - }, - }; - - return { data: mappedProject, error: null }; - }, - { operation: 'load project', resource: `project ${projectId}` } - ); - - return result.data || null; - }, - - async create(projectData: { name: string; description: string }, accountId?: string): Promise { - const result = await supabaseClient.execute( - async () => { - const supabase = createClient(); - - if (!accountId) { - const { data: userData, error: userError } = await supabase.auth.getUser(); - if (userError) return { data: null, error: userError }; - if (!userData.user) return { data: null, error: new Error('You must be logged in to create a project') }; - accountId = userData.user.id; - } - - const { data, error } = await supabase - .from('projects') - .insert({ - name: projectData.name, - description: projectData.description || null, - account_id: accountId, - }) - .select() - .single(); - - if (error) return { data: null, error }; - - const project: Project = { - id: data.project_id, - name: data.name, - description: data.description || '', - created_at: data.created_at, - sandbox: { id: '', pass: '', vnc_preview: '' }, - }; - - return { data: project, error: null }; - }, - { operation: 'create project', resource: 'project' } - ); - - return result.data || null; - }, - - async update(projectId: string, data: Partial): Promise { - if (!projectId || projectId === '') { - throw new Error('Cannot update project: Invalid project ID'); - } - - const result = await supabaseClient.execute( - async () => { - const supabase = createClient(); - const { data: updatedData, error } = await supabase - .from('projects') - .update(data) - .eq('project_id', projectId) - .select() - .single(); - - if (error) return { data: null, error }; - if (!updatedData) return { data: null, error: new Error('No data returned from update') }; - - // Dispatch custom event for project updates - if (typeof window !== 'undefined') { - window.dispatchEvent( - new CustomEvent('project-updated', { - detail: { - projectId, - updatedData: { - id: updatedData.project_id, - name: updatedData.name, - description: updatedData.description, - }, - }, - }), - ); - } - - const project: Project = { - id: updatedData.project_id, - name: updatedData.name, - description: updatedData.description || '', - created_at: updatedData.created_at, - sandbox: updatedData.sandbox || { - id: '', - pass: '', - vnc_preview: '', - sandbox_url: '', - }, - }; - - return { data: project, error: null }; - }, - { operation: 'update project', resource: `project ${projectId}` } - ); - return result.data || null; - }, - - async delete(projectId: string): Promise { - const result = await supabaseClient.execute( - async () => { - const supabase = createClient(); - const { error } = await supabase - .from('projects') - .delete() - .eq('project_id', projectId); - - return { data: !error, error }; - }, - { operation: 'delete project', resource: `project ${projectId}` } - ); - return result.success; - }, -}; - -export const threadsApi = { - async getAll(projectId?: string): Promise { - const result = await supabaseClient.execute( - async () => { - const supabase = createClient(); - const { data: userData, error: userError } = await supabase.auth.getUser(); - - if (userError) return { data: null, error: userError }; - if (!userData.user) return { data: [], error: null }; - - let query = supabase.from('threads').select('*').eq('account_id', userData.user.id); - - if (projectId) { - query = query.eq('project_id', projectId); - } - - const { data, error } = await query; - if (error) return { data: null, error }; - - const mappedThreads: Thread[] = (data || []).map((thread) => ({ - thread_id: thread.thread_id, - project_id: thread.project_id, - created_at: thread.created_at, - updated_at: thread.updated_at, - metadata: thread.metadata, - })); - - return { data: mappedThreads, error: null }; - }, - { operation: 'load threads', resource: projectId ? `threads for project ${projectId}` : 'threads' } - ); - - return result.data || []; - }, - - async getById(threadId: string): Promise { - const result = await supabaseClient.execute( - async () => { - const supabase = createClient(); - const { data, error } = await supabase - .from('threads') - .select('*') - .eq('thread_id', threadId) - .single(); - - return { data, error }; - }, - { operation: 'load thread', resource: `thread ${threadId}` } - ); - - return result.data || null; - }, - - async create(projectId: string): Promise { - const result = await supabaseClient.execute( - async () => { - const supabase = createClient(); - const { data: { user } } = await supabase.auth.getUser(); - - if (!user) { - return { data: null, error: new Error('You must be logged in to create a thread') }; - } - - const { data, error } = await supabase - .from('threads') - .insert({ - project_id: projectId, - account_id: user.id, - }) - .select() - .single(); - - return { data, error }; - }, - { operation: 'create thread', resource: 'thread' } - ); - return result.data || null; - }, -}; - -export const agentApi = { - async start( - threadId: string, - options?: { - model_name?: string; - enable_thinking?: boolean; - reasoning_effort?: string; - stream?: boolean; - } - ): Promise<{ agent_run_id: string } | null> { - const result = await backendApi.post( - `/thread/${threadId}/agent/start`, - options, - { - errorContext: { operation: 'start agent', resource: 'AI assistant' }, - timeout: 60000, - } - ); - return result.data || null; - }, - - async stop(agentRunId: string): Promise { - const result = await backendApi.post( - `/agent/${agentRunId}/stop`, - undefined, - { - errorContext: { operation: 'stop agent', resource: 'AI assistant' }, - } - ); - - if (result.success) { - handleApiSuccess('AI assistant stopped'); - } - - return result.success; - }, - - async getStatus(agentRunId: string): Promise { - const result = await backendApi.get( - `/agent/${agentRunId}/status`, - { - errorContext: { operation: 'get agent status', resource: 'AI assistant status' }, - showErrors: false, - } - ); - - return result.data || null; - }, - - async getRuns(threadId: string): Promise { - const result = await backendApi.get( - `/thread/${threadId}/agent/runs`, - { - errorContext: { operation: 'load agent runs', resource: 'conversation history' }, - } - ); - - return result.data || []; - }, -}; - -export const billingApi = { - async getSubscription(): Promise { - const result = await backendApi.get( - '/billing/subscription', - { - errorContext: { operation: 'load subscription', resource: 'billing information' }, - } - ); - - return result.data || null; - }, - - async checkStatus(): Promise { - const result = await backendApi.get( - '/billing/status', - { - errorContext: { operation: 'check billing status', resource: 'account status' }, - } - ); - - return result.data || null; - }, - - async createCheckoutSession(request: CreateCheckoutSessionRequest): Promise { - const result = await backendApi.post( - '/billing/create-checkout-session', - request, - { - errorContext: { operation: 'create checkout session', resource: 'billing' }, - } - ); - - return result.data || null; - }, - - async createPortalSession(request: CreatePortalSessionRequest): Promise<{ url: string } | null> { - const result = await backendApi.post( - '/billing/create-portal-session', - request, - { - errorContext: { operation: 'create portal session', resource: 'billing portal' }, - } - ); - - return result.data || null; - }, - - async getAvailableModels(): Promise { - const result = await backendApi.get( - '/billing/available-models', - { - errorContext: { operation: 'load available models', resource: 'AI models' }, - } - ); - - return result.data || null; - }, - - async getUsageLogs(page: number = 0, itemsPerPage: number = 1000): Promise { - const result = await backendApi.get( - `/billing/usage-logs?page=${page}&items_per_page=${itemsPerPage}`, - { - errorContext: { operation: 'load usage logs', resource: 'usage history' }, - } - ); - - return result.data || null; - }, -}; - -export const healthApi = { - async check(): Promise { - const result = await backendApi.get( - '/health', - { - errorContext: { operation: 'check system health', resource: 'system status' }, - timeout: 10000, - } - ); - - return result.data || null; - }, -}; \ No newline at end of file diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index b3345159..b5cdae73 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1841,9 +1841,7 @@ export interface UsageLogEntry { export interface UsageLogsResponse { logs: UsageLogEntry[]; has_more: boolean; - message?: string; - subscription_limit?: number; - cumulative_cost?: number; + total_count?: number; } export interface BillingStatusResponse { @@ -2185,7 +2183,6 @@ export const getAvailableModels = async (): Promise => } }; - export const checkBillingStatus = async (): Promise => { try { const supabase = createClient(); diff --git a/frontend/src/lib/stores/model-store.ts b/frontend/src/lib/stores/model-store.ts index 7ed16346..9427ab6e 100644 --- a/frontend/src/lib/stores/model-store.ts +++ b/frontend/src/lib/stores/model-store.ts @@ -1,142 +1,33 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; -import { isLocalMode } from '@/lib/config'; - -export interface CustomModel { - id: string; - label: string; -} - -export interface ModelOption { - id: string; - label: string; - requiresSubscription: boolean; - description?: string; - top?: boolean; - isCustom?: boolean; - priority?: number; - recommended?: boolean; - capabilities?: string[]; - contextWindow?: number; - backendId?: string; // For mapping display names to backend IDs -} - -export type SubscriptionStatus = 'no_subscription' | 'active'; interface ModelStore { selectedModel: string; - customModels: CustomModel[]; - hasHydrated: boolean; - setSelectedModel: (model: string) => void; - addCustomModel: (model: CustomModel) => void; - updateCustomModel: (id: string, model: CustomModel) => void; - removeCustomModel: (id: string) => void; - setCustomModels: (models: CustomModel[]) => void; - setHasHydrated: (hydrated: boolean) => void; - - getDefaultModel: (subscriptionStatus: SubscriptionStatus) => string; - resetToDefault: (subscriptionStatus: SubscriptionStatus) => void; } -const DEFAULT_FREE_MODEL_ID = 'moonshotai/kimi-k2'; -const DEFAULT_PREMIUM_MODEL_ID = 'claude-sonnet-4'; - export const useModelStore = create()( persist( - (set, get) => ({ - selectedModel: DEFAULT_FREE_MODEL_ID, - customModels: [], - hasHydrated: false, - + (set) => ({ + selectedModel: '', // Will be set by the hook based on API data setSelectedModel: (model: string) => { + console.log('🔧 ModelStore: Setting selected model to:', model); set({ selectedModel: model }); }, - - addCustomModel: (model: CustomModel) => { - const { customModels } = get(); - if (customModels.some(existing => existing.id === model.id)) { - return; - } - const newCustomModels = [...customModels, model]; - set({ customModels: newCustomModels }); - }, - - updateCustomModel: (id: string, model: CustomModel) => { - const { customModels } = get(); - const newCustomModels = customModels.map(existing => - existing.id === id ? model : existing - ); - set({ customModels: newCustomModels }); - }, - - removeCustomModel: (id: string) => { - const { customModels, selectedModel } = get(); - const newCustomModels = customModels.filter(model => model.id !== id); - - const updates: Partial = { customModels: newCustomModels }; - if (selectedModel === id) { - updates.selectedModel = DEFAULT_FREE_MODEL_ID; - } - - set(updates); - }, - - setCustomModels: (models: CustomModel[]) => { - set({ customModels: models }); - }, - - setHasHydrated: (hydrated: boolean) => { - set({ hasHydrated: hydrated }); - }, - - getDefaultModel: (subscriptionStatus: SubscriptionStatus) => { - if (isLocalMode()) { - return DEFAULT_PREMIUM_MODEL_ID; - } - return subscriptionStatus === 'active' ? DEFAULT_PREMIUM_MODEL_ID : DEFAULT_FREE_MODEL_ID; - }, - - resetToDefault: (subscriptionStatus: SubscriptionStatus) => { - const defaultModel = get().getDefaultModel(subscriptionStatus); - set({ selectedModel: defaultModel }); - }, }), { - name: 'suna-model-selection-v2', + name: 'suna-model-selection-v3', partialize: (state) => ({ selectedModel: state.selectedModel, - customModels: state.customModels, }), - onRehydrateStorage: () => (state) => { - if (state) { - state.setHasHydrated(true); - } - }, } ) ); -export const canAccessModel = ( - subscriptionStatus: SubscriptionStatus, - requiresSubscription: boolean, -): boolean => { - if (isLocalMode()) { - return true; - } - return subscriptionStatus === 'active' || !requiresSubscription; -}; - +// Utility functions for compatibility export const formatModelName = (name: string): string => { return name .split('-') .map(word => word.charAt(0).toUpperCase() + word.slice(1)) .join(' '); }; - -export const getPrefixedModelId = (modelId: string, isCustom: boolean): string => { - if (isCustom && !modelId.startsWith('openrouter/')) { - return `openrouter/${modelId}`; - } - return modelId; -}; \ No newline at end of file