feat: add fetch model function

This commit is contained in:
2026-08-15 10:35:55 +07:00
parent 570512549d
commit 4bcf5eb132
5 changed files with 340 additions and 31 deletions
+61 -16
View File
@@ -1,5 +1,5 @@
import { useState, useMemo } from 'react' import { useState, useMemo, useEffect, useCallback } from 'react'
import type { ToolId, ConnectionConfig } from './types' import type { ToolId, ConnectionConfig, Model } from './types'
import { mockModels } from './data/mockModels' import { mockModels } from './data/mockModels'
import { tools, toolList } from './data/tools' import { tools, toolList } from './data/tools'
import { Header } from './components/Header' import { Header } from './components/Header'
@@ -7,21 +7,56 @@ import { ConnectionForm } from './components/ConnectionForm'
import { ModelList } from './components/ModelList' import { ModelList } from './components/ModelList'
import { ToolSelector } from './components/ToolSelector' import { ToolSelector } from './components/ToolSelector'
import { ConfigPreview } from './components/ConfigPreview' import { ConfigPreview } from './components/ConfigPreview'
import { fetchRemoteModels } from './services/modelService'
export function App() { export function App() {
const [connection, setConnection] = useState<ConnectionConfig>({ const [connection, setConnection] = useState<ConnectionConfig>({
baseUrl: 'http://localhost:20128/v1', baseUrl: 'https://9router.nezumi.pw/v1',
apiKey: '', apiKey: 'sk-caa6204550dbdba8-h1x4oy-4f464660',
}) })
// Default select 2 popular models const [models, setModels] = useState<Model[]>(mockModels)
const [selectedModelIds, setSelectedModelIds] = useState<string[]>([ const [selectedModelIds, setSelectedModelIds] = useState<string[]>([])
'cc/claude-sonnet-4.5',
'openai/gpt-4o',
])
const [selectedToolId, setSelectedToolId] = useState<ToolId>('copilot') const [selectedToolId, setSelectedToolId] = useState<ToolId>('copilot')
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [isLoadingModels, setIsLoadingModels] = useState(false)
const [fetchError, setFetchError] = useState<string | null>(null)
const [isLive, setIsLive] = useState(false)
const handleFetchModels = useCallback(
async (overrideConfig?: ConnectionConfig) => {
const cfg = overrideConfig || connection
if (!cfg.baseUrl) return
setIsLoadingModels(true)
setFetchError(null)
try {
const fetched = await fetchRemoteModels(cfg)
if (fetched.length > 0) {
setModels(fetched)
setIsLive(true)
// Default select the first 2-3 models if nothing selected or preserve existing matching IDs
setSelectedModelIds((prev) => {
const validPrev = prev.filter((id) => fetched.some((m) => m.id === id))
if (validPrev.length > 0) return validPrev
return fetched.slice(0, Math.min(3, fetched.length)).map((m) => m.id)
})
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Unknown error'
setFetchError(message)
} finally {
setIsLoadingModels(false)
}
},
[connection]
)
// Auto-fetch initial models on mount if valid baseUrl exists
useEffect(() => {
handleFetchModels()
}, []) // eslint-disable-line react-hooks/exhaustive-deps
const handleToggleModel = (id: string) => { const handleToggleModel = (id: string) => {
setSelectedModelIds((prev) => setSelectedModelIds((prev) =>
@@ -31,7 +66,7 @@ export function App() {
const handleSelectAll = () => { const handleSelectAll = () => {
const q = searchQuery.toLowerCase().trim() const q = searchQuery.toLowerCase().trim()
const visibleIds = mockModels const visibleIds = models
.filter( .filter(
(m) => (m) =>
!q || !q ||
@@ -48,7 +83,7 @@ export function App() {
const handleDeselectAll = () => { const handleDeselectAll = () => {
const q = searchQuery.toLowerCase().trim() const q = searchQuery.toLowerCase().trim()
const visibleIds = new Set( const visibleIds = new Set(
mockModels models
.filter( .filter(
(m) => (m) =>
!q || !q ||
@@ -64,8 +99,8 @@ export function App() {
} }
const selectedModels = useMemo(() => { const selectedModels = useMemo(() => {
return mockModels.filter((m) => selectedModelIds.includes(m.id)) return models.filter((m) => selectedModelIds.includes(m.id))
}, [selectedModelIds]) }, [models, selectedModelIds])
const activeTool = tools[selectedToolId] || tools.copilot const activeTool = tools[selectedToolId] || tools.copilot
@@ -79,17 +114,27 @@ export function App() {
<div className="flex flex-col gap-6 min-w-0"> <div className="flex flex-col gap-6 min-w-0">
<ConnectionForm <ConnectionForm
connection={connection} connection={connection}
onChange={setConnection} onChange={(newConn) => {
setConnection(newConn)
setFetchError(null)
}}
onFetchModels={() => handleFetchModels()}
isLoading={isLoadingModels}
error={fetchError}
modelsCount={models.length}
isLive={isLive}
/> />
<ModelList <ModelList
models={mockModels} models={models}
selectedIds={selectedModelIds} selectedIds={selectedModelIds}
searchQuery={searchQuery} searchQuery={searchQuery}
onSearchChange={setSearchQuery} onSearchChange={setSearchQuery}
onToggleModel={handleToggleModel} onToggleModel={handleToggleModel}
onSelectAll={handleSelectAll} onSelectAll={handleSelectAll}
onDeselectAll={handleDeselectAll} onDeselectAll={handleDeselectAll}
isLoading={isLoadingModels}
isLive={isLive}
/> />
<ToolSelector <ToolSelector
+40 -10
View File
@@ -4,16 +4,26 @@ import { Input } from './ui/Input'
import { Button } from './ui/Button' import { Button } from './ui/Button'
import { Card } from './ui/Card' import { Card } from './ui/Card'
import { Badge } from './ui/Badge' import { Badge } from './ui/Badge'
import { Link2, Key, RefreshCw, Eye, EyeOff } from 'lucide-react' import { Link2, Key, RefreshCw, Eye, EyeOff, AlertCircle } from 'lucide-react'
interface ConnectionFormProps { interface ConnectionFormProps {
connection: ConnectionConfig connection: ConnectionConfig
onChange: (connection: ConnectionConfig) => void onChange: (connection: ConnectionConfig) => void
onFetchModels: () => void
isLoading?: boolean
error?: string | null
modelsCount?: number
isLive?: boolean
} }
export const ConnectionForm: React.FC<ConnectionFormProps> = ({ export const ConnectionForm: React.FC<ConnectionFormProps> = ({
connection, connection,
onChange, onChange,
onFetchModels,
isLoading = false,
error = null,
modelsCount = 0,
isLive = false,
}) => { }) => {
const [showKey, setShowKey] = useState(false) const [showKey, setShowKey] = useState(false)
@@ -24,8 +34,8 @@ export const ConnectionForm: React.FC<ConnectionFormProps> = ({
<h2 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 m-0"> <h2 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 m-0">
1. Gateway Connection 1. Gateway Connection
</h2> </h2>
<Badge variant="brand" size="sm"> <Badge variant={isLive ? 'success' : 'brand'} size="sm">
Local Router {isLive ? 'Live Connected' : 'Local Router'}
</Badge> </Badge>
</div> </div>
<span className="text-xs text-zinc-500 dark:text-zinc-400">OpenAI compatible</span> <span className="text-xs text-zinc-500 dark:text-zinc-400">OpenAI compatible</span>
@@ -62,19 +72,39 @@ export const ConnectionForm: React.FC<ConnectionFormProps> = ({
/> />
</div> </div>
{error && (
<div className="flex items-center gap-2 p-2.5 rounded-lg bg-rose-500/10 border border-rose-500/20 text-rose-600 dark:text-rose-400 text-xs">
<AlertCircle className="w-4 h-4 shrink-0" />
<span className="truncate">{error}</span>
</div>
)}
<div className="flex items-center justify-between pt-1"> <div className="flex items-center justify-between pt-1">
<div className="flex items-center gap-2 text-xs text-zinc-500 dark:text-zinc-400"> <div className="flex items-center gap-2 text-xs text-zinc-500 dark:text-zinc-400">
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" /> <div
<span>Local mock catalog loaded</span> className={`w-2 h-2 rounded-full ${
isLive ? 'bg-emerald-500 animate-pulse' : 'bg-amber-500'
}`}
/>
<span>
{isLive
? `Live connected (${modelsCount} models loaded)`
: 'Local mock catalog loaded'}
</span>
</div> </div>
<Button <Button
variant="outline" variant="primary"
size="sm" size="sm"
disabled onClick={onFetchModels}
icon={<RefreshCw className="w-3.5 h-3.5" />} disabled={isLoading || !connection.baseUrl}
title="Live endpoint fetching will be available in next release" icon={
<RefreshCw
className={`w-3.5 h-3.5 ${isLoading ? 'animate-spin' : ''}`}
/>
}
title="Fetch models from the specified Base URL and API Key"
> >
Fetch Models (Live) {isLoading ? 'Fetching...' : 'Fetch Models'}
</Button> </Button>
</div> </div>
</Card> </Card>
+18 -4
View File
@@ -3,7 +3,7 @@ import type { Model } from '../types'
import { Card } from './ui/Card' import { Card } from './ui/Card'
import { Badge } from './ui/Badge' import { Badge } from './ui/Badge'
import { Input } from './ui/Input' import { Input } from './ui/Input'
import { Search, CheckSquare, Square, Eye, Wrench, Layers } from 'lucide-react' import { Search, CheckSquare, Square, Eye, Wrench, Layers, RefreshCw } from 'lucide-react'
interface ModelListProps { interface ModelListProps {
models: Model[] models: Model[]
@@ -13,6 +13,8 @@ interface ModelListProps {
onToggleModel: (id: string) => void onToggleModel: (id: string) => void
onSelectAll: () => void onSelectAll: () => void
onDeselectAll: () => void onDeselectAll: () => void
isLoading?: boolean
isLive?: boolean
} }
export const ModelList: React.FC<ModelListProps> = ({ export const ModelList: React.FC<ModelListProps> = ({
@@ -23,6 +25,8 @@ export const ModelList: React.FC<ModelListProps> = ({
onToggleModel, onToggleModel,
onSelectAll, onSelectAll,
onDeselectAll, onDeselectAll,
isLoading = false,
isLive = false,
}) => { }) => {
const filteredModels = useMemo(() => { const filteredModels = useMemo(() => {
const q = searchQuery.toLowerCase().trim() const q = searchQuery.toLowerCase().trim()
@@ -48,8 +52,13 @@ export const ModelList: React.FC<ModelListProps> = ({
2. Select Models 2. Select Models
</h2> </h2>
<Badge variant="brand" size="sm"> <Badge variant="brand" size="sm">
{selectedIds.length} selected {selectedIds.length} / {models.length} selected
</Badge> </Badge>
{isLive && (
<Badge variant="success" size="sm">
Live
</Badge>
)}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -71,8 +80,13 @@ export const ModelList: React.FC<ModelListProps> = ({
className="text-xs" className="text-xs"
/> />
<div className="max-h-72 overflow-y-auto pr-1 flex flex-col gap-2"> <div className="max-h-72 overflow-y-auto pr-1 flex flex-col gap-2 relative">
{filteredModels.length === 0 ? ( {isLoading ? (
<div className="p-8 text-center text-xs text-zinc-500 dark:text-zinc-400 flex flex-col items-center justify-center gap-2 border border-zinc-200 dark:border-zinc-800 rounded-lg">
<RefreshCw className="w-5 h-5 animate-spin text-amber-500" />
<span>Fetching models from endpoint...</span>
</div>
) : filteredModels.length === 0 ? (
<div className="p-6 text-center text-xs text-zinc-500 dark:text-zinc-400 border border-dashed border-zinc-200 dark:border-zinc-800 rounded-lg"> <div className="p-6 text-center text-xs text-zinc-500 dark:text-zinc-400 border border-dashed border-zinc-200 dark:border-zinc-800 rounded-lg">
No models matching "{searchQuery}" No models matching "{searchQuery}"
</div> </div>
+166
View File
@@ -0,0 +1,166 @@
import type { ConnectionConfig, Model } from '../types'
interface RawModelCapability {
vision?: boolean
tools?: boolean
reasoning?: boolean
contextWindow?: number
maxOutput?: number
[key: string]: unknown
}
interface RawModelResponseItem {
id: string
object?: string
owned_by?: string
name?: string
capabilities?: RawModelCapability
context_length?: number
max_completion_tokens?: number
description?: string
[key: string]: unknown
}
interface RawModelsResponse {
data?: RawModelResponseItem[]
object?: string
}
function parseProvider(id: string, ownedBy?: string): string {
if (ownedBy) {
if (ownedBy === 'cx') return 'Codex / OpenAI'
if (ownedBy === 'ag') return 'Antigravity / Google'
if (ownedBy === 'anthropic' || ownedBy === 'cc') return 'Anthropic'
if (ownedBy === 'openai') return 'OpenAI'
return ownedBy.toUpperCase()
}
const prefix = id.split('/')[0]?.toLowerCase()
if (prefix === 'cx') return 'Codex'
if (prefix === 'ag') return 'Antigravity'
if (prefix === 'cc') return 'Anthropic'
if (prefix === 'openai') return 'OpenAI'
if (prefix === 'vertex') return 'Google Vertex'
if (prefix === 'deepseek') return 'DeepSeek'
return prefix ? prefix.toUpperCase() : 'Custom'
}
function formatModelName(id: string, rawName?: string): string {
if (rawName && rawName !== id) return rawName
// Remove prefix if present, e.g. "cx/gpt-5.6-sol" -> "gpt-5.6-sol"
const cleanId = id.includes('/') ? id.split('/').slice(1).join('/') : id
return cleanId
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
export async function fetchRemoteModels(connection: ConnectionConfig): Promise<Model[]> {
const trimmedBase = connection.baseUrl.replace(/\/+$/, '')
const isHttpsOrCustom = trimmedBase.startsWith('http://') || trimmedBase.startsWith('https://')
let data: RawModelsResponse | null = null
// 1. Direct fetch with ?key= param if remote requires it or headers
try {
const urlObj = new URL(`${trimmedBase}/models`)
if (connection.apiKey) {
urlObj.searchParams.set('key', connection.apiKey.trim())
}
const directRes = await fetch(urlObj.toString(), {
method: 'GET',
headers: {
Accept: 'application/json',
},
})
if (directRes.ok) {
data = await directRes.json()
}
} catch {
// If direct CORS/network fails, fallback to standard Authorization header or proxy
}
// 2. If direct query-param fetch did not succeed, try standard Bearer header
if (!data) {
try {
const headers: Record<string, string> = {
Accept: 'application/json',
}
if (connection.apiKey?.trim()) {
headers['Authorization'] = `Bearer ${connection.apiKey.trim()}`
}
const directRes = await fetch(`${trimmedBase}/models`, {
method: 'GET',
headers,
})
if (directRes.ok) {
data = await directRes.json()
}
} catch {
// Direct fetch failed (likely CORS on browser), proceed to fallback proxy
}
}
// 3. Fallback to local Vite / Worker proxy if running in dev or support environment
if (!data && isHttpsOrCustom) {
try {
const proxyRes = await fetch('/api/fetch-models', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
baseUrl: connection.baseUrl,
apiKey: connection.apiKey,
}),
})
if (proxyRes.ok) {
data = await proxyRes.json()
} else {
const errJson = await proxyRes.json().catch(() => null)
const errMsg = errJson?.error?.message || errJson?.error || proxyRes.statusText
throw new Error(`Failed to fetch models: ${errMsg}`)
}
} catch (err: unknown) {
if (err instanceof Error) {
throw err
}
}
}
if (!data) {
throw new Error('Unable to connect to gateway models endpoint. Check your Base URL and API Key.')
}
const rawList = Array.isArray(data) ? data : (data.data || [])
return rawList.map((item) => {
const caps = item.capabilities || {}
const contextWindow =
caps.contextWindow ||
item.context_length ||
(item.contextWindow as number) ||
undefined
const vision = caps.vision ?? (item.vision as boolean | undefined) ?? false
const toolCalling = caps.tools ?? caps.toolCalling ?? (item.toolCalling as boolean | undefined) ?? false
return {
id: item.id,
name: item.name || formatModelName(item.id),
provider: parseProvider(item.id, item.owned_by),
family: item.id.split('/').pop()?.replace(/[^a-zA-Z0-9_-]/g, '-'),
contextWindow,
vision,
toolCalling,
description: item.description || (caps.reasoning ? 'Reasoning model with extended thinking capabilities' : undefined),
}
})
}
+55 -1
View File
@@ -2,12 +2,66 @@ import { defineConfig } from 'vite'
import react, { reactCompilerPreset } from '@vitejs/plugin-react' import react, { reactCompilerPreset } from '@vitejs/plugin-react'
import babel from '@rolldown/plugin-babel' import babel from '@rolldown/plugin-babel'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
import type { IncomingMessage, ServerResponse } from 'node:http'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
tailwindcss(), tailwindcss(),
react(), react(),
babel({ presets: [reactCompilerPreset()] }) babel({ presets: [reactCompilerPreset()] }),
{
name: 'router-proxy-middleware',
configureServer(server) {
server.middlewares.use('/api/fetch-models', async (req: IncomingMessage, res: ServerResponse) => {
if (req.method !== 'POST') {
res.statusCode = 405
res.end(JSON.stringify({ error: 'Method not allowed' }))
return
}
let body = ''
req.on('data', (chunk: Buffer) => {
body += chunk.toString()
})
req.on('end', async () => {
try {
const { baseUrl, apiKey } = JSON.parse(body || '{}')
if (!baseUrl) {
res.statusCode = 400
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ error: 'baseUrl is required' }))
return
}
const targetUrl = `${baseUrl.replace(/\/+$/, '')}/models`
const headers: Record<string, string> = {
'Accept': 'application/json',
}
if (apiKey) {
headers['Authorization'] = `Bearer ${apiKey.trim()}`
}
const remoteRes = await fetch(targetUrl, {
method: 'GET',
headers,
})
const remoteData = await remoteRes.json().catch(() => null)
res.statusCode = remoteRes.status
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify(remoteData || {}))
} catch (err: unknown) {
res.statusCode = 500
res.setHeader('Content-Type', 'application/json')
const msg = err instanceof Error ? err.message : 'Internal error'
res.end(JSON.stringify({ error: msg }))
}
})
})
},
},
], ],
}) })