mirror of
https://github.com/Nezumi-2711/9router-config-generate.git
synced 2026-09-22 13:38:32 +00:00
feat: add fetch model function
This commit is contained in:
+61
-16
@@ -1,5 +1,5 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import type { ToolId, ConnectionConfig } from './types'
|
||||
import { useState, useMemo, useEffect, useCallback } from 'react'
|
||||
import type { ToolId, ConnectionConfig, Model } from './types'
|
||||
import { mockModels } from './data/mockModels'
|
||||
import { tools, toolList } from './data/tools'
|
||||
import { Header } from './components/Header'
|
||||
@@ -7,21 +7,56 @@ import { ConnectionForm } from './components/ConnectionForm'
|
||||
import { ModelList } from './components/ModelList'
|
||||
import { ToolSelector } from './components/ToolSelector'
|
||||
import { ConfigPreview } from './components/ConfigPreview'
|
||||
import { fetchRemoteModels } from './services/modelService'
|
||||
|
||||
export function App() {
|
||||
const [connection, setConnection] = useState<ConnectionConfig>({
|
||||
baseUrl: 'http://localhost:20128/v1',
|
||||
apiKey: '',
|
||||
baseUrl: 'https://9router.nezumi.pw/v1',
|
||||
apiKey: 'sk-caa6204550dbdba8-h1x4oy-4f464660',
|
||||
})
|
||||
|
||||
// Default select 2 popular models
|
||||
const [selectedModelIds, setSelectedModelIds] = useState<string[]>([
|
||||
'cc/claude-sonnet-4.5',
|
||||
'openai/gpt-4o',
|
||||
])
|
||||
|
||||
const [models, setModels] = useState<Model[]>(mockModels)
|
||||
const [selectedModelIds, setSelectedModelIds] = useState<string[]>([])
|
||||
const [selectedToolId, setSelectedToolId] = useState<ToolId>('copilot')
|
||||
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) => {
|
||||
setSelectedModelIds((prev) =>
|
||||
@@ -31,7 +66,7 @@ export function App() {
|
||||
|
||||
const handleSelectAll = () => {
|
||||
const q = searchQuery.toLowerCase().trim()
|
||||
const visibleIds = mockModels
|
||||
const visibleIds = models
|
||||
.filter(
|
||||
(m) =>
|
||||
!q ||
|
||||
@@ -48,7 +83,7 @@ export function App() {
|
||||
const handleDeselectAll = () => {
|
||||
const q = searchQuery.toLowerCase().trim()
|
||||
const visibleIds = new Set(
|
||||
mockModels
|
||||
models
|
||||
.filter(
|
||||
(m) =>
|
||||
!q ||
|
||||
@@ -64,8 +99,8 @@ export function App() {
|
||||
}
|
||||
|
||||
const selectedModels = useMemo(() => {
|
||||
return mockModels.filter((m) => selectedModelIds.includes(m.id))
|
||||
}, [selectedModelIds])
|
||||
return models.filter((m) => selectedModelIds.includes(m.id))
|
||||
}, [models, selectedModelIds])
|
||||
|
||||
const activeTool = tools[selectedToolId] || tools.copilot
|
||||
|
||||
@@ -79,17 +114,27 @@ export function App() {
|
||||
<div className="flex flex-col gap-6 min-w-0">
|
||||
<ConnectionForm
|
||||
connection={connection}
|
||||
onChange={setConnection}
|
||||
onChange={(newConn) => {
|
||||
setConnection(newConn)
|
||||
setFetchError(null)
|
||||
}}
|
||||
onFetchModels={() => handleFetchModels()}
|
||||
isLoading={isLoadingModels}
|
||||
error={fetchError}
|
||||
modelsCount={models.length}
|
||||
isLive={isLive}
|
||||
/>
|
||||
|
||||
<ModelList
|
||||
models={mockModels}
|
||||
models={models}
|
||||
selectedIds={selectedModelIds}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
onToggleModel={handleToggleModel}
|
||||
onSelectAll={handleSelectAll}
|
||||
onDeselectAll={handleDeselectAll}
|
||||
isLoading={isLoadingModels}
|
||||
isLive={isLive}
|
||||
/>
|
||||
|
||||
<ToolSelector
|
||||
|
||||
@@ -4,16 +4,26 @@ import { Input } from './ui/Input'
|
||||
import { Button } from './ui/Button'
|
||||
import { Card } from './ui/Card'
|
||||
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 {
|
||||
connection: ConnectionConfig
|
||||
onChange: (connection: ConnectionConfig) => void
|
||||
onFetchModels: () => void
|
||||
isLoading?: boolean
|
||||
error?: string | null
|
||||
modelsCount?: number
|
||||
isLive?: boolean
|
||||
}
|
||||
|
||||
export const ConnectionForm: React.FC<ConnectionFormProps> = ({
|
||||
connection,
|
||||
onChange,
|
||||
onFetchModels,
|
||||
isLoading = false,
|
||||
error = null,
|
||||
modelsCount = 0,
|
||||
isLive = 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">
|
||||
1. Gateway Connection
|
||||
</h2>
|
||||
<Badge variant="brand" size="sm">
|
||||
Local Router
|
||||
<Badge variant={isLive ? 'success' : 'brand'} size="sm">
|
||||
{isLive ? 'Live Connected' : 'Local Router'}
|
||||
</Badge>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{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 gap-2 text-xs text-zinc-500 dark:text-zinc-400">
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
|
||||
<span>Local mock catalog loaded</span>
|
||||
<div
|
||||
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>
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled
|
||||
icon={<RefreshCw className="w-3.5 h-3.5" />}
|
||||
title="Live endpoint fetching will be available in next release"
|
||||
onClick={onFetchModels}
|
||||
disabled={isLoading || !connection.baseUrl}
|
||||
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>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Model } from '../types'
|
||||
import { Card } from './ui/Card'
|
||||
import { Badge } from './ui/Badge'
|
||||
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 {
|
||||
models: Model[]
|
||||
@@ -13,6 +13,8 @@ interface ModelListProps {
|
||||
onToggleModel: (id: string) => void
|
||||
onSelectAll: () => void
|
||||
onDeselectAll: () => void
|
||||
isLoading?: boolean
|
||||
isLive?: boolean
|
||||
}
|
||||
|
||||
export const ModelList: React.FC<ModelListProps> = ({
|
||||
@@ -23,6 +25,8 @@ export const ModelList: React.FC<ModelListProps> = ({
|
||||
onToggleModel,
|
||||
onSelectAll,
|
||||
onDeselectAll,
|
||||
isLoading = false,
|
||||
isLive = false,
|
||||
}) => {
|
||||
const filteredModels = useMemo(() => {
|
||||
const q = searchQuery.toLowerCase().trim()
|
||||
@@ -48,8 +52,13 @@ export const ModelList: React.FC<ModelListProps> = ({
|
||||
2. Select Models
|
||||
</h2>
|
||||
<Badge variant="brand" size="sm">
|
||||
{selectedIds.length} selected
|
||||
{selectedIds.length} / {models.length} selected
|
||||
</Badge>
|
||||
{isLive && (
|
||||
<Badge variant="success" size="sm">
|
||||
Live
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -71,8 +80,13 @@ export const ModelList: React.FC<ModelListProps> = ({
|
||||
className="text-xs"
|
||||
/>
|
||||
|
||||
<div className="max-h-72 overflow-y-auto pr-1 flex flex-col gap-2">
|
||||
{filteredModels.length === 0 ? (
|
||||
<div className="max-h-72 overflow-y-auto pr-1 flex flex-col gap-2 relative">
|
||||
{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">
|
||||
No models matching "{searchQuery}"
|
||||
</div>
|
||||
|
||||
@@ -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
@@ -2,12 +2,66 @@ import { defineConfig } from 'vite'
|
||||
import react, { reactCompilerPreset } from '@vitejs/plugin-react'
|
||||
import babel from '@rolldown/plugin-babel'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
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 }))
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user