Some checks failed
Deploy to Production / Build and Deploy (push) Has been cancelled
- Fix React bug #33580: remove Suspense boundaries co-located with Link components - Delete settings/loading.tsx and admin/loading.tsx (root cause of race condition) - Convert all admin navigation from Next.js Link to anchor tags - Move admin pages to dedicated (admin) route group - Add AdminHeader matching main header visual design - Add AdminSidebar with anchor-based navigation - Add /api/admin/models route handler (replaces server actions for GET) - Add /api/debug/client-error for server-side browser error reporting - Add useNoteRefreshOptional() to fix crash in AdminHeader - Hide Admin Dashboard menu for non-admin users - Change app icons from yellow to blue (#3A7CA5) matching brand primary - Fix admin search bar width to match main header Made-with: Cursor
108 lines
3.8 KiB
TypeScript
108 lines
3.8 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server'
|
|
import { auth } from '@/auth'
|
|
|
|
async function requireAdmin() {
|
|
const session = await auth()
|
|
if (!session?.user?.id || (session.user as any).role !== 'ADMIN') return null
|
|
return session
|
|
}
|
|
|
|
/**
|
|
* GET /api/admin/models?type=ollama&url=<base_url>
|
|
* GET /api/admin/models?type=custom&url=<base_url>&key=<api_key>&kind=tags|embeddings
|
|
*
|
|
* Route API (not a Server Action) for fetching AI model lists from Ollama or
|
|
* OpenAI-compatible providers. Using a Route Handler instead of a Server Action
|
|
* is the correct architecture for client-side GET requests: Server Actions are
|
|
* for data mutations, and calling them from useEffect pushes items into the
|
|
* App Router's internal action queue, which is drained during render (inside
|
|
* AppRouter's useMemo), triggering React Error #310 when multiple calls stack up.
|
|
*/
|
|
export async function GET(request: NextRequest) {
|
|
if (!(await requireAdmin())) {
|
|
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
|
|
}
|
|
|
|
const { searchParams } = request.nextUrl
|
|
const type = searchParams.get('type')
|
|
const rawUrl = searchParams.get('url') ?? ''
|
|
const apiKey = searchParams.get('key') ?? undefined
|
|
const kind = searchParams.get('kind') ?? 'tags'
|
|
|
|
if (!rawUrl) {
|
|
return NextResponse.json({ success: false, models: [], error: 'url parameter is required' })
|
|
}
|
|
|
|
const baseUrl = rawUrl.replace(/\/$/, '').replace(/\/v1$/, '')
|
|
|
|
try {
|
|
if (type === 'ollama') {
|
|
const res = await fetch(`${baseUrl}/api/tags`, {
|
|
headers: { 'Content-Type': 'application/json' },
|
|
signal: AbortSignal.timeout(5000),
|
|
})
|
|
if (!res.ok) {
|
|
return NextResponse.json({ success: false, models: [], error: `Ollama ${res.status}` })
|
|
}
|
|
const data = await res.json()
|
|
const models: string[] = (data.models ?? []).map((m: { name: string }) => m.name)
|
|
return NextResponse.json({ success: true, models })
|
|
}
|
|
|
|
if (type === 'custom') {
|
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
|
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`
|
|
|
|
if (kind === 'embeddings') {
|
|
// Try provider-specific embeddings endpoint first (e.g. OpenRouter)
|
|
try {
|
|
const embRes = await fetch(`${baseUrl}/v1/embeddings/models`, {
|
|
headers,
|
|
signal: AbortSignal.timeout(8000),
|
|
})
|
|
if (embRes.ok) {
|
|
const embData = await embRes.json()
|
|
const embModels: string[] = (embData.data ?? [])
|
|
.map((m: { id: string }) => m.id)
|
|
.filter(Boolean)
|
|
.sort()
|
|
if (embModels.length > 0) {
|
|
return NextResponse.json({ success: true, models: embModels })
|
|
}
|
|
}
|
|
} catch {
|
|
// Fall through to /v1/models with keyword filter
|
|
}
|
|
}
|
|
|
|
const res = await fetch(`${baseUrl}/v1/models`, {
|
|
headers,
|
|
signal: AbortSignal.timeout(8000),
|
|
})
|
|
if (!res.ok) {
|
|
return NextResponse.json({ success: false, models: [], error: `Provider ${res.status}` })
|
|
}
|
|
|
|
const data = await res.json()
|
|
let models: string[] = (data.data ?? [])
|
|
.map((m: { id: string }) => m.id)
|
|
.filter(Boolean)
|
|
.sort()
|
|
|
|
if (kind === 'embeddings') {
|
|
const keywords = ['embed', 'embedding', 'ada', 'e5', 'bge', 'gte', 'minilm']
|
|
const filtered = models.filter((id) =>
|
|
keywords.some((kw) => id.toLowerCase().includes(kw))
|
|
)
|
|
if (filtered.length > 0) models = filtered
|
|
}
|
|
|
|
return NextResponse.json({ success: true, models })
|
|
}
|
|
|
|
return NextResponse.json({ success: false, models: [], error: `Unknown type: ${type}` })
|
|
} catch (err: any) {
|
|
return NextResponse.json({ success: false, models: [], error: err.message ?? 'Request failed' })
|
|
}
|
|
}
|