shin / frontend /src /components /OpenAICompatibleView.tsx
shinmentakezo07
feat: theme dashboard charts and expose admin UI on HF Spaces
8bdd0d7
Raw
History Blame Contribute Delete
23.2 kB
import { useEffect, useState } from "react"
import {
Cable,
Check,
ListChecks,
ListPlus,
Loader2,
Plus,
RefreshCw,
Repeat,
Server,
Star,
Trash2,
} from "lucide-react"
import { statusClass } from "@/App"
import type {
AdminConfig,
LocalProviderStatus,
OpenAICompatibleInstance,
} from "@/lib/types"
import { statusDotClass } from "@/lib/status"
import { cn } from "@/lib/utils"
import { Badge } from "./ui/badge"
import { Button } from "./ui/button"
import { Card, CardContent } from "./ui/card"
import { Checkbox } from "./ui/checkbox"
import { Input } from "./ui/input"
import { Label } from "./ui/label"
const INSTANCES_FIELD_KEY = "OPENAI_COMPATIBLE_INSTANCES"
interface OpenAICompatibleViewProps {
config: AdminConfig
values: Record<string, string>
onValuesChange: (values: Record<string, string>) => void
localStatus: Record<string, LocalProviderStatus>
onTestProvider: (providerId: string, done?: () => void) => void
onFetchModels: (providerId: string) => Promise<string[]>
onAddModels: (providerId: string, models: string[]) => void
onMessage: (text: string, kind?: string) => void
}
interface InstanceStatus {
status: string
label: string
baseUrl: string
}
function parseInstances(raw: string | undefined): OpenAICompatibleInstance[] {
if (!raw) return []
try {
const parsed: unknown = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
return parsed.filter(
(entry): entry is OpenAICompatibleInstance =>
entry !== null && typeof entry === "object",
)
} catch {
return []
}
}
function providerIdFor(index: number): string {
return `openai_compatible_${index + 1}`
}
function statusFor(
providerId: string,
config: AdminConfig,
localStatus: Record<string, LocalProviderStatus>,
): InstanceStatus {
const tested = localStatus[providerId]
if (tested) {
return {
status: tested.status,
label: tested.label,
baseUrl: tested.base_url ?? "",
}
}
const descriptor = config.provider_status.find(
(candidate) => candidate.provider_id === providerId,
)
return {
status: descriptor?.status ?? "unknown",
label: descriptor?.label ?? "Not configured",
baseUrl: descriptor?.base_url ?? "",
}
}
interface InstanceCardProps {
index: number
instance: OpenAICompatibleInstance
status: InstanceStatus
locked: boolean
testing: boolean
fetching: boolean
models: string[]
selected: string[]
currentDefault: string
onPatch: (patch: Partial<OpenAICompatibleInstance>) => void
onRemove: () => void
onTest: () => void
onFetch: () => void
onClearModels: () => void
onAddModels: (models: string[]) => void
onToggleModel: (model: string) => void
onUseModel: (model: string) => void
}
function InstanceCard({
index,
instance,
status,
locked,
testing,
fetching,
models,
selected,
currentDefault,
onPatch,
onRemove,
onTest,
onFetch,
onClearModels,
onAddModels,
onToggleModel,
onUseModel,
}: InstanceCardProps) {
const providerId = providerIdFor(index)
const canProbe = !locked && Boolean(instance.base_url.trim())
const [modelDraft, setModelDraft] = useState("")
const addFromDraft = () => {
const ids = modelDraft
.split(",")
.map((id) => id.trim())
.filter(Boolean)
if (ids.length === 0) return
onAddModels(ids)
setModelDraft("")
}
return (
<Card className="overflow-hidden">
<CardContent className="flex flex-col gap-4 py-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-3">
<span className="flex size-9 shrink-0 items-center justify-center rounded-full bg-primary/10 text-sm font-bold text-primary ring-1 ring-primary/20">
{index + 1}
</span>
<div className="min-w-0">
<h4 className="text-base font-semibold leading-tight">
Endpoint {index + 1}
</h4>
<p className="font-mono text-xs text-muted-foreground">
{providerId}/&lt;model&gt;
</p>
</div>
</div>
<div className="flex items-center gap-1.5">
<Badge variant={statusClass(status.status)} className="gap-1.5">
<span className={cn("size-1.5 rounded-full", statusDotClass(status.status))} />
{status.label}
</Badge>
<Button
type="button"
variant="ghost"
size="icon"
disabled={locked}
aria-label={`Remove endpoint ${index + 1}`}
title="Remove endpoint"
onClick={onRemove}
>
<Trash2 />
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="flex flex-col gap-1.5">
<Label htmlFor={`${providerId}-base-url`}>Base URL</Label>
<Input
id={`${providerId}-base-url`}
type="text"
placeholder="https://your-gateway.example/v1"
value={instance.base_url}
disabled={locked}
onChange={(event) => onPatch({ base_url: event.target.value })}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor={`${providerId}-api-keys`}>API keys</Label>
<Input
id={`${providerId}-api-keys`}
type="text"
autoComplete="off"
placeholder="key1, key2"
value={instance.api_keys}
disabled={locked}
onChange={(event) => onPatch({ api_keys: event.target.value })}
/>
<p className="text-xs text-muted-foreground">
Comma-separated keys rotate round-robin with failover. Leave empty
for keyless local servers.
</p>
</div>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor={`${providerId}-proxy`}>Proxy (optional)</Label>
<Input
id={`${providerId}-proxy`}
type="text"
placeholder="http://proxy:8080"
value={instance.proxy}
disabled={locked}
onChange={(event) => onPatch({ proxy: event.target.value })}
/>
</div>
<div className="flex flex-col gap-2 rounded-md border bg-muted/30 p-3">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<p className="flex items-center gap-1.5 text-sm font-medium">
<ListChecks className="size-4 text-muted-foreground" />
{models.length === 0
? "No model ids yet"
: `${models.length} model id${models.length === 1 ? "" : "s"}`}
</p>
{selected.length > 0 ? (
<Badge variant="outline" className="gap-1 px-1.5 py-0 text-[10px]">
<Check className="size-3" />
{selected.length} selected
</Badge>
) : null}
</div>
{models.length > 0 ? (
<Button
type="button"
variant="ghost"
size="sm"
disabled={locked}
onClick={onClearModels}
>
<Trash2 />
Clear
</Button>
) : null}
</div>
<div className="flex gap-2">
<Input
id={`${providerId}-model-id`}
type="text"
autoComplete="off"
placeholder="Add model id, comma-separated (gpt-4o, deepseek-v3, …)"
value={modelDraft}
disabled={locked}
onChange={(event) => setModelDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault()
addFromDraft()
}
}}
/>
<Button
type="button"
variant="secondary"
size="sm"
disabled={locked || !modelDraft.trim()}
onClick={addFromDraft}
>
<ListPlus />
Add
</Button>
</div>
{models.length > 0 ? (
<div className="grid max-h-56 gap-1 overflow-auto">
{models.map((model) => {
// The prefix is optional: a bare id also matches the default.
const isDefault =
model === currentDefault ||
`${providerId}/${model}` === currentDefault
return (
<div
key={model}
className={cn(
"flex items-center gap-2 rounded-md border px-2.5 py-1.5 transition-colors hover:border-border",
isDefault
? "border-emerald-200 bg-emerald-50/70 dark:border-emerald-500/30 dark:bg-emerald-500/10"
: "border-transparent bg-card",
)}
>
<Checkbox
id={`${providerId}-model-${model}`}
checked={selected.includes(model)}
disabled={locked}
aria-label={`Apply model ${model}`}
title={
selected.includes(model)
? "Applied to this endpoint"
: "Select to apply this model"
}
onCheckedChange={() => onToggleModel(model)}
/>
<span className="flex min-w-0 flex-1 items-center gap-1.5">
{isDefault ? (
<Star className="size-3.5 shrink-0 fill-amber-400 text-amber-500" />
) : null}
<span className="truncate font-mono text-xs">{model}</span>
{isDefault ? (
<Badge variant="ok" className="px-1.5 py-0 text-[10px]">
default
</Badge>
) : null}
</span>
<Button
type="button"
variant="ghost"
size="sm"
disabled={locked}
onClick={() => onUseModel(model)}
>
<Star className="size-3.5" />
Use as default
</Button>
</div>
)
})}
</div>
) : (
<p className="text-xs text-muted-foreground">
Add model ids manually, or use <strong>Fetch models</strong> below
to pull them from GET {`{base}`}/models.
</p>
)}
</div>
<div className="flex flex-wrap items-center justify-between gap-2 rounded-md border bg-muted/30 px-3 py-2">
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Cable className="size-3.5" />
{status.baseUrl ? `Resolves to ${status.baseUrl}` : "Not checked yet"}
</p>
<div className="flex items-center gap-2">
<Button
type="button"
variant="secondary"
size="sm"
disabled={!canProbe || fetching}
onClick={onFetch}
title="Fetch model ids from GET {base}/models (uses the saved config, Apply first)"
>
{fetching ? (
<Loader2 className="animate-spin" />
) : (
<RefreshCw />
)}
{fetching ? "Fetching…" : "Fetch models"}
</Button>
<Button
type="button"
variant="secondary"
size="sm"
disabled={!canProbe || testing}
onClick={onTest}
>
{testing ? <Loader2 className="animate-spin" /> : <Cable />}
{testing ? "Testing…" : "Test connection"}
</Button>
</div>
</div>
</CardContent>
</Card>
)
}
export function OpenAICompatibleView({
config,
values,
onValuesChange,
localStatus,
onTestProvider,
onFetchModels,
onAddModels,
onMessage,
}: OpenAICompatibleViewProps) {
const field = config.fields.find(
(candidate) => candidate.key === INSTANCES_FIELD_KEY,
)
const raw = values[INSTANCES_FIELD_KEY] ?? field?.value ?? "[]"
const [instances, setInstances] = useState<OpenAICompatibleInstance[]>(() =>
parseInstances(raw),
)
const [testingId, setTestingId] = useState<string | null>(null)
const [fetchingId, setFetchingId] = useState<string | null>(null)
const [modelsByProvider, setModelsByProvider] = useState<Record<string, string[]>>(
{},
)
useEffect(() => {
setInstances(parseInstances(raw))
}, [raw])
const commit = (next: OpenAICompatibleInstance[]) => {
setInstances(next)
onValuesChange({ ...values, [INSTANCES_FIELD_KEY]: JSON.stringify(next) })
}
const updateInstance = (index: number, patch: Partial<OpenAICompatibleInstance>) => {
commit(
instances.map((instance, i) =>
i === index ? { ...instance, ...patch } : instance,
),
)
}
const addInstance = () => {
commit([...instances, { base_url: "", api_keys: "", proxy: "" }])
}
const removeInstance = (index: number) => {
commit(instances.filter((_, i) => i !== index))
}
const fetchModels = async (index: number) => {
const providerId = providerIdFor(index)
setFetchingId(providerId)
try {
const models = await onFetchModels(providerId)
if (models.length === 0) {
onMessage(`No models found at ${providerId}.`, "warn")
return
}
setModelsByProvider((prev) => ({
...prev,
[providerId]: [...new Set([...(prev[providerId] ?? []), ...models])],
}))
onMessage(`Fetched ${models.length} models from ${providerId}.`, "ok")
} finally {
setFetchingId(null)
}
}
const addModels = (index: number, models: string[]) => {
const providerId = providerIdFor(index)
setModelsByProvider((prev) => ({
...prev,
[providerId]: [...new Set([...(prev[providerId] ?? []), ...models])],
}))
// Manually added ids are deliberately typed, so apply them immediately.
const current = instances[index]?.models ?? []
updateInstance(index, { models: [...new Set([...current, ...models])] })
onAddModels(providerId, models)
onMessage(
`Added ${models.length} model id${models.length === 1 ? "" : "s"} to ${providerId}. Apply to save.`,
"ok",
)
}
const toggleModel = (index: number, model: string) => {
const current = instances[index]?.models ?? []
updateInstance(
index,
current.includes(model)
? { models: current.filter((candidate) => candidate !== model) }
: { models: [...current, model] },
)
}
const useModelAsDefault = (index: number, model: string) => {
// Set the bare id: the prefix is optional, so MODEL=deepseek routes to
// whatever endpoint advertises it (round-robin across duplicates).
onValuesChange({ ...values, MODEL: model })
const current = instances[index]?.models ?? []
if (!current.includes(model)) {
updateInstance(index, { models: [...current, model] })
}
onMessage(`Default model set to ${model}. Apply to save.`, "ok")
}
if (!field) {
return (
<p className="text-sm text-muted-foreground">
OpenAI-Compatible endpoint management is unavailable in this config.
</p>
)
}
const legacyBaseUrl = values["OPENAI_COMPATIBLE_BASE_URL"] ?? ""
const legacyKey = values["OPENAI_COMPATIBLE_API_KEY"] ?? ""
const cardModels = (index: number): string[] => {
const providerId = providerIdFor(index)
return [
...new Set([
...(instances[index]?.models ?? []),
...(modelsByProvider[providerId] ?? []),
]),
]
}
const keyCountFor = (index: number): number =>
(instances[index]?.api_keys ?? "")
.split(",")
.filter((key) => key.trim()).length
const totalModels = instances.reduce(
(sum, _instance, index) => sum + cardModels(index).length,
0,
)
// Model ids served by more than one endpoint rotate provider + key pool
// round-robin at runtime; surface that so it is never a surprise.
const duplicateModelIds = (() => {
const byModel = new Map<string, number[]>()
instances.forEach((_instance, index) => {
cardModels(index).forEach((model) => {
byModel.set(model, [...(byModel.get(model) ?? []), index])
})
})
return [...byModel.entries()]
.filter(([, indexes]) => indexes.length > 1)
.sort(([a], [b]) => a.localeCompare(b))
})()
return (
<section className="space-y-4">
<div>
<h3 className="text-lg font-semibold tracking-tight">Endpoints</h3>
<p className="mt-1 text-sm text-muted-foreground">
Add any OpenAI-compatible server (vLLM, LM Studio, Ollama, Together, or
your own gateway). Each endpoint becomes a numbered provider:{" "}
<code>openai_compatible_1/&lt;model&gt;</code>,{" "}
<code>openai_compatible_2/&lt;model&gt;</code>, … The prefix is
optional — a model id also works bare (<code>MODEL=deepseek</code>) and
resolves to whichever endpoint advertises it; when the same id exists
on several endpoints, requests rotate round-robin across providers and
key pools. Add as many model ids as you want per endpoint,
comma-separated, or use <strong>Fetch models</strong> to pull them from
the endpoint's <code>/models</code> route. Tick the models you want
applied — selected ids are saved with the endpoint on{" "}
<strong>Apply</strong> and appear in the Model Config dropdowns after a
reload.
</p>
<div className="mt-3 flex flex-wrap gap-2">
<Badge variant="outline" className="gap-1.5">
<Server className="size-3.5 text-muted-foreground" />
{instances.length} endpoint{instances.length === 1 ? "" : "s"}
</Badge>
<Badge variant="outline" className="gap-1.5">
<ListChecks className="size-3.5 text-muted-foreground" />
{totalModels} model id{totalModels === 1 ? "" : "s"}
</Badge>
</div>
</div>
{duplicateModelIds.length > 0 ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-500/30 dark:bg-amber-500/10">
<p className="flex items-center gap-2 text-sm font-medium text-amber-800 dark:text-amber-300">
<Repeat className="size-4 shrink-0" />
Model ids shared across endpoints rotate round-robin
</p>
<ul className="mt-2 space-y-1.5 text-sm text-amber-800/90 dark:text-amber-200/90">
{duplicateModelIds.map(([model, indexes]) => (
<li key={model}>
<code className="rounded bg-amber-100 px-1 py-0.5 font-mono text-xs dark:bg-amber-500/15">
{model}
</code>{" "}
is served by{" "}
{indexes
.map(
(index) =>
`Endpoint ${index + 1} (${keyCountFor(index)} key${
keyCountFor(index) === 1 ? "" : "s"
})`,
)
.join(", ")}{" "}
— requests cycle across providers and key pools.
</li>
))}
</ul>
</div>
) : null}
{instances.length === 0 && legacyBaseUrl.trim() ? (
<div className="flex flex-wrap items-center justify-between gap-4 rounded-lg border border-amber-200 bg-amber-50 p-4">
<p className="text-sm text-amber-800">
You already have the legacy <code>OPENAI_COMPATIBLE_BASE_URL</code>{" "}
endpoint configured. Import it as endpoint 1, or keep it and add more
endpoints below.
</p>
<Button
type="button"
variant="secondary"
size="sm"
onClick={() =>
commit([{ base_url: legacyBaseUrl, api_keys: legacyKey, proxy: "" }])
}
>
<ListPlus />
Import as endpoint 1
</Button>
</div>
) : null}
{instances.length === 0 ? (
<Card className="border-dashed">
<CardContent className="flex flex-col items-center gap-3 py-12 text-center">
<span className="flex size-12 items-center justify-center rounded-full bg-primary/10">
<Server className="size-6 text-primary" />
</span>
<p className="text-sm text-muted-foreground">
No endpoints yet. Add your first OpenAI-compatible endpoint.
</p>
<Button type="button" onClick={addInstance}>
<Plus />
Add endpoint
</Button>
</CardContent>
</Card>
) : (
<div className="grid gap-4">
{instances.map((instance, index) => {
const providerId = providerIdFor(index)
return (
<InstanceCard
key={providerId}
index={index}
instance={instance}
status={statusFor(providerId, config, localStatus)}
locked={field.locked}
testing={testingId === providerId}
fetching={fetchingId === providerId}
models={cardModels(index)}
selected={instance.models ?? []}
currentDefault={values["MODEL"] ?? ""}
onPatch={(patch) => updateInstance(index, patch)}
onRemove={() => removeInstance(index)}
onTest={() => {
setTestingId(providerId)
onTestProvider(providerId, () => setTestingId(null))
}}
onFetch={() => void fetchModels(index)}
onClearModels={() => {
setModelsByProvider((prev) => ({ ...prev, [providerId]: [] }))
updateInstance(index, { models: [] })
}}
onAddModels={(models) => addModels(index, models)}
onToggleModel={(model) => toggleModel(index, model)}
onUseModel={(model) => useModelAsDefault(index, model)}
/>
)
})}
<Button
type="button"
variant="outline"
className="w-full border-dashed"
onClick={addInstance}
>
<Plus />
Add endpoint
</Button>
</div>
)}
</section>
)
}