| 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}/<model> |
| </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])], |
| })) |
| |
| 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) => { |
| |
| |
| 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, |
| ) |
| |
| |
| 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/<model></code>,{" "} |
| <code>openai_compatible_2/<model></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> |
| ) |
| } |
|
|