FreeLLMAPI / client /src /pages /KeysPage.tsx
Nryn215's picture
Upload folder using huggingface_hub
077865a verified
Raw
History Blame Contribute Delete
22.9 kB
import { useState, useRef, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { apiFetch } from '@/lib/api'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { PageHeader } from '@/components/page-header'
import type { ApiKey, Platform } from '../../../shared/types'
import { Pencil, ExternalLink } from 'lucide-react'
import { formatSqliteUtcToLocalTime } from '@/lib/utils'
// Small "Get API key" external link shown next to a provider (#137).
function GetKeyLink({ url }: { url: string }) {
if (!url) return null
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Get API key
<ExternalLink className="size-3" />
</a>
)
}
// `url` points to each provider's key-management / signup page so the Keys page
// can show a "Get API key" shortcut (#137). OpenCode Zen's key is free from
// opencode.ai/auth — no card needed; billing only applies to paid models (#128).
// `keyless: true` providers (Kilo's anonymous free tier) need no API key — the
// form disables the key field and submits a sentinel the backend stores so
// routing treats the platform as configured.
const PLATFORMS: { value: Platform; label: string; url: string; keyless?: boolean }[] = [
{ value: 'google', label: 'Google AI Studio', url: 'https://aistudio.google.com/apikey' },
{ value: 'groq', label: 'Groq', url: 'https://console.groq.com/keys' },
{ value: 'cerebras', label: 'Cerebras', url: 'https://cloud.cerebras.ai' },
{ value: 'nvidia', label: 'NVIDIA NIM', url: 'https://build.nvidia.com/settings/api-keys' },
{ value: 'mistral', label: 'Mistral', url: 'https://console.mistral.ai/api-keys/' },
{ value: 'openrouter', label: 'OpenRouter', url: 'https://openrouter.ai/keys' },
{ value: 'github', label: 'GitHub Models', url: 'https://github.com/settings/tokens' },
{ value: 'cohere', label: 'Cohere', url: 'https://dashboard.cohere.com/api-keys' },
{ value: 'cloudflare', label: 'Cloudflare Workers AI', url: 'https://dash.cloudflare.com' },
{ value: 'zhipu', label: 'Zhipu AI (Z.ai)', url: 'https://z.ai/manage-apikey/apikey-list' },
{ value: 'ollama', label: 'Ollama Cloud', url: 'https://ollama.com/settings/keys' },
{ value: 'kilo', label: 'Kilo Gateway (no key needed)', url: 'https://app.kilo.ai', keyless: true },
{ value: 'pollinations', label: 'Pollinations (anon ok)', url: 'https://pollinations.ai' },
{ value: 'llm7', label: 'LLM7 (anon ok)', url: 'https://llm7.io' },
{ value: 'huggingface', label: 'HuggingFace Router', url: 'https://huggingface.co/settings/tokens' },
{ value: 'opencode', label: 'OpenCode Zen (free key)', url: 'https://opencode.ai/auth' },
]
// 'custom' is configured through its own form (base URL + model), not the
// generic key dropdown — but it still appears in the grouped provider list.
const CUSTOM_GROUP: { value: Platform; label: string; url: string } = {
value: 'custom',
label: 'Custom (OpenAI-compatible)',
url: '',
}
const statusDot: Record<string, string> = {
healthy: 'bg-emerald-500',
rate_limited: 'bg-amber-500',
invalid: 'bg-rose-500',
error: 'bg-rose-500',
unknown: 'bg-muted-foreground/40',
}
const statusLabel: Record<string, string> = {
healthy: 'healthy',
rate_limited: 'rate-limited',
invalid: 'invalid',
error: 'error',
unknown: 'unchecked',
}
interface HealthPlatform {
platform: string
totalKeys: number
healthyKeys: number
rateLimitedKeys: number
invalidKeys: number
errorKeys: number
unknownKeys: number
}
interface HealthData {
platforms: HealthPlatform[]
keys: { id: number; platform: string; status: string; lastCheckedAt: string | null }[]
}
function UnifiedKeySection() {
const queryClient = useQueryClient()
const [showKey, setShowKey] = useState(false)
const [copied, setCopied] = useState(false)
const { data, isError } = useQuery<{ apiKey: string }>({
queryKey: ['unified-key'],
queryFn: () => apiFetch('/api/settings/api-key'),
})
const regenerate = useMutation({
mutationFn: () => apiFetch('/api/settings/api-key/regenerate', { method: 'POST' }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['unified-key'] }),
})
const apiKey = data?.apiKey ?? ''
const masked = apiKey ? apiKey.slice(0, 13) + '•'.repeat(32) : '…'
const baseUrl = import.meta.env.DEV
? `http://${window.location.hostname}:${__SERVER_PORT__}/v1`
: `${window.location.origin}/v1`
function copy() {
navigator.clipboard.writeText(apiKey)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}
return (
<section className="rounded-3xl border bg-card p-5">
<div className="flex items-start justify-between gap-4 mb-3">
<div>
<h2 className="text-sm font-medium">Your unified API key</h2>
<p className="text-xs text-muted-foreground mt-0.5">
Use this as your OpenAI <code className="font-mono">api_key</code>; it authenticates requests to this proxy.
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => regenerate.mutate()}
disabled={regenerate.isPending || isError}
>
Regenerate
</Button>
</div>
{isError ? (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2.5 text-xs text-destructive">
Can't reach the server on <code className="font-mono">{baseUrl.replace('/v1', '')}</code>. Make sure the
backend is running. <code className="font-mono">npm run dev</code> starts both, and the server logs print
under the <code className="font-mono">server</code> prefix.
</div>
) : (
<div className="flex items-center gap-2">
<code className="flex-1 font-mono text-xs bg-muted px-3 py-2 rounded-lg select-all truncate tabular-nums">
{showKey ? apiKey : masked}
</code>
<Button variant="outline" size="sm" onClick={() => setShowKey(!showKey)}>
{showKey ? 'Hide' : 'Show'}
</Button>
<Button variant="outline" size="sm" onClick={copy}>
{copied ? 'Copied' : 'Copy'}
</Button>
</div>
)}
<div className="mt-4 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-xs">
<span className="text-muted-foreground">Base URL</span>
<code className="font-mono">{baseUrl}</code>
<span className="text-muted-foreground">Chat</span>
<code className="font-mono">/v1/chat/completions</code>
<span className="text-muted-foreground">Responses</span>
<code className="font-mono">/v1/responses</code>
<span className="text-muted-foreground">Embeddings</span>
<code className="font-mono">/v1/embeddings <span className="text-muted-foreground">(model: "auto" or a family from the Embeddings tab)</span></code>
</div>
</section>
)
}
function CustomProviderSection() {
const queryClient = useQueryClient()
const [baseUrl, setBaseUrl] = useState('')
const [model, setModel] = useState('')
const [displayName, setDisplayName] = useState('')
const [apiKey, setApiKey] = useState('')
const addCustom = useMutation({
mutationFn: (body: { baseUrl: string; model: string; displayName?: string; apiKey?: string }) =>
apiFetch('/api/keys/custom', { method: 'POST', body: JSON.stringify(body) }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['keys'] })
queryClient.invalidateQueries({ queryKey: ['health'] })
queryClient.invalidateQueries({ queryKey: ['fallback'] })
queryClient.invalidateQueries({ queryKey: ['models'] })
setModel('')
setDisplayName('')
},
})
const submit = (e: React.FormEvent) => {
e.preventDefault()
if (!baseUrl || !model) return
addCustom.mutate({ baseUrl, model, displayName: displayName || undefined, apiKey: apiKey || undefined })
}
return (
<section>
<h2 className="text-sm font-medium mb-1">Add a custom OpenAI-compatible model</h2>
<p className="text-xs text-muted-foreground mb-3">
Point at any OpenAI-compatible endpoint: llama.cpp, LM Studio, vLLM, a local Ollama, or a remote
gateway. Add each model you want routed; they all share the one endpoint. The API key is optional
(most local servers don't need one).
</p>
<form onSubmit={submit} className="flex flex-wrap items-end gap-3 rounded-3xl border p-4 bg-card">
<div className="space-y-1.5 flex-1 min-w-[240px]">
<Label className="text-xs">Base URL</Label>
<Input
value={baseUrl}
onChange={e => setBaseUrl(e.target.value)}
placeholder="http://127.0.0.1:11434/v1"
className="font-mono text-xs"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Model</Label>
<Input
value={model}
onChange={e => setModel(e.target.value)}
placeholder="qwen3:4b"
className="w-[180px] font-mono text-xs"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Display name</Label>
<Input
value={displayName}
onChange={e => setDisplayName(e.target.value)}
placeholder="optional"
className="w-[150px]"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">API key</Label>
<Input
type="password"
value={apiKey}
onChange={e => setApiKey(e.target.value)}
placeholder="optional"
className="w-[150px] font-mono text-xs"
/>
</div>
<Button type="submit" size="sm" disabled={!baseUrl || !model || addCustom.isPending}>
{addCustom.isPending ? 'Adding…' : 'Add model'}
</Button>
</form>
{addCustom.isError && (
<p className="text-destructive text-xs mt-2">{(addCustom.error as Error).message}</p>
)}
</section>
)
}
export default function KeysPage() {
const queryClient = useQueryClient()
const [platform, setPlatform] = useState<Platform | ''>('')
const [apiKey, setApiKey] = useState('')
const [accountId, setAccountId] = useState('')
const [label, setLabel] = useState('')
const [editingKeyId, setEditingKeyId] = useState<number | null>(null)
const [editingLabel, setEditingLabel] = useState('')
const editInputRef = useRef<HTMLInputElement>(null)
const { data: keys = [], isLoading } = useQuery<ApiKey[]>({
queryKey: ['keys'],
queryFn: () => apiFetch('/api/keys'),
})
const { data: healthData } = useQuery<HealthData>({
queryKey: ['health'],
queryFn: () => apiFetch('/api/health'),
refetchInterval: 30000,
})
const addKey = useMutation({
mutationFn: (body: { platform: string; key: string; label?: string }) =>
apiFetch('/api/keys', { method: 'POST', body: JSON.stringify(body) }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['keys'] })
queryClient.invalidateQueries({ queryKey: ['health'] })
queryClient.invalidateQueries({ queryKey: ['fallback'] })
setPlatform('')
setApiKey('')
setAccountId('')
setLabel('')
},
})
const deleteKey = useMutation({
mutationFn: (id: number) => apiFetch(`/api/keys/${id}`, { method: 'DELETE' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['keys'] })
queryClient.invalidateQueries({ queryKey: ['health'] })
},
})
const checkAll = useMutation({
mutationFn: () => apiFetch('/api/health/check-all', { method: 'POST' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['health'] })
queryClient.invalidateQueries({ queryKey: ['keys'] })
},
})
const checkKey = useMutation({
mutationFn: (keyId: number) => apiFetch(`/api/health/check/${keyId}`, { method: 'POST' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['health'] })
queryClient.invalidateQueries({ queryKey: ['keys'] })
},
})
const togglePlatform = useMutation({
mutationFn: ({ platform, enabled }: { platform: string; enabled: boolean }) =>
apiFetch(`/api/keys/platform/${platform}`, {
method: 'PATCH',
body: JSON.stringify({ enabled }),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['keys'] })
queryClient.invalidateQueries({ queryKey: ['health'] })
queryClient.invalidateQueries({ queryKey: ['fallback'] })
},
})
const updateKey = useMutation({
mutationFn: ({ id, label }: { id: number; label: string }) =>
apiFetch(`/api/keys/${id}`, {
method: 'PATCH',
body: JSON.stringify({ label }),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['keys'] })
setEditingKeyId(null)
setEditingLabel('')
},
})
function startEditing(key: ApiKey) {
setEditingKeyId(key.id)
setEditingLabel(key.label)
}
function cancelEditing() {
setEditingKeyId(null)
setEditingLabel('')
}
function saveEditing(id: number) {
if (editingLabel !== undefined) {
updateKey.mutate({ id, label: editingLabel })
}
}
useEffect(() => {
if (editingKeyId !== null && editInputRef.current) {
editInputRef.current.focus()
}
}, [editingKeyId])
const needsAccountId = platform === 'cloudflare'
const isKeyless = PLATFORMS.find(p => p.value === platform)?.keyless ?? false
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!platform) return
if (!isKeyless && !apiKey) return
if (needsAccountId && !accountId) return
// Keyless providers submit an empty key; the backend stores a sentinel.
const key = isKeyless ? '' : (needsAccountId ? `${accountId}:${apiKey}` : apiKey)
addKey.mutate({ platform, key, label: label || undefined })
}
const healthKeyMap = new Map<number, { status: string; lastCheckedAt: string | null }>()
for (const k of healthData?.keys ?? []) healthKeyMap.set(k.id, k)
const grouped = [...PLATFORMS, CUSTOM_GROUP].map(p => ({
...p,
keys: keys.filter(k => k.platform === p.value),
})).filter(p => p.keys.length > 0)
return (
<div>
<PageHeader
title="Keys"
description="Provider credentials and the unified API key your apps connect with."
actions={
keys.length > 0 && (
<Button variant="outline" size="sm" onClick={() => checkAll.mutate()} disabled={checkAll.isPending}>
{checkAll.isPending ? 'Checking…' : 'Check all'}
</Button>
)
}
/>
<div className="space-y-8">
<UnifiedKeySection />
<section>
<h2 className="text-sm font-medium mb-3">Add a provider key</h2>
<form onSubmit={handleSubmit} className="flex flex-wrap gap-3 rounded-3xl border p-4 bg-card">
<div className="space-y-1.5">
<Label className="text-xs">Platform</Label>
<Select value={platform} onValueChange={(v) => setPlatform(v as Platform)}>
<SelectTrigger className="w-[220px]">
<SelectValue placeholder="Select provider" />
</SelectTrigger>
<SelectContent>
{PLATFORMS.map(p => (
<SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>
))}
</SelectContent>
</Select>
{(() => {
const sel = PLATFORMS.find(p => p.value === platform)
return sel?.url ? <div className="pt-0.5"><GetKeyLink url={sel.url} /></div> : null
})()}
</div>
{needsAccountId && (
<div className="space-y-1.5">
<Label className="text-xs">Account ID</Label>
<Input
value={accountId}
onChange={e => setAccountId(e.target.value)}
placeholder="a1b2c3d4…"
className="w-[200px] font-mono text-xs"
/>
</div>
)}
<div className="space-y-1.5 flex-1 min-w-[240px]">
<Label className="text-xs">{needsAccountId ? 'API token' : 'API key'}</Label>
<Input
type="password"
value={isKeyless ? '' : apiKey}
onChange={e => setApiKey(e.target.value)}
placeholder={isKeyless ? 'No API key needed' : (needsAccountId ? 'Bearer token' : 'paste key here')}
className="font-mono text-xs"
disabled={isKeyless}
/>
{isKeyless && (
<p className="text-[11px] text-muted-foreground">
No API key needed: this provider's free tier is anonymous (rate-limited per IP).
</p>
)}
</div>
<div className="space-y-1.5">
<Label className="text-xs">Label</Label>
<div className="flex flex-wrap items-center space-x-3">
<Input
value={label}
onChange={e => setLabel(e.target.value)}
placeholder="optional"
className="w-[160px]"
/>
<Button type="submit" size="sm" disabled={!platform || (!isKeyless && !apiKey) || (needsAccountId && !accountId) || addKey.isPending}>
{addKey.isPending ? 'Adding…' : isKeyless ? 'Enable' : 'Add key'}
</Button>
</div>
</div>
</form>
{addKey.isError && (
<p className="text-destructive text-xs mt-2">{(addKey.error as Error).message}</p>
)}
</section>
<CustomProviderSection />
<section>
<h2 className="text-sm font-medium mb-3">Configured providers</h2>
{isLoading ? (
<p className="text-sm text-muted-foreground">Loading…</p>
) : keys.length === 0 ? (
<div className="rounded-3xl border border-dashed p-8 text-center">
<p className="text-sm text-muted-foreground">
No provider keys yet. Add one above to start routing.
</p>
</div>
) : (
<div className="space-y-6">
{grouped.map(group => (
<div key={group.value}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Switch
checked={group.keys.some(k => k.enabled)}
onCheckedChange={(checked) =>
togglePlatform.mutate({ platform: group.value, enabled: checked })
}
disabled={togglePlatform.isPending}
/>
<h3 className="text-sm font-medium">{group.label}</h3>
<GetKeyLink url={group.url} />
</div>
<span className="text-xs text-muted-foreground tabular-nums">
{group.keys.length} key{group.keys.length === 1 ? '' : 's'}
</span>
</div>
<div className="rounded-2xl border divide-y bg-card overflow-hidden">
{group.keys.map(k => {
const h = healthKeyMap.get(k.id)
const status = h?.status ?? k.status
const lastChecked = h?.lastCheckedAt
const isEditing = editingKeyId === k.id
return (
<div key={k.id} className="flex items-center gap-3 px-4 py-3 hover:bg-muted/40 transition-colors">
<span className={`size-1.5 rounded-full flex-shrink-0 ${statusDot[status] ?? statusDot.unknown}`} />
<code className="text-xs font-mono flex-shrink-0">{k.maskedKey}</code>
{isEditing ? (
<Input
ref={editInputRef}
value={editingLabel}
onChange={e => setEditingLabel(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') saveEditing(k.id)
if (e.key === 'Escape') cancelEditing()
}}
onBlur={() => saveEditing(k.id)}
className="h-6 w-[160px] text-xs"
disabled={updateKey.isPending}
/>
) : (
<>
{k.label && <span className="text-xs text-muted-foreground">{k.label}</span>}
</>
)}
<span className="text-xs text-muted-foreground">{statusLabel[status] ?? status}</span>
<div className="flex-1" />
{lastChecked && (
<span className="text-[11px] text-muted-foreground tabular-nums">
{formatSqliteUtcToLocalTime(lastChecked, { hour: '2-digit', minute: '2-digit' })}
</span>
)}
{!isEditing && (
<Button variant="ghost" size="xs" onClick={() => startEditing(k)}>
<Pencil className="size-3" />
</Button>
)}
<Button variant="ghost" size="xs" onClick={() => checkKey.mutate(k.id)} disabled={checkKey.isPending}>
Check
</Button>
<Button variant="ghost" size="xs" className="text-muted-foreground hover:text-destructive" onClick={() => deleteKey.mutate(k.id)} disabled={deleteKey.isPending}>
Remove
</Button>
</div>
)
})}
</div>
</div>
))}
</div>
)}
</section>
</div>
</div>
)
}