frontend model selection slight cleanup

This commit is contained in:
marko-kraemer 2025-09-21 18:36:47 +02:00
parent fb30938caa
commit d750a2241f
17 changed files with 180 additions and 1795 deletions

View File

@ -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

View File

@ -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,

View File

@ -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<string>(
'anthropic/claude-sonnet-4-20250514',
@ -223,7 +220,7 @@ export default function PricingPage() {
);
}
if (error) {
if (!modelsResponse && !loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="max-w-md text-center space-y-4">
@ -233,14 +230,9 @@ export default function PricingPage() {
Pricing Unavailable
</h3>
<p className="text-sm text-muted-foreground">
{error instanceof Error
? error.message
: 'Failed to fetch model pricing'}
Failed to fetch model pricing. Please refresh the page.
</p>
</div>
<Button onClick={() => refetch()} size="sm">
Try Again
</Button>
</div>
</div>
);

View File

@ -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<number>(-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 (
<Tooltip key={`model-${model.id}-${index}`}>
@ -411,14 +384,12 @@ export function AgentModelSelector({
<div className="flex items-center gap-2 min-w-0">
<div className="relative flex items-center justify-center">
<Cpu className="h-4 w-4" />
{MODELS[selectedModel]?.lowQuality && (
<AlertTriangle className="h-2.5 w-2.5 text-amber-500 absolute -top-1 -right-1" />
)}
{/* API models are quality controlled - no low quality warning needed */}
</div>
<span className="truncate">{selectedModelDisplay}</span>
</div>
<div className="flex items-center gap-2">
{MODELS[selectedModel]?.recommended && (
{allModels.find(m => m.id === selectedModel)?.recommended && (
<span className="text-[10px] px-1.5 py-0.5 rounded-sm bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-300 font-medium">
Recommended
</span>
@ -438,9 +409,7 @@ export function AgentModelSelector({
>
<div className="relative flex items-center justify-center">
<Cpu className="h-4 w-4" />
{MODELS[selectedModel]?.lowQuality && (
<AlertTriangle className="h-2.5 w-2.5 text-amber-500 absolute -top-1 -right-1" />
)}
{/* API models are quality controlled - no low quality warning needed */}
</div>
<span className="text-sm">{selectedModelDisplay}</span>
</Button>

View File

@ -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<UsageLogEntry[]>([]);
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<string, DailyUsage>,
);
return Object.values(grouped).sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
);
};
if (isLoading && page === 0) {
return (
<Card>
<CardHeader>
<CardTitle>Usage Logs</CardTitle>
<CardDescription>Loading your token usage history...</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
</CardContent>
</Card>
);
}
if (error) {
return (
<Card>
<CardHeader>
<CardTitle>Usage Logs</CardTitle>
</CardHeader>
<CardContent>
<div className="p-4 bg-destructive/10 border border-destructive/20 rounded-lg">
<p className="text-sm text-destructive">
Error: {error.message || 'Failed to load usage logs'}
</p>
</div>
</CardContent>
</Card>
);
}
// Handle local development mode message
if (currentPageData?.message) {
return (
<Card>
<CardHeader>
<CardTitle>Usage Logs</CardTitle>
</CardHeader>
<CardContent>
<div className="p-4 bg-muted/30 border border-border rounded-lg text-center">
<p className="text-sm text-muted-foreground">
{currentPageData.message}
</p>
</div>
</CardContent>
</Card>
);
}
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 (
<div className="space-y-6">
{/* Show credit usage info if user has gone over limit */}
{subscriptionLimit > 0 && totalUsage > subscriptionLimit && (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertTitle>Credits Being Used</AlertTitle>
<AlertDescription>
You've exceeded your monthly subscription limit of ${subscriptionLimit.toFixed(2)}.
Additional usage is being deducted from your credit balance.
</AlertDescription>
</Alert>
)}
{/* Usage Logs Accordion */}
<Card>
<CardHeader>
<CardTitle>Daily Usage Logs</CardTitle>
<CardDescription>
<div className='flex justify-between items-center'>
Your token usage organized by day, sorted by most recent.{" "}
<Button variant='outline' asChild className='text-sm ml-4'>
<Link href="/model-pricing">
View Model Pricing <OpenInNewWindowIcon className='w-4 h-4' />
</Link>
</Button>
</div>
</CardDescription>
</CardHeader>
<CardContent>
{dailyUsage.length === 0 ? (
<div className="text-center py-8">
<p className="text-muted-foreground">No usage logs found.</p>
</div>
) : (
<>
<Accordion type="single" collapsible className="w-full">
{dailyUsage.map((day) => (
<AccordionItem key={day.date} value={day.date}>
<AccordionTrigger className="hover:no-underline">
<div className="flex justify-between items-center w-full mr-4">
<div className="text-left">
<div className="font-semibold">
{formatDateOnly(day.date)}
</div>
<div className="text-sm text-muted-foreground">
{day.requestCount} request
{day.requestCount !== 1 ? 's' : ''} {' '}
{day.models.join(', ')}
</div>
</div>
<div className="text-right">
<div className="font-mono font-semibold">
{formatCost(day.totalCost)}
</div>
<div className="text-sm text-muted-foreground font-mono">
{day.totalTokens.toLocaleString()} tokens
</div>
</div>
</div>
</AccordionTrigger>
<AccordionContent>
<div className="rounded-md border mt-4">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-[180px] text-xs">Time</TableHead>
<TableHead className="text-xs">Model</TableHead>
<TableHead className="text-xs text-right">Prompt</TableHead>
<TableHead className="text-xs text-right">Completion</TableHead>
<TableHead className="text-xs text-right">Total</TableHead>
<TableHead className="text-xs text-right">Cost</TableHead>
<TableHead className="text-xs text-right">Payment</TableHead>
<TableHead className="w-[100px] text-xs text-center">Thread</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{day.logs.map((log, index) => (
<TableRow
key={`${log.message_id}_${index}`}
className="hover:bg-muted/50 group"
>
<TableCell className="font-mono text-xs text-muted-foreground">
{new Date(log.created_at).toLocaleTimeString()}
</TableCell>
<TableCell className="text-xs">
<Badge variant="secondary" className="font-mono text-xs">
{log.content.model.replace('openrouter/', '').replace('anthropic/', '')}
</Badge>
</TableCell>
<TableCell className="text-right font-mono text-xs">
{log.content.usage.prompt_tokens.toLocaleString()}
</TableCell>
<TableCell className="text-right font-mono text-xs">
{log.content.usage.completion_tokens.toLocaleString()}
</TableCell>
<TableCell className="text-right font-mono text-xs">
{log.total_tokens.toLocaleString()}
</TableCell>
<TableCell className="text-right font-mono text-xs">
{formatCost(log.estimated_cost)}
</TableCell>
<TableCell className="text-right text-xs">
{log.payment_method === 'credits' ? (
<div className="flex items-center justify-end gap-2">
<Badge variant="outline" className="text-xs">
Credits
</Badge>
{log.credit_used && log.credit_used > 0 && (
<span className="text-xs text-muted-foreground">
-{formatCreditAmount(log.credit_used)}
</span>
)}
</div>
) : (
<Badge variant="secondary" className="text-xs">
Subscription
</Badge>
)}
</TableCell>
<TableCell className="text-center">
<Button
variant="ghost"
size="sm"
onClick={() => handleThreadClick(log.thread_id, log.project_id)}
className="h-6 px-2 text-xs opacity-0 group-hover:opacity-100 transition-opacity"
>
<ExternalLink className="h-3 w-3" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
{hasMore && (
<div className="flex justify-center pt-6">
<Button
onClick={loadMore}
disabled={isLoading}
variant="outline"
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading...
</>
) : (
'Load More'
)}
</Button>
</div>
)}
</>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -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,

View File

@ -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,
};
};

View File

@ -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<CustomModel[]>([]);
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

View File

@ -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<ChatInputHandles, ChatInputProps>(
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 });

View File

@ -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';

View File

@ -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';

View File

@ -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,
}
)();

View File

@ -1,21 +0,0 @@
import { createQueryHook } from "@/hooks/use-query";
import { AvailableModelsResponse, getAvailableModels } from "@/lib/api";
import { modelKeys } from "./keys";
export const useAvailableModels = createQueryHook<AvailableModelsResponse, Error>(
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)
),
};
},
}
);

View File

@ -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<ModelOption[]>(() => {
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: () => {},
};
};

View File

@ -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<Project[]> {
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<Project | null> {
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<Project | null> {
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<Project>): Promise<Project | null> {
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<boolean> {
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<Thread[]> {
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<Thread | null> {
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<Thread | null> {
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<boolean> {
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<AgentRun | null> {
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<AgentRun[]> {
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<SubscriptionStatus | null> {
const result = await backendApi.get(
'/billing/subscription',
{
errorContext: { operation: 'load subscription', resource: 'billing information' },
}
);
return result.data || null;
},
async checkStatus(): Promise<BillingStatusResponse | null> {
const result = await backendApi.get(
'/billing/status',
{
errorContext: { operation: 'check billing status', resource: 'account status' },
}
);
return result.data || null;
},
async createCheckoutSession(request: CreateCheckoutSessionRequest): Promise<CreateCheckoutSessionResponse | null> {
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<AvailableModelsResponse | null> {
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<UsageLogsResponse | null> {
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<HealthCheckResponse | null> {
const result = await backendApi.get(
'/health',
{
errorContext: { operation: 'check system health', resource: 'system status' },
timeout: 10000,
}
);
return result.data || null;
},
};

View File

@ -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<AvailableModelsResponse> =>
}
};
export const checkBillingStatus = async (): Promise<BillingStatusResponse> => {
try {
const supabase = createClient();

View File

@ -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<ModelStore>()(
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<ModelStore> = { 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;
};