diff --git a/src/App.tsx b/src/App.tsx index 3a7bb63..55a2357 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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({ - 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([ - 'cc/claude-sonnet-4.5', - 'openai/gpt-4o', - ]) - + const [models, setModels] = useState(mockModels) + const [selectedModelIds, setSelectedModelIds] = useState([]) const [selectedToolId, setSelectedToolId] = useState('copilot') const [searchQuery, setSearchQuery] = useState('') + const [isLoadingModels, setIsLoadingModels] = useState(false) + const [fetchError, setFetchError] = useState(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() {
{ + setConnection(newConn) + setFetchError(null) + }} + onFetchModels={() => handleFetchModels()} + isLoading={isLoadingModels} + error={fetchError} + modelsCount={models.length} + isLive={isLive} /> void + onFetchModels: () => void + isLoading?: boolean + error?: string | null + modelsCount?: number + isLive?: boolean } export const ConnectionForm: React.FC = ({ 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 = ({

1. Gateway Connection

- - Local Router + + {isLive ? 'Live Connected' : 'Local Router'}
OpenAI compatible @@ -62,19 +72,39 @@ export const ConnectionForm: React.FC = ({ /> + {error && ( +
+ + {error} +
+ )} +
-
- Local mock catalog loaded +
+ + {isLive + ? `Live connected (${modelsCount} models loaded)` + : 'Local mock catalog loaded'} +
diff --git a/src/components/ModelList.tsx b/src/components/ModelList.tsx index 3179a4e..68833c4 100644 --- a/src/components/ModelList.tsx +++ b/src/components/ModelList.tsx @@ -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 = ({ @@ -23,6 +25,8 @@ export const ModelList: React.FC = ({ onToggleModel, onSelectAll, onDeselectAll, + isLoading = false, + isLive = false, }) => { const filteredModels = useMemo(() => { const q = searchQuery.toLowerCase().trim() @@ -48,8 +52,13 @@ export const ModelList: React.FC = ({ 2. Select Models - {selectedIds.length} selected + {selectedIds.length} / {models.length} selected + {isLive && ( + + Live + + )}
@@ -71,8 +80,13 @@ export const ModelList: React.FC = ({ className="text-xs" /> -
- {filteredModels.length === 0 ? ( +
+ {isLoading ? ( +
+ + Fetching models from endpoint... +
+ ) : filteredModels.length === 0 ? (
No models matching "{searchQuery}"
diff --git a/src/services/modelService.ts b/src/services/modelService.ts new file mode 100644 index 0000000..1b3fc2c --- /dev/null +++ b/src/services/modelService.ts @@ -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 { + 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 = { + 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), + } + }) +} diff --git a/vite.config.ts b/vite.config.ts index 1ba605f..b29feeb 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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 = { + '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 })) + } + }) + }) + }, + }, ], })