feat: custom OpenAI-compatible endpoint as a first-class provider (vision + context from /models)

This commit is contained in:
Daniel Buckner
2026-10-27 15:48:16 -07:00
parent 8f6fd7cd29
commit 07b6403d94
15 changed files with 965 additions and 41 deletions
+183 -1
View File
@@ -24,7 +24,7 @@
*/
import { useEffect, useRef, useState } from "react";
import { AUTH_KEYS, type AuthKeyStatus } from "@/src/core";
import { AUTH_KEYS, CUSTOM_PROVIDER, type AuthKeyStatus, type CustomModel } from "@/src/core";
import { SettingsGroup } from "./controls.tsx";
import { SubscriptionConnections } from "./subscription-connections.tsx";
import { ProviderMenuList } from "./model-menu.tsx";
@@ -83,6 +83,10 @@ export function classifyKeys(
const unknownRows: AuthKeyStatus[] = [];
for (const s of statuses) {
if (isTool(s.id)) continue;
// The custom endpoint's key has its OWN home (the Custom endpoint
// section), so it never shows here — not as a provider row, not as an
// "other stored variable".
if (s.id === CUSTOM_PROVIDER.envVar) continue;
if (providerEnv.has(s.id) || (!s.info && providers === undefined)) providerRows.push(s);
else if (!s.info) unknownRows.push(s);
// (pre-interlude catalogs: a curated-but-unclassified status still
@@ -453,6 +457,7 @@ export function KeysSection({
}}
/>
)}
<CustomProviderRow />
{adding && (
<div className="flex items-center gap-2 rounded-sm px-1 py-1.5" data-testid={`key-${adding.id}-editor`}>
<span className="shrink-0 font-ui text-xs text-foreground">{adding.label}</span>
@@ -561,3 +566,180 @@ function AddProviderEditor({
</>
);
}
/** The custom OpenAI-compatible endpoint's row in the provider list — one more
* provider, connected by a base URL + (optional) key exactly like the built-ins
* are connected by a key (the URL is the one extra field). Its status reads
* GET /api/custom-provider; Connect POSTs the connect (validate + discover
* models + register) and dispatches the keys signal so the catalog widens with
* no restart. Keyless local servers (Ollama / LM Studio) connect with no key. */
function CustomProviderRow() {
const [status, setStatus] = useState<{ baseUrl: string; models: CustomModel[]; keyPresent: boolean } | null>(null);
const [expanded, setExpanded] = useState(false);
const [baseUrl, setBaseUrl] = useState("");
const [key, setKey] = useState("");
const [keyTouched, setKeyTouched] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const reload = () =>
fetch("/api/custom-provider")
.then((r) => r.json())
.then((b: { baseUrl?: string; models?: CustomModel[]; keyPresent?: boolean }) =>
setStatus({ baseUrl: b.baseUrl ?? "", models: b.models ?? [], keyPresent: Boolean(b.keyPresent) }),
)
.catch(() => setStatus({ baseUrl: "", models: [], keyPresent: false }));
useEffect(() => {
void reload();
}, []);
async function save() {
if (busy || !baseUrl.trim()) return;
setBusy(true);
setError(null);
const res = await fetch("/api/custom-provider", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ baseUrl, ...(keyTouched ? { key } : {}) }),
}).catch(() => null);
setBusy(false);
if (!res || !res.ok) {
const b = (await res?.json().catch(() => ({}))) as { error?: string } | undefined;
setError(res ? (b?.error ?? `Connect failed (${res.status})`) : "Connect failed. Check your connection.");
return;
}
setExpanded(false);
setKey("");
setKeyTouched(false);
await reload();
notifySettingsSaved("keys");
}
// Flip one model's image capability and persist it (capability update — no
// re-discovery, no key change). Uses the CONNECTED base URL (status.baseUrl),
// not the editor's possibly-edited field, so toggles always apply to the
// endpoint whose models are listed.
async function toggleImage(id: string) {
if (!status?.baseUrl || busy) return;
const models = status.models.map((m) => (m.id === id ? { ...m, image: !m.image } : m));
setBusy(true);
setError(null);
const res = await fetch("/api/custom-provider", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ baseUrl: status.baseUrl, models }),
}).catch(() => null);
setBusy(false);
if (!res || !res.ok) {
const b = (await res?.json().catch(() => ({}))) as { error?: string } | undefined;
setError(res ? (b?.error ?? `Save failed (${res.status})`) : "Save failed. Check your connection.");
return;
}
await reload();
notifySettingsSaved("keys");
}
if (expanded) {
return (
<div data-testid="custom-endpoint-editor" className="flex flex-col gap-2 rounded-sm px-1 py-1.5">
<div className="flex items-center gap-2">
<span className="shrink-0 font-ui text-xs text-foreground">Custom endpoint</span>
<input
type="text"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder="Base URL, e.g. http://localhost:11434/v1"
aria-label="Custom endpoint base URL"
data-testid="custom-baseurl-input"
disabled={busy}
autoFocus
className="h-7 min-w-0 flex-1 rounded-sm border border-border bg-background px-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50"
/>
</div>
<div className="flex items-center gap-2">
<input
type="text"
value={key}
onChange={(e) => {
setKey(e.target.value);
setKeyTouched(true);
}}
placeholder={status?.keyPresent ? "Key set — paste to replace, empty to clear" : "API key (optional for local servers)"}
aria-label="Custom endpoint API key"
data-testid="custom-key-input"
disabled={busy}
className="h-7 min-w-0 flex-1 rounded-sm border border-border bg-background px-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50"
/>
<button
type="button"
onClick={() => void save()}
disabled={busy || !baseUrl.trim()}
data-testid="custom-save"
className="shrink-0 text-xs text-foreground underline-offset-2 hover:underline disabled:text-muted-foreground/40 disabled:no-underline"
>
{busy ? "Connecting…" : "Connect"}
</button>
<button
type="button"
onClick={() => setExpanded(false)}
disabled={busy}
data-testid="custom-cancel"
className="shrink-0 text-xs text-muted-foreground/70 underline-offset-2 hover:text-foreground hover:underline"
>
Cancel
</button>
</div>
{status && status.models.length > 0 && (
<div className="flex flex-col gap-1 border-t border-border/40 pt-2">
<span className="text-[11px] text-muted-foreground">
Models — check the ones that accept images (needed for the Vision slot)
</span>
{status.models.map((m) => (
<label key={m.id} className="flex items-center gap-2 text-xs">
<input
type="checkbox"
checked={Boolean(m.image)}
disabled={busy}
onChange={() => void toggleImage(m.id)}
data-testid={`custom-image-${m.id.replace(/[^a-z0-9-]/gi, "_")}`}
/>
<span className="font-mono text-foreground">{m.id}</span>
<span className="text-muted-foreground">images</span>
</label>
))}
</div>
)}
{error && (
<p role="alert" data-testid="custom-error" className="text-xs text-red-500">
{error}
</p>
)}
</div>
);
}
const count = status?.models.length ?? 0;
const imgCount = status?.models.filter((m) => m.image).length ?? 0;
const line = status?.baseUrl
? `${status.baseUrl} · ${count} model${count === 1 ? "" : "s"}${imgCount ? ` · ${imgCount} image` : ""}${status.keyPresent ? "" : " · keyless"}`
: "not set";
return (
<div data-testid="custom-endpoint-row">
<button
type="button"
data-testid="custom-endpoint-row-btn"
onClick={() => {
setBaseUrl(status?.baseUrl ?? "");
setExpanded(true);
}}
className="flex w-full items-center justify-between gap-3 rounded-sm px-1 py-1.5 text-left cursor-pointer transition-colors hover:bg-accent/50"
>
<span className="font-ui text-xs text-foreground">Custom endpoint</span>
<span data-testid="custom-endpoint-status" className="max-w-[60%] truncate font-mono text-[11px] text-muted-foreground">
{line}
</span>
</button>
</div>
);
}
+75 -32
View File
@@ -40,7 +40,7 @@ import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useBackdropDismiss } from "@/components/backdrop-dismiss";
import type React from "react";
import { type ModelCatalogEntry, type ModelSlotName, type ModelsSettings } from "@/src/core";
import { type ModelCatalogEntry, type ModelSlotName, type ModelsSettings, CUSTOM_PROVIDER } from "@/src/core";
import { prettyModel } from "@/components/usage-format";
import { ModelMenuList } from "@/components/settings/model-menu";
import { MenuShell } from "@/components/menu-shell";
@@ -65,6 +65,11 @@ const SLOT_META: Record<ModelSlotName, { label: string; roster: string }> = {
};
const SLOT_ORDER: readonly ModelSlotName[] = ["strong", "cheap", "vision"];
/** The custom endpoint's synthetic picker entry — one more provider in the
* Stage 1 roster, connected by a base URL + optional key (POST
* /api/custom-provider). Not part of the /api/keys plain-key roster. */
const CUSTOM_PICKER: ProviderKeyInfo = { id: CUSTOM_PROVIDER.id, envVar: CUSTOM_PROVIDER.envVar, label: CUSTOM_PROVIDER.label };
/** The GET /api/models response fields the flow consumes. */
interface ModelsBody {
models: ModelCatalogEntry[];
@@ -134,6 +139,9 @@ export function SetupKeysBody({
onValue,
onSave,
menuAnchor,
isCustom,
baseUrl,
onBaseUrl,
}: {
/** The full provider roster (GET /api/keys providers) — undefined
* while it loads; the picker renders an honest empty state then. */
@@ -151,6 +159,12 @@ export function SetupKeysBody({
onSave: () => void;
/** The MenuShell anchor — wired by the wrapper (its triggerRef). */
menuAnchor: React.RefObject<HTMLDivElement | null>;
/** The chosen provider is the custom endpoint: a base URL field joins the
* key field (the key becomes optional — keyless local servers). All three
* optional so the plain-key callers (and tests) stay unchanged. */
isCustom?: boolean;
baseUrl?: string;
onBaseUrl?: (v: string) => void;
}) {
return (
<div className="flex flex-col gap-3 px-6 py-5" data-testid="setup-stage-keys">
@@ -185,30 +199,44 @@ export function SetupKeysBody({
)}
</div>
{provider && (
<div className="flex max-w-96 items-center gap-2">
<input
type="text"
value={value}
onChange={(e) => onValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && value.trim()) onSave();
}}
placeholder="Paste key…"
aria-label={`${provider.label} key`}
data-testid="setup-key-input"
disabled={saving}
autoFocus
className="h-7 min-w-0 flex-1 rounded-sm border border-border bg-background px-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50"
/>
<button
type="button"
onClick={onSave}
disabled={saving || !value.trim()}
data-testid="setup-key-save"
className="shrink-0 rounded-sm border border-border bg-background px-3 py-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 disabled:cursor-not-allowed disabled:opacity-40"
>
{saving ? "Saving…" : "Save key"}
</button>
<div className="flex max-w-96 flex-col gap-2">
{isCustom && (
<input
type="text"
value={baseUrl ?? ""}
onChange={(e) => onBaseUrl?.(e.target.value)}
placeholder="Base URL, e.g. http://localhost:11434/v1"
aria-label="Custom endpoint base URL"
data-testid="setup-custom-baseurl"
disabled={saving}
className="h-7 w-full rounded-sm border border-border bg-background px-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50"
/>
)}
<div className="flex items-center gap-2">
<input
type="text"
value={value}
onChange={(e) => onValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && (isCustom ? baseUrl?.trim() : value.trim())) onSave();
}}
placeholder={isCustom ? "API key (optional for local servers)" : "Paste key…"}
aria-label={`${provider.label} key`}
data-testid="setup-key-input"
disabled={saving}
autoFocus
className="h-7 min-w-0 flex-1 rounded-sm border border-border bg-background px-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50"
/>
<button
type="button"
onClick={onSave}
disabled={saving || (isCustom ? !baseUrl?.trim() : !value.trim())}
data-testid="setup-key-save"
className="shrink-0 rounded-sm border border-border bg-background px-3 py-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 disabled:cursor-not-allowed disabled:opacity-40"
>
{saving ? "Saving…" : isCustom ? "Connect" : "Save key"}
</button>
</div>
</div>
)}
{error && (
@@ -264,6 +292,7 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) {
const [keyProviders, setKeyProviders] = useState<ProviderKeyInfo[] | undefined>(undefined);
const [keyProvider, setKeyProvider] = useState<ProviderKeyInfo | null>(null);
const [keyValue, setKeyValue] = useState("");
const [customBaseUrl, setCustomBaseUrl] = useState("");
const [keySaving, setKeySaving] = useState(false);
const [keyError, setKeyError] = useState<string | null>(null);
const [keyMenuOpen, setKeyMenuOpen] = useState(false);
@@ -374,14 +403,24 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) {
* close recovery or advance to the missing model slots. Shape-gate
* 400s keep their user-facing line inline. */
async function saveKey() {
if (!keyProvider || !keyValue.trim() || keySaving) return;
if (!keyProvider || keySaving) return;
const isCustom = keyProvider.id === CUSTOM_PROVIDER.id;
if (!isCustom && !keyValue.trim()) return;
setKeySaving(true);
setKeyError(null);
const res = await fetch(`/api/keys/${encodeURIComponent(keyProvider.envVar)}`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ value: keyValue }),
}).catch(() => null);
// The custom endpoint connects through its own route (base URL + key +
// discovery in one action); every other provider is a plain key PUT.
const res = isCustom
? await fetch("/api/custom-provider", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ baseUrl: customBaseUrl, key: keyValue }),
}).catch(() => null)
: await fetch(`/api/keys/${encodeURIComponent(keyProvider.envVar)}`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ value: keyValue }),
}).catch(() => null);
setKeySaving(false);
if (!res || !res.ok) {
const body = (await res?.json().catch(() => ({}))) as { error?: string } | undefined;
@@ -390,6 +429,7 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) {
}
setKeyProvider(null);
setKeyValue("");
setCustomBaseUrl("");
notifySettingsSaved("keys");
}
@@ -420,13 +460,16 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) {
}
const keysBody = <SetupKeysBody
providers={keyProviders}
providers={keyProviders ? [...keyProviders, CUSTOM_PICKER] : undefined}
provider={keyProvider}
value={keyValue}
saving={keySaving}
error={keyError}
menuOpen={keyMenuOpen}
menuAnchor={keyTriggerRef}
isCustom={keyProvider?.id === CUSTOM_PROVIDER.id}
baseUrl={customBaseUrl}
onBaseUrl={setCustomBaseUrl}
onToggleMenu={() => setKeyMenuOpen((v) => !v)}
onPick={(p) => { setKeyProvider(p); setKeyMenuOpen(false); setKeyError(null); }}
onValue={setKeyValue}