mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-30 07:37:39 +00:00
550 lines
25 KiB
TypeScript
550 lines
25 KiB
TypeScript
'use client';
|
|
|
|
import { ModelSelector } from '@/components/ModelSelector';
|
|
import { ModelTester } from '@/components/ModelTester';
|
|
import { ApiEndpointTester } from '@/components/ApiEndpointTester';
|
|
import { ModelSearchFilter } from '@/components/ModelSearchFilter';
|
|
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
|
|
import { AppSidebar } from '@/components/app-sidebar';
|
|
import { SiteHeader } from '@/components/site-header';
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { AdminService } from '@/lib/api/services/admin';
|
|
import { ModelMappingService } from '@/lib/api/services/modelMappings';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { AlertCircle, Users, Globe } from 'lucide-react';
|
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import React, { useMemo, useState } from 'react';
|
|
import type { Model } from '@/lib/api/schemas/models';
|
|
import { groupAndSortModelsByProvider } from '@/lib/utils/modelSort';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Trash2, Plus, Edit2, Save, X } from 'lucide-react';
|
|
|
|
export default function ModelsPage() {
|
|
const [filteredModels, setFilteredModels] = useState<Model[]>([]);
|
|
const [modelMappings, setModelMappings] = useState<Record<string, string>>(
|
|
{}
|
|
);
|
|
const [editingMapping, setEditingMapping] = useState<string | null>(null);
|
|
const [newMapping, setNewMapping] = useState({ from: '', to: '' });
|
|
|
|
const {
|
|
data: modelsData,
|
|
isLoading: isLoadingModels,
|
|
error: modelsError,
|
|
} = useQuery({
|
|
queryKey: ['admin-models-with-providers'],
|
|
queryFn: () => AdminService.getModelsWithProviders(),
|
|
refetchOnWindowFocus: false,
|
|
});
|
|
|
|
const {
|
|
data: mappingsData,
|
|
isLoading: isLoadingMappings,
|
|
error: mappingsError,
|
|
refetch: refetchMappings,
|
|
} = useQuery({
|
|
queryKey: ['model-mappings'],
|
|
queryFn: () => ModelMappingService.getModelMappings(),
|
|
refetchOnWindowFocus: false,
|
|
});
|
|
|
|
React.useEffect(() => {
|
|
if (mappingsData) {
|
|
setModelMappings(mappingsData);
|
|
}
|
|
}, [mappingsData]);
|
|
|
|
const { models = [], groups = [] } = modelsData || {};
|
|
|
|
const groupedModels = useMemo(() => {
|
|
return groupAndSortModelsByProvider(models);
|
|
}, [models]);
|
|
|
|
const groupDataMap = useMemo(() => {
|
|
return new Map(groups.map((group) => [group.provider, group]));
|
|
}, [groups]);
|
|
|
|
const providerInfo = useMemo(() => {
|
|
const allProviders = new Set([
|
|
...Object.keys(groupedModels),
|
|
...groups.map((g) => g.provider),
|
|
]);
|
|
console.log(allProviders);
|
|
|
|
return Array.from(allProviders).map((provider) => {
|
|
const providerModels = groupedModels[provider] || [];
|
|
const groupData = groupDataMap.get(provider);
|
|
const activeModels = providerModels.filter(
|
|
(m) => m.isEnabled && !m.soft_deleted
|
|
).length;
|
|
const totalModels = providerModels.length;
|
|
|
|
return {
|
|
provider,
|
|
activeModels,
|
|
totalModels,
|
|
groupData,
|
|
hasGroupUrl: !!groupData?.group_url,
|
|
hasGroupApiKey: !!groupData?.group_api_key,
|
|
};
|
|
});
|
|
}, [groupedModels, groupDataMap, groups]);
|
|
|
|
const handleAddMapping = async () => {
|
|
if (!newMapping.from || !newMapping.to) return;
|
|
|
|
try {
|
|
await ModelMappingService.createModelMapping({
|
|
from: newMapping.from,
|
|
to: newMapping.to,
|
|
});
|
|
setNewMapping({ from: '', to: '' });
|
|
refetchMappings();
|
|
} catch (error) {
|
|
console.error('Failed to add mapping:', error);
|
|
}
|
|
};
|
|
|
|
const handleDeleteMapping = async (from: string) => {
|
|
try {
|
|
await ModelMappingService.deleteModelMapping(from);
|
|
refetchMappings();
|
|
} catch (error) {
|
|
console.error('Failed to delete mapping:', error);
|
|
}
|
|
};
|
|
|
|
const handleUpdateMapping = async (from: string, to: string) => {
|
|
try {
|
|
await ModelMappingService.updateModelMapping(from, { to });
|
|
setEditingMapping(null);
|
|
refetchMappings();
|
|
} catch (error) {
|
|
console.error('Failed to update mapping:', error);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<SidebarProvider>
|
|
<AppSidebar variant='inset' />
|
|
<SidebarInset>
|
|
<SiteHeader />
|
|
<div className='flex flex-1 flex-col'>
|
|
<div className='@container/main flex flex-1 flex-col gap-4 p-4 md:gap-8 md:p-8'>
|
|
<div className='mb-6 flex items-center justify-between'>
|
|
<h1 className='text-2xl font-bold tracking-tight'>
|
|
Model Management & API Testing
|
|
</h1>
|
|
</div>
|
|
|
|
<Tabs defaultValue='manage' className='w-full'>
|
|
<TabsList className='grid w-full grid-cols-4'>
|
|
<TabsTrigger value='manage'>Manage Models</TabsTrigger>
|
|
<TabsTrigger value='mappings'>Model Mappings</TabsTrigger>
|
|
{/*<TabsTrigger value='test-basic'>Basic Testing</TabsTrigger>
|
|
<TabsTrigger value='test-api'>API Endpoints</TabsTrigger> */}
|
|
</TabsList>
|
|
|
|
<TabsContent value='manage' className='space-y-4'>
|
|
<div className='text-muted-foreground text-sm'>
|
|
Manage your AI models organized by provider groups. Configure
|
|
API keys, and organize models by provider groups.
|
|
</div>
|
|
|
|
{isLoadingModels ? (
|
|
<div className='space-y-4'>
|
|
<Skeleton className='h-[60px] w-full' />
|
|
<Skeleton className='h-[400px] w-full' />
|
|
</div>
|
|
) : modelsError ? (
|
|
<Alert variant='destructive'>
|
|
<AlertCircle className='h-4 w-4' />
|
|
<AlertDescription>
|
|
Failed to load models. Please try refreshing the page.
|
|
</AlertDescription>
|
|
</Alert>
|
|
) : (
|
|
<Tabs defaultValue='all' className='w-full'>
|
|
<div className='space-y-4'>
|
|
{/* Provider Tabs Navigation */}
|
|
<div className='overflow-x-auto rounded-lg border p-1'>
|
|
<TabsList className='grid w-full max-w-full min-w-max auto-cols-fr grid-flow-col gap-1 sm:gap-2'>
|
|
<TabsTrigger
|
|
value='all'
|
|
className='flex items-center gap-1 text-xs whitespace-nowrap sm:gap-2 sm:text-sm'
|
|
>
|
|
<Globe className='h-3 w-3 sm:h-4 sm:w-4' />
|
|
<span className='hidden sm:inline'>All Models</span>
|
|
<span className='sm:hidden'>All</span>
|
|
<Badge variant='secondary' className='ml-1 text-xs'>
|
|
{models.length}
|
|
</Badge>
|
|
</TabsTrigger>
|
|
{providerInfo.map(
|
|
({ provider, activeModels, totalModels }) => (
|
|
<TabsTrigger
|
|
key={provider}
|
|
value={provider}
|
|
className='flex min-w-fit items-center gap-1 text-xs whitespace-nowrap sm:gap-2 sm:text-sm'
|
|
>
|
|
<Users className='h-3 w-3 sm:h-4 sm:w-4' />
|
|
<span className='max-w-20 truncate sm:max-w-none'>
|
|
{provider}
|
|
</span>
|
|
<div className='flex items-center gap-1'>
|
|
<Badge
|
|
variant='secondary'
|
|
className='ml-1 text-xs'
|
|
>
|
|
{activeModels}/{totalModels}
|
|
</Badge>
|
|
</div>
|
|
</TabsTrigger>
|
|
)
|
|
)}
|
|
</TabsList>
|
|
</div>
|
|
|
|
{/* All Models Tab */}
|
|
<TabsContent value='all'>
|
|
<div className='space-y-4'>
|
|
<div className='text-muted-foreground text-sm'>
|
|
Overview of all models across all provider groups.
|
|
</div>
|
|
<ModelSearchFilter
|
|
models={models}
|
|
onFilteredModelsChange={setFilteredModels}
|
|
/>
|
|
<ModelSelector
|
|
filteredModels={filteredModels}
|
|
showDeleteAllButton={true}
|
|
/>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
{providerInfo.map(
|
|
({ provider, totalModels, groupData }) => {
|
|
const providerModels = groupedModels[provider] || [];
|
|
|
|
return (
|
|
<TabsContent key={provider} value={provider}>
|
|
<div className='space-y-4'>
|
|
<div className='flex items-center justify-between'>
|
|
<div>
|
|
<h3 className='flex items-center gap-2 text-lg font-semibold'>
|
|
<Users className='h-5 w-5' />
|
|
{provider}
|
|
</h3>
|
|
<div className='text-muted-foreground flex items-center gap-4 text-sm'>
|
|
{providerModels.filter(
|
|
(m) => m.soft_deleted
|
|
).length > 0 && (
|
|
<span className='text-orange-600'>
|
|
{
|
|
providerModels.filter(
|
|
(m) => m.soft_deleted
|
|
).length
|
|
}{' '}
|
|
disabled
|
|
</span>
|
|
)}
|
|
{groupData?.group_url && (
|
|
<span className='flex items-center gap-1'>
|
|
<Globe className='h-3 w-3' />
|
|
{groupData.group_url}
|
|
</span>
|
|
)}
|
|
{totalModels === 0 && (
|
|
<span className='text-muted-foreground'>
|
|
No models configured
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{totalModels === 0 && (
|
|
<Alert className='mb-4'>
|
|
<AlertCircle className='h-4 w-4' />
|
|
<AlertDescription>
|
|
<div className='space-y-2'>
|
|
<p className='font-medium'>
|
|
No models found for this provider
|
|
</p>
|
|
<div className='space-y-1 text-sm'>
|
|
<p className='font-medium'>
|
|
Common issues:
|
|
</p>
|
|
<ul className='ml-2 list-inside list-disc space-y-1'>
|
|
<li>
|
|
<strong>API credentials:</strong>{' '}
|
|
Check if the API key is correct
|
|
and has the right permissions
|
|
</li>
|
|
<li>
|
|
<strong>Base URL:</strong> Verify
|
|
the base URL is correct for your
|
|
provider
|
|
</li>
|
|
<li>
|
|
<strong>Network access:</strong>{' '}
|
|
Ensure the server can reach the
|
|
provider's API endpoint
|
|
</li>
|
|
<li>
|
|
<strong>Provider status:</strong>{' '}
|
|
The upstream provider might be
|
|
temporarily unavailable
|
|
</li>
|
|
</ul>
|
|
{groupData?.group_url && (
|
|
<p className='text-muted-foreground mt-2 text-xs'>
|
|
Current endpoint:{' '}
|
|
<code className='bg-muted rounded px-1'>
|
|
{groupData.group_url}
|
|
</code>
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
<ModelSelector
|
|
filterProvider={provider}
|
|
groupData={groupData}
|
|
showProviderActions={true}
|
|
showDeleteAllButton={false}
|
|
/>
|
|
</div>
|
|
</TabsContent>
|
|
);
|
|
}
|
|
)}
|
|
</div>
|
|
</Tabs>
|
|
)}
|
|
</TabsContent>
|
|
|
|
<TabsContent value='mappings' className='space-y-4'>
|
|
<div className='text-muted-foreground text-sm'>
|
|
Manage model ID mappings to redirect requests from one model
|
|
to another. This is useful for maintaining compatibility with
|
|
legacy model names or creating aliases.
|
|
</div>
|
|
|
|
{isLoadingMappings ? (
|
|
<div className='space-y-4'>
|
|
<Skeleton className='h-[200px] w-full' />
|
|
</div>
|
|
) : mappingsError ? (
|
|
<Alert variant='destructive'>
|
|
<AlertCircle className='h-4 w-4' />
|
|
<AlertDescription>
|
|
Failed to load model mappings. Please try refreshing the
|
|
page.
|
|
</AlertDescription>
|
|
</Alert>
|
|
) : (
|
|
<div className='space-y-6'>
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className='flex items-center gap-2'>
|
|
<Plus className='h-5 w-5' />
|
|
Add New Model Mapping
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className='grid grid-cols-1 gap-4 md:grid-cols-3'>
|
|
<Input
|
|
placeholder='From model ID'
|
|
value={newMapping.from}
|
|
onChange={(e) =>
|
|
setNewMapping({
|
|
...newMapping,
|
|
from: e.target.value,
|
|
})
|
|
}
|
|
/>
|
|
<Input
|
|
placeholder='To model ID'
|
|
value={newMapping.to}
|
|
onChange={(e) =>
|
|
setNewMapping({
|
|
...newMapping,
|
|
to: e.target.value,
|
|
})
|
|
}
|
|
/>
|
|
<Button
|
|
onClick={handleAddMapping}
|
|
disabled={!newMapping.from || !newMapping.to}
|
|
className='w-full'
|
|
>
|
|
<Plus className='mr-2 h-4 w-4' />
|
|
Add Mapping
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Current Model Mappings</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{Object.keys(modelMappings).length === 0 ? (
|
|
<div className='text-muted-foreground py-8 text-center'>
|
|
No model mappings configured
|
|
</div>
|
|
) : (
|
|
<div className='space-y-3'>
|
|
{Object.entries(modelMappings).map(([from, to]) => (
|
|
<div
|
|
key={from}
|
|
className='flex items-center justify-between gap-4 rounded-lg border p-4'
|
|
>
|
|
<div className='grid flex-1 grid-cols-1 gap-4 md:grid-cols-2'>
|
|
<div>
|
|
<label className='text-muted-foreground text-sm font-medium'>
|
|
From
|
|
</label>
|
|
<div className='font-mono text-sm'>
|
|
{from}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className='text-muted-foreground text-sm font-medium'>
|
|
To
|
|
</label>
|
|
{editingMapping === from ? (
|
|
<div className='flex items-center gap-2'>
|
|
<Input
|
|
defaultValue={to}
|
|
id={`edit-${from}`}
|
|
className='text-sm'
|
|
/>
|
|
<Button
|
|
size='sm'
|
|
onClick={() => {
|
|
const input =
|
|
document.getElementById(
|
|
`edit-${from}`
|
|
) as HTMLInputElement;
|
|
handleUpdateMapping(
|
|
from,
|
|
input.value
|
|
);
|
|
}}
|
|
>
|
|
<Save className='h-4 w-4' />
|
|
</Button>
|
|
<Button
|
|
size='sm'
|
|
variant='outline'
|
|
onClick={() =>
|
|
setEditingMapping(null)
|
|
}
|
|
>
|
|
<X className='h-4 w-4' />
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className='font-mono text-sm'>
|
|
{to}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{editingMapping !== from && (
|
|
<div className='flex items-center gap-2'>
|
|
<Button
|
|
size='sm'
|
|
variant='outline'
|
|
onClick={() => setEditingMapping(from)}
|
|
>
|
|
<Edit2 className='h-4 w-4' />
|
|
</Button>
|
|
<Button
|
|
size='sm'
|
|
variant='destructive'
|
|
onClick={() => handleDeleteMapping(from)}
|
|
>
|
|
<Trash2 className='h-4 w-4' />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
|
|
<TabsContent value='test-basic' className='space-y-4'>
|
|
<div className='text-muted-foreground text-sm'>
|
|
Test model credentials and connectivity with basic chat
|
|
completion requests through the secure proxy (resolves CORS
|
|
and Docker network issues). Models can be tested even without
|
|
API keys configured (useful for free models or when
|
|
authentication is handled elsewhere).
|
|
</div>
|
|
|
|
{isLoadingModels ? (
|
|
<div className='space-y-4'>
|
|
<Skeleton className='h-[200px] w-full' />
|
|
<Skeleton className='h-[100px] w-full' />
|
|
</div>
|
|
) : modelsError ? (
|
|
<Alert variant='destructive'>
|
|
<AlertCircle className='h-4 w-4' />
|
|
<AlertDescription>
|
|
Failed to load models for testing. Please try refreshing
|
|
the page.
|
|
</AlertDescription>
|
|
</Alert>
|
|
) : (
|
|
<ModelTester models={models} />
|
|
)}
|
|
</TabsContent>
|
|
|
|
<TabsContent value='test-api' className='space-y-4'>
|
|
<div className='text-muted-foreground text-sm'>
|
|
Comprehensive testing of all OpenAI API endpoints including
|
|
chat completions, embeddings, image generation, audio
|
|
synthesis, and model listing through the secure proxy
|
|
(resolves CORS and Docker network issues). Models can be
|
|
tested with or without API keys configured.
|
|
</div>
|
|
|
|
{isLoadingModels ? (
|
|
<div className='space-y-4'>
|
|
<Skeleton className='h-[300px] w-full' />
|
|
<Skeleton className='h-[200px] w-full' />
|
|
</div>
|
|
) : modelsError ? (
|
|
<Alert variant='destructive'>
|
|
<AlertCircle className='h-4 w-4' />
|
|
<AlertDescription>
|
|
Failed to load models for API testing. Please try
|
|
refreshing the page.
|
|
</AlertDescription>
|
|
</Alert>
|
|
) : (
|
|
<ApiEndpointTester models={models} />
|
|
)}
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
</div>
|
|
</SidebarInset>
|
|
</SidebarProvider>
|
|
);
|
|
}
|