TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1"use client";2import * as React from "react";3import { Eye, EyeOff, Plus, Trash2, Activity, ClipboardPaste, AlertTriangle, CheckCircle2, XCircle, Info } from "lucide-react";4import { ResponsiveDialog } from "@/components/ui/sheet";5import { Button } from "@/components/ui/button";6import { Input, Field } from "@/components/ui/input";7import { Switch } from "@/components/ui/switch";8import { Badge } from "@/components/ui/badge";9import { toast } from "@/components/ui/toast";10import { api } from "@/lib/client/api";11import { errorMessage } from "@/lib/client/humanize";12import { formatMs, cn } from "@/lib/utils";13import type { PublicEndpoint, ManualModel } from "@/lib/client/types";14import { ENDPOINT_PRESETS } from "./presets";1516interface HeaderRow {17 key: string;18 value: string;19}2021interface FormState {22 name: string;23 baseUrl: string;24 apiKey: string;25 clearKey: boolean;26 headers: HeaderRow[];27 discover: boolean;28 modelsPath: string;29 manualModels: ManualModel[];30}3132const EMPTY: FormState = { name: "", baseUrl: "", apiKey: "", clearKey: false, headers: [], discover: true, modelsPath: "/models", manualModels: [] };3334function fromEndpoint(e: PublicEndpoint): FormState {35 return { name: e.name, baseUrl: e.baseUrl, apiKey: "", clearKey: false, headers: e.headerNames.map((k) => ({ key: k, value: "" })), discover: Boolean(e.modelsPath), modelsPath: e.modelsPath || "/models", manualModels: e.manualModels.map((m) => ({ ...m })) };36}3738/** Client-side hint only — the server runs the authoritative SSRF check. */39export function looksPrivate(raw: string): boolean {40 try {41 const u = new URL(raw);42 const h = u.hostname.replace(/^\[|\]$/g, "").toLowerCase();43 return h === "localhost" || h.endsWith(".localhost") || h.endsWith(".local") || h.endsWith(".internal") || h === "::1" || h.startsWith("fe80:") || h.startsWith("fc") || h.startsWith("fd") || /^(127\.|10\.|192\.168\.|169\.254\.|0\.)/.test(h) || /^172\.(1[6-9]|2\d|3[01])\./.test(h) || !h.includes(".");44 } catch {45 return false;46 }47}4849export interface TestOutcome {50 ok: boolean;51 latencyMs: number;52 modelsAvailable: number;53 error?: string;54 models?: { id: string; displayName: string }[];55}5657export function EndpointSheet({ open, onOpenChange, endpoint, allowPrivate, onSaved }: { open: boolean; onOpenChange: (v: boolean) => void; endpoint: PublicEndpoint | null; allowPrivate: boolean; onSaved: (e: PublicEndpoint, test?: TestOutcome) => void }) {58 const editing = Boolean(endpoint);59 const [form, setForm] = React.useState<FormState>(EMPTY);60 const [preset, setPreset] = React.useState<string>("other");61 const [showKey, setShowKey] = React.useState(false);62 const [busy, setBusy] = React.useState<"save" | "test" | null>(null);63 const [error, setError] = React.useState<string | null>(null);64 const [test, setTest] = React.useState<TestOutcome | null>(null);65 const [canPaste, setCanPaste] = React.useState(false);6667 React.useEffect(() => {68 if (!open) return;69 // eslint-disable-next-line react-hooks/set-state-in-effect70 setForm(endpoint ? fromEndpoint(endpoint) : EMPTY);71 setPreset(endpoint ? ENDPOINT_PRESETS.find((p) => endpoint.baseUrl.startsWith(p.baseUrl.replace(/\/v1$/, "")))?.id ?? "other" : "other");72 setError(null);73 setTest(null);74 setShowKey(false);75 setCanPaste(typeof navigator !== "undefined" && Boolean(navigator.clipboard?.readText));76 }, [open, endpoint]);7778 const patch = (p: Partial<FormState>) => {79 setForm((f) => ({ ...f, ...p }));80 setError(null);81 };8283 const applyPreset = (id: string) => {84 setPreset(id);85 const p = ENDPOINT_PRESETS.find((x) => x.id === id);86 if (!p || id === "other") return;87 patch({ name: form.name || p.name, baseUrl: p.baseUrl, modelsPath: p.modelsPath, discover: true });88 };8990 const privateHost = looksPrivate(form.baseUrl);91 const urlOk = /^https?:\/\/\S+/i.test(form.baseUrl.trim());92 const canSave = form.name.trim().length > 0 && urlOk && (form.discover ? form.modelsPath.trim().length > 0 : form.manualModels.some((m) => m.id.trim()));9394 const payload = () => {95 const headers: Record<string, string> = {};96 for (const h of form.headers) if (h.key.trim() && h.value.trim()) headers[h.key.trim()] = h.value.trim();97 const keepExistingHeaders = editing && form.headers.length > 0 && form.headers.every((h) => !h.value.trim()) && form.headers.every((h) => endpoint!.headerNames.includes(h.key.trim()));98 return {99 name: form.name.trim(),100 baseUrl: form.baseUrl.trim().replace(/\/+$/, ""),101 ...(form.clearKey ? { apiKey: null } : form.apiKey.trim() ? { apiKey: form.apiKey.trim() } : {}),102 ...(keepExistingHeaders ? {} : { headers: Object.keys(headers).length ? headers : null }),103 modelsPath: form.discover ? form.modelsPath.trim() : "",104 manualModels: form.manualModels.filter((m) => m.id.trim()).map((m) => ({ ...m, id: m.id.trim(), displayName: m.displayName?.trim() || undefined, contextTokens: m.contextTokens || undefined })),105 };106 };107108 const save = async (andTest: boolean) => {109 if (!canSave) return;110 setBusy(andTest ? "test" : "save");111 setError(null);112 try {113 if (editing) {114 const { endpoint: saved } = await api<{ endpoint: PublicEndpoint }>(`/api/endpoints/${endpoint!.id}`, { method: "PATCH", json: payload() });115 let outcome: TestOutcome | undefined;116 if (andTest) {117 const r = await api<{ ok: boolean; latencyMs: number; models: { id: string; displayName: string }[]; error?: string; endpoint: PublicEndpoint }>(`/api/endpoints/${endpoint!.id}/sync`, { method: "POST" });118 outcome = { ok: r.ok, latencyMs: r.latencyMs, modelsAvailable: r.models.length, error: r.error, models: r.models };119 setTest(outcome);120 onSaved(r.endpoint, outcome);121 if (r.ok) toast.success("Endpoint reachable", `${r.models.length} models · ${formatMs(r.latencyMs)}`);122 else toast.error("Endpoint test failed", r.error);123 return;124 }125 onSaved(saved);126 toast.success("Endpoint saved");127 onOpenChange(false);128 } else {129 const r = await api<{ endpoint: PublicEndpoint; test?: { ok: boolean; latencyMs: number; modelsAvailable: number; error?: string } }>("/api/endpoints", { method: "POST", json: { ...payload(), validate: andTest } });130 if (r.test) {131 const outcome: TestOutcome = { ...r.test, models: r.endpoint.discoveredModels.map((m) => ({ id: m.id, displayName: m.id })) };132 setTest(outcome);133 onSaved(r.endpoint, outcome);134 if (r.test.ok) toast.success(`${r.endpoint.name} connected`, `${r.test.modelsAvailable} models · ${formatMs(r.test.latencyMs)}`);135 else toast.warning("Saved, but the test failed", r.test.error);136 if (r.test.ok) onOpenChange(false);137 } else {138 onSaved(r.endpoint);139 toast.success("Endpoint saved", "Not tested yet.");140 onOpenChange(false);141 }142 }143 } catch (e) {144 setError(errorMessage(e, "Could not save the endpoint."));145 } finally {146 setBusy(null);147 }148 };149150 const pasteKey = async () => {151 try {152 const t = (await navigator.clipboard.readText()).trim();153 if (t) patch({ apiKey: t, clearKey: false });154 } catch {155 toast.error("Could not read the clipboard");156 }157 };158159 const presetMeta = ENDPOINT_PRESETS.find((p) => p.id === preset);160161 return (162 <ResponsiveDialog163 open={open}164 onOpenChange={(v) => !busy && onOpenChange(v)}165 size="lg"166 snap="full"167 title={editing ? `Edit ${endpoint!.name}` : "Add custom endpoint"}168 description="Any server that speaks the OpenAI Chat Completions API."169 footer={170 <div className="flex flex-wrap gap-2">171 <Button variant="ghost" className="md:mr-auto" onClick={() => onOpenChange(false)} disabled={Boolean(busy)}>172 Cancel173 </Button>174 <Button variant="outline" className="flex-1 md:flex-none" disabled={!canSave} loading={busy === "test"} onClick={() => save(true)}>175 <Activity /> {editing ? "Save & test" : "Save & test connection"}176 </Button>177 <Button variant="accent" className="flex-1 md:flex-none" disabled={!canSave} loading={busy === "save"} onClick={() => save(false)}>178 {editing ? "Save" : "Save without testing"}179 </Button>180 </div>181 }182 >183 <div className="space-y-5 pt-1">184 {!editing ? (185 <div>186 <p className="mb-1.5 text-[12px] font-medium text-fg-muted">Start from a preset</p>187 <div className="-mx-4 flex gap-1.5 overflow-x-auto px-4 pb-1 scrollbar-none md:mx-0 md:flex-wrap md:px-0">188 {ENDPOINT_PRESETS.map((p) => (189 <button key={p.id} type="button" onClick={() => applyPreset(p.id)} aria-pressed={preset === p.id} className={cn("inline-flex h-9 shrink-0 items-center rounded-full border px-3.5 text-[13px] font-medium transition-colors", preset === p.id ? "border-fg bg-fg text-bg" : "border-border bg-bg-elevated text-fg-muted hover:border-border-strong hover:text-fg")}>190 {p.name}191 </button>192 ))}193 </div>194 {presetMeta ? <p className="mt-1.5 text-[12px] leading-4 text-fg-subtle">{presetMeta.hint}</p> : null}195 </div>196 ) : null}197198 <div className="grid gap-4 md:grid-cols-2">199 <Field label="Name" htmlFor="ep-name">200 <Input id="ep-name" value={form.name} onChange={(e) => patch({ name: e.target.value })} placeholder="My Ollama" maxLength={80} className="h-11 text-[16px] md:h-9 md:text-sm" />201 </Field>202 <Field label="Base URL" htmlFor="ep-url" hint="Up to and including /v1." error={form.baseUrl && !urlOk ? "Enter a full http(s) URL." : null}>203 <Input id="ep-url" value={form.baseUrl} onChange={(e) => patch({ baseUrl: e.target.value })} placeholder="http://localhost:11434/v1" inputMode="url" autoCapitalize="off" autoCorrect="off" spellCheck={false} className="h-11 font-mono text-[16px] md:h-9 md:text-sm" />204 </Field>205 </div>206207 {privateHost ? (208 <div className={cn("flex gap-2 rounded-lg px-3 py-2.5 text-xs leading-5", allowPrivate ? "bg-info-soft text-info" : "bg-warning-soft text-warning")}>209 {allowPrivate ? <Info className="mt-0.5 size-3.5 shrink-0" /> : <AlertTriangle className="mt-0.5 size-3.5 shrink-0" />}210 <p>211 {allowPrivate ? (212 <>This server allows private addresses: the URL must be reachable from the machine running PolyLLM, not from your browser.</>213 ) : (214 <>215 <span className="font-medium">Requests are made by the PolyLLM server, not your browser.</span> On www.polyllm.io a localhost or LAN address is unreachable and will be rejected. Expose the server through a tunnel (Cloudflare Tunnel, ngrok, Tailscale Funnel) and use that public URL, or run PolyLLM on your own machine with <code className="font-mono">ALLOW_PRIVATE_ENDPOINTS=1</code>.216 </>217 )}218 </p>219 </div>220 ) : null}221222 <Field label="API key (optional)" htmlFor="ep-key" hint={editing && endpoint!.hasKey && !form.clearKey && !form.apiKey ? `Current key ${endpoint!.keyHint} is kept unless you enter a new one.` : "Sent as Authorization: Bearer. Encrypted at rest, never shown again."}>223 <div className="flex gap-2">224 <div className="relative min-w-0 flex-1">225 <Input id="ep-key" type={showKey ? "text" : "password"} value={form.apiKey} disabled={form.clearKey} onChange={(e) => patch({ apiKey: e.target.value })} placeholder={editing && endpoint!.hasKey ? "Enter a new key to replace" : "Leave empty if the server needs none"} autoComplete="off" autoCapitalize="off" autoCorrect="off" spellCheck={false} className="h-11 pr-11 font-mono text-[16px] md:h-9 md:text-sm" />226 <button type="button" onClick={() => setShowKey((s) => !s)} className="tap absolute right-1.5 top-1/2 -translate-y-1/2 rounded-sm p-1.5 text-fg-subtle hover:bg-bg-muted hover:text-fg" aria-label={showKey ? "Hide key" : "Show key"}>227 {showKey ? <EyeOff className="size-4" /> : <Eye className="size-4" />}228 </button>229 </div>230 {canPaste ? (231 <Button type="button" variant="outline" className="h-11 shrink-0 px-3 md:h-9" onClick={pasteKey} aria-label="Paste key">232 <ClipboardPaste />233 </Button>234 ) : null}235 </div>236 {editing && endpoint!.hasKey ? (237 <label className="mt-2 flex items-center gap-2 text-xs text-fg-muted">238 <Switch size="sm" checked={form.clearKey} onCheckedChange={(v) => patch({ clearKey: v, apiKey: v ? "" : form.apiKey })} aria-label="Remove the stored key" />239 Remove the stored key240 </label>241 ) : null}242 </Field>243244 <div>245 <div className="mb-1.5 flex items-center justify-between">246 <p className="text-[12px] font-medium text-fg-muted">Custom headers</p>247 <Button type="button" size="xs" variant="ghost" onClick={() => patch({ headers: [...form.headers, { key: "", value: "" }] })} disabled={form.headers.length >= 12}>248 <Plus /> Add header249 </Button>250 </div>251 {form.headers.length === 0 ? (252 <p className="text-[12px] text-fg-subtle">None. Useful for gateways (e.g. <code className="font-mono">X-Api-Key</code>, <code className="font-mono">CF-Access-Client-Id</code>).</p>253 ) : (254 <ul className="space-y-2">255 {form.headers.map((h, i) => (256 <li key={i} className="flex gap-2">257 <Input value={h.key} onChange={(e) => patch({ headers: form.headers.map((x, j) => (j === i ? { ...x, key: e.target.value } : x)) })} placeholder="Header" aria-label={`Header ${i + 1} name`} autoCapitalize="off" spellCheck={false} className="h-11 w-2/5 font-mono text-[16px] md:h-9 md:text-sm" />258 <Input value={h.value} onChange={(e) => patch({ headers: form.headers.map((x, j) => (j === i ? { ...x, value: e.target.value } : x)) })} placeholder={editing && endpoint!.headerNames.includes(h.key) && !h.value ? "•••••• (stored)" : "Value"} aria-label={`Header ${i + 1} value`} autoCapitalize="off" spellCheck={false} className="h-11 min-w-0 flex-1 font-mono text-[16px] md:h-9 md:text-sm" />259 <Button type="button" variant="ghost" size="icon-lg" className="h-11 md:size-9" aria-label="Remove header" onClick={() => patch({ headers: form.headers.filter((_, j) => j !== i) })}>260 <Trash2 />261 </Button>262 </li>263 ))}264 </ul>265 )}266 {editing && form.headers.some((h) => endpoint!.headerNames.includes(h.key) && !h.value) ? <p className="mt-1.5 text-[11px] text-fg-subtle">Stored header values are never shown. Leave them blank to keep them, or type a new value to replace; editing any value re-saves all headers.</p> : null}267 </div>268269 <div className="panel divide-y divide-hairline">270 <div className="flex min-h-[52px] items-center justify-between gap-4 px-4 py-3">271 <div>272 <p className="text-[14px] font-medium">Discover models automatically</p>273 <p className="mt-0.5 text-[12px] text-fg-muted">GET {form.baseUrl.trim().replace(/\/+$/, "") || "{baseUrl}"}{form.discover ? form.modelsPath || "/models" : "…"}</p>274 </div>275 <Switch checked={form.discover} onCheckedChange={(v) => patch({ discover: v })} aria-label="Discover models automatically" />276 </div>277 {form.discover ? (278 <div className="px-4 py-3">279 <Field label="Models path" htmlFor="ep-models-path" hint="Relative to the base URL. OpenAI shape {data:[{id}]}, bare arrays and Ollama's {models:[{name}]} are understood.">280 <Input id="ep-models-path" value={form.modelsPath} onChange={(e) => patch({ modelsPath: e.target.value })} placeholder="/models" autoCapitalize="off" spellCheck={false} className="h-11 font-mono text-[16px] md:h-9 md:text-sm" />281 </Field>282 </div>283 ) : null}284 </div>285286 <div>287 <div className="mb-1.5 flex items-center justify-between">288 <div>289 <p className="text-[12px] font-medium text-fg-muted">Manual models {form.discover ? "(optional overrides)" : ""}</p>290 <p className="text-[11px] text-fg-subtle">{form.discover ? "Declare capabilities for discovered ids (vision, tools, reasoning, context) or add ids the listing misses." : "Discovery is off: list every model id the server accepts."}</p>291 </div>292 <Button type="button" size="xs" variant="ghost" onClick={() => patch({ manualModels: [...form.manualModels, { id: "" }] })} disabled={form.manualModels.length >= 200}>293 <Plus /> Add model294 </Button>295 </div>296 {form.manualModels.length ? (297 <ul className="space-y-2">298 {form.manualModels.map((m, i) => {299 const upd = (p: Partial<ManualModel>) => patch({ manualModels: form.manualModels.map((x, j) => (j === i ? { ...x, ...p } : x)) });300 return (301 <li key={i} className="panel space-y-2 p-3">302 <div className="flex gap-2">303 <Input value={m.id} onChange={(e) => upd({ id: e.target.value })} placeholder="model id (e.g. llama3.1:8b)" aria-label={`Model ${i + 1} id`} autoCapitalize="off" spellCheck={false} className="h-11 min-w-0 flex-1 font-mono text-[16px] md:h-9 md:text-sm" />304 <Button type="button" variant="ghost" size="icon-lg" className="h-11 md:size-9" aria-label="Remove model" onClick={() => patch({ manualModels: form.manualModels.filter((_, j) => j !== i) })}>305 <Trash2 />306 </Button>307 </div>308 <div className="grid gap-2 sm:grid-cols-2">309 <Input value={m.displayName ?? ""} onChange={(e) => upd({ displayName: e.target.value })} placeholder="Display name (optional)" aria-label={`Model ${i + 1} display name`} className="h-11 text-[16px] md:h-9 md:text-sm" />310 <Input type="number" inputMode="numeric" min={1} value={m.contextTokens ?? ""} onChange={(e) => upd({ contextTokens: e.target.value ? Number(e.target.value) : undefined })} placeholder="Context tokens (optional)" aria-label={`Model ${i + 1} context tokens`} className="h-11 text-[16px] md:h-9 md:text-sm" />311 </div>312 <div className="flex flex-wrap gap-x-5 gap-y-2 text-[13px]">313 {(["vision", "tools", "reasoning"] as const).map((cap) => (314 <label key={cap} className="flex min-h-[32px] items-center gap-2 capitalize">315 <Switch size="sm" checked={Boolean(m[cap])} onCheckedChange={(v) => upd({ [cap]: v })} aria-label={`${cap} supported`} />316 {cap}317 </label>318 ))}319 </div>320 </li>321 );322 })}323 </ul>324 ) : (325 <p className="text-[12px] text-fg-subtle">Discovered models start as text-only. Add an entry to unlock vision, tools or reasoning controls for a specific id.</p>326 )}327 </div>328329 {test ? (330 <div className={cn("rounded-lg px-3 py-2.5 text-xs leading-5", test.ok ? "bg-success-soft text-success" : "bg-danger-soft text-danger")}>331 <p className="flex items-center gap-1.5 font-medium">332 {test.ok ? <CheckCircle2 className="size-3.5" /> : <XCircle className="size-3.5" />}333 {test.ok ? `Reachable in ${formatMs(test.latencyMs)} · ${test.modelsAvailable} model${test.modelsAvailable === 1 ? "" : "s"}` : `Failed after ${formatMs(test.latencyMs)}`}334 </p>335 {!test.ok && test.error ? <p className="mt-0.5 text-fg-muted">{test.error}</p> : null}336 {test.ok && test.models?.length ? (337 <ul className="mt-2 flex flex-wrap gap-1.5">338 {test.models.slice(0, 30).map((m) => (339 <li key={m.id}>340 <Badge variant="outline" className="font-mono text-[10.5px] text-fg-muted">341 {m.id}342 </Badge>343 </li>344 ))}345 {test.models.length > 30 ? <li className="text-fg-muted">+{test.models.length - 30} more</li> : null}346 </ul>347 ) : null}348 </div>349 ) : null}350351 {error ? <p className="rounded-lg bg-danger-soft px-3 py-2 text-xs text-danger">{error}</p> : null}352 </div>353 </ResponsiveDialog>354 );355}356