feat: custom OpenAI-compatible endpoint as a first-class provider (vision + context from /models)
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* The custom OpenAI-compatible endpoint's connect surface (custom-endpoint
|
||||
* wave). It is ONE provider in the Keys list, connected exactly like the
|
||||
* built-ins are connected by a key — the only extra input is a base URL.
|
||||
*
|
||||
* GET → the resolved non-secret config { baseUrl, models } + whether a key
|
||||
* is present (masked status only; the key is a secret in the auth
|
||||
* store and never enters settings.json).
|
||||
* POST → connect. Body { baseUrl, key? }: validate + normalize the base URL,
|
||||
* PROBE the endpoint's /models (fail fast — a broken URL never
|
||||
* persists), then store the key (empty string clears it, absent
|
||||
* leaves it), persist { baseUrl, models } to the settings record, and
|
||||
* re-register the provider into piModels() with no restart.
|
||||
*
|
||||
* Discovery lives here (not a separate route) because connecting and discovering
|
||||
* are one user action: paste URL + key, get a working provider.
|
||||
*/
|
||||
import {
|
||||
deleteKey,
|
||||
discoverModels,
|
||||
effectiveSettings,
|
||||
keyValue,
|
||||
readSettingsRecord,
|
||||
saveKey,
|
||||
syncCustomProvider,
|
||||
writeSettingsRecord,
|
||||
} from "@/src/server";
|
||||
import { CUSTOM_PROVIDER, normalizeBaseUrl } from "@/src/core";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
const cp = effectiveSettings().customProvider;
|
||||
return Response.json({
|
||||
baseUrl: cp.baseUrl,
|
||||
models: cp.models,
|
||||
keyPresent: keyValue(CUSTOM_PROVIDER.envVar) !== undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = (await request.json().catch(() => null)) as {
|
||||
baseUrl?: unknown;
|
||||
key?: unknown;
|
||||
models?: unknown;
|
||||
} | null;
|
||||
const base = normalizeBaseUrl(typeof body?.baseUrl === "string" ? body.baseUrl : "");
|
||||
if (!base) return Response.json({ error: "Base URL must be a valid http(s) URL" }, { status: 400 });
|
||||
|
||||
// Two intents on one endpoint, told apart by the presence of `models`:
|
||||
// • `models` present → CAPABILITY UPDATE: persist the user's per-model
|
||||
// image toggles (no re-discovery, no key change) and re-register.
|
||||
// • `models` absent → CONNECT: probe /models, store the discovered set.
|
||||
if (Array.isArray(body?.models)) {
|
||||
const record = readSettingsRecord() ?? { version: 1 };
|
||||
const next = writeSettingsRecord({ ...record, customProvider: { baseUrl: base, models: body!.models } });
|
||||
syncCustomProvider(next.customProvider);
|
||||
return Response.json({ ok: true, models: next.customProvider?.models ?? [] });
|
||||
}
|
||||
|
||||
// A provided key is trimmed; an empty string means "clear it", an absent
|
||||
// field means "leave whatever is stored". Only a real key rides the probe.
|
||||
const keyProvided = typeof body?.key === "string";
|
||||
const key = keyProvided ? (body!.key as string).trim() : undefined;
|
||||
|
||||
// Probe first: a URL that doesn't answer with a model list never persists,
|
||||
// so a typo can't leave a half-configured provider behind.
|
||||
const found = await discoverModels(base, key ? key : undefined);
|
||||
if (!found.ok) return Response.json({ error: found.error }, { status: 502 });
|
||||
|
||||
if (keyProvided) {
|
||||
if (key) {
|
||||
const saved = saveKey(CUSTOM_PROVIDER.envVar, key);
|
||||
if (!saved.ok) return Response.json({ error: saved.error }, { status: 400 });
|
||||
} else {
|
||||
deleteKey(CUSTOM_PROVIDER.envVar);
|
||||
}
|
||||
}
|
||||
|
||||
const record = readSettingsRecord() ?? { version: 1 };
|
||||
const next = writeSettingsRecord({ ...record, customProvider: { baseUrl: base, models: found.models } });
|
||||
syncCustomProvider(next.customProvider);
|
||||
return Response.json({ ok: true, models: next.customProvider?.models ?? [] });
|
||||
}
|
||||
@@ -128,6 +128,6 @@ export async function PUT(request: Request, ctx: { params: Promise<{ section: st
|
||||
// Read-modify-write ONE section of the record; everything else rides
|
||||
// along untouched (other sections, unknown keys — sanitize preserves).
|
||||
const next = { ...record, [section]: sectionOut };
|
||||
writeSettingsRecord(next);
|
||||
return Response.json({ settings: next });
|
||||
const saved = writeSettingsRecord(next);
|
||||
return Response.json({ settings: saved });
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -122,6 +122,24 @@ export const AUTH_PROVIDER_KEYS: readonly AuthProviderInfo[] = [
|
||||
{ id: "zai-coding-cn", envVar: "ZAI_CODING_CN_API_KEY", label: "Z.AI Coding CN" },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* THE SINGLE CUSTOM ENDPOINT's identity (custom-endpoint wave): a
|
||||
* user-configured OpenAI-compatible server registered into pi-ai at runtime
|
||||
* (server/custom-provider.ts). Deliberately NOT part of AUTH_PROVIDER_KEYS —
|
||||
* that table is test-pinned to pi-ai's BUILT-IN providers
|
||||
* (tests/auth-provider-keys.test.ts) and every row is env-var-keyed; the
|
||||
* custom endpoint is keyed by a user-supplied BASE URL instead, and its API
|
||||
* key rides this one reserved env var (OPTIONAL — keyless local servers such
|
||||
* as Ollama / LM Studio are allowed). The Custom endpoint Settings section is
|
||||
* its one home; the key is stored in auth.json under `envVar` like any other
|
||||
* provider key (masked, env-reflected, no restart).
|
||||
*/
|
||||
export const CUSTOM_PROVIDER = {
|
||||
id: "custom",
|
||||
envVar: "CUSTOM_OPENAI_API_KEY",
|
||||
label: "Custom endpoint",
|
||||
} as const;
|
||||
|
||||
/** Every distinct env var a model-provider key rides in (the write side's
|
||||
* writability check — see isProviderEnvVar below; also drives which
|
||||
* PRESENT provider vars surface as status rows). */
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
type GeneralSettings,
|
||||
type DictationSettings,
|
||||
type CompactionSettings,
|
||||
type CustomModel,
|
||||
type GraphSettings,
|
||||
type ModelSlot,
|
||||
type ModelSlotName,
|
||||
@@ -95,6 +96,11 @@ export interface EffectiveSettings {
|
||||
/** Compaction (owner round 14): resolved concrete — absent record
|
||||
* section resolves to the factory 150k. */
|
||||
compaction: Required<CompactionSettings>;
|
||||
/** The single custom OpenAI-compatible endpoint (custom-endpoint wave):
|
||||
* resolved concrete — baseUrl defaults "", models default []. Per-model
|
||||
* defaults (context window, cost) apply at provider-build time in the
|
||||
* server (custom-provider.ts), not here. */
|
||||
customProvider: { baseUrl: string; models: CustomModel[] };
|
||||
}
|
||||
|
||||
/** The conversation pair's chain (BYOK, owner 2026-09-22): the chat's own
|
||||
@@ -221,6 +227,10 @@ export function resolveSettings(record: UserSettings | null): EffectiveSettings
|
||||
compaction: {
|
||||
thresholdTokens: record?.compaction?.thresholdTokens ?? FACTORY_SETTINGS.compaction.thresholdTokens,
|
||||
},
|
||||
customProvider: {
|
||||
baseUrl: record?.customProvider?.baseUrl ?? "",
|
||||
models: record?.customProvider?.models ?? [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -206,6 +206,83 @@ export interface ModelsSettings {
|
||||
vision?: ModelSlot;
|
||||
}
|
||||
|
||||
// ─── Custom OpenAI-compatible endpoint (custom-endpoint wave) ────────────────
|
||||
// ONE user-configured endpoint speaking the OpenAI /chat/completions API
|
||||
// (self-hosted vLLM / Ollama / llama.cpp, a corporate proxy). It is NOT a
|
||||
// special concept: it is one more provider in the Keys list, connected by a
|
||||
// base URL + (optional) key exactly as the built-ins are connected by a key.
|
||||
// NON-SECRET config only — base URL + the model ids discovered from it. The
|
||||
// API key is a SECRET and lives in the auth store under CUSTOM_PROVIDER.envVar
|
||||
// (core/auth-keys.ts), never in this record, exactly like every other key. The
|
||||
// endpoint is registered into pi-ai as a normal provider
|
||||
// (server/custom-provider.ts) so its models fill the same slots/roles.
|
||||
|
||||
/** Default facts for a custom model. The endpoint's /models reports ids (and
|
||||
* sometimes modality); these fill the rest: self-hosted endpoints are free. */
|
||||
export const CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW = 8192;
|
||||
export const CUSTOM_MODEL_DEFAULT_MAX_TOKENS = 4096;
|
||||
|
||||
/** One discovered custom model. `image` marks it as accepting image input so
|
||||
* it is selectable for the Vision slot (PDFs, image tasks). Discovery sets it
|
||||
* from the endpoint's /models metadata when reported; the Keys row exposes a
|
||||
* checkbox for endpoints that don't report it. `context` / `maxTokens` carry
|
||||
* the endpoint's reported token limits (max_input_tokens / max_output_tokens).
|
||||
* Anything the endpoint omits (cost, and any unreported window) lands on the
|
||||
* CUSTOM_MODEL_DEFAULT_* values at provider-build time. */
|
||||
export interface CustomModel {
|
||||
id: string;
|
||||
/** Accepts image input (Vision-capable). Default false (text-only). */
|
||||
image?: boolean;
|
||||
/** Context window in tokens, from the endpoint. Absent → the default. */
|
||||
context?: number;
|
||||
/** Max output tokens, from the endpoint. Absent → the default. */
|
||||
maxTokens?: number;
|
||||
}
|
||||
|
||||
/** The single custom endpoint's stored config (delta section). `models` are
|
||||
* discovered from the endpoint's /models on connect; the only user-editable
|
||||
* fact per model is the `image` capability toggle. */
|
||||
export interface CustomProviderSettings {
|
||||
/** Base URL of the OpenAI-compatible server, NORMALIZED (see
|
||||
* normalizeBaseUrl): no trailing slash, absolute http(s). Empty/absent =
|
||||
* not configured. */
|
||||
baseUrl?: string;
|
||||
/** Models discovered from the endpoint (refreshed on connect), each with
|
||||
* its image-capability flag. */
|
||||
models?: CustomModel[];
|
||||
}
|
||||
|
||||
/** THE one place the base-URL canonical form is defined (single source — the
|
||||
* sanitizer stores the normalized value, so no consumer re-derives it and
|
||||
* the trailing-slash edge case is defined out of existence). Trims; parses
|
||||
* as an absolute URL (scheme + host lowercased by the parser); drops any
|
||||
* trailing slashes (the OpenAI SDK appends /chat/completions itself).
|
||||
* Returns "" for anything that is not a valid absolute http(s) URL — callers
|
||||
* treat "" as "not configured". */
|
||||
export function normalizeBaseUrl(raw: string): string {
|
||||
const value = raw.trim();
|
||||
if (!value) return "";
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return "";
|
||||
return url.toString().replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
/** THE single source of "is the custom endpoint usable?" (custom-endpoint
|
||||
* wave): a non-empty base URL. The API key is OPTIONAL (keyless local
|
||||
* servers), so key presence is deliberately NOT part of this — the picker
|
||||
* federation, the sync, and the roster all consult this one predicate so the
|
||||
* rule can never drift between them. */
|
||||
export function customProviderUsable(
|
||||
cp: CustomProviderSettings | undefined,
|
||||
): cp is CustomProviderSettings & { baseUrl: string } {
|
||||
return Boolean(cp?.baseUrl);
|
||||
}
|
||||
|
||||
/** First-run gate (model-slots wave, owner 2026-09-20): the app refuses
|
||||
* to start chats until the user has actively picked all three slots —
|
||||
* there ARE no defaults for 33 providers; a configured app is one whose
|
||||
@@ -371,6 +448,9 @@ export interface UserSettings {
|
||||
agent?: AgentSettings;
|
||||
/** The strong/cheap/vision model slots (model slots wave, 2026-09-20). */
|
||||
models?: ModelsSettings;
|
||||
/** The single custom OpenAI-compatible endpoint (custom-endpoint wave).
|
||||
* Non-secret config only; the API key lives in the auth store. */
|
||||
customProvider?: CustomProviderSettings;
|
||||
appearance?: AppearanceSettings;
|
||||
graph?: GraphSettings;
|
||||
compaction?: CompactionSettings;
|
||||
@@ -497,6 +577,43 @@ function sanitizeModels(raw: Record<string, unknown>): ModelsSettings {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The customProvider section: a normalized base URL (invalid → dropped, i.e.
|
||||
* treated as unset) and the discovered models (each `{ id, image? }`; ids
|
||||
* trimmed, empties and duplicate ids dropped, `image` kept only when true;
|
||||
* a non-array drops the whole list). Clamp-never-reject, like every
|
||||
* sanitizer here. */
|
||||
/** A positive integer, or undefined — keeps only meaningful token counts. */
|
||||
function positiveInt(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function sanitizeCustomProvider(raw: Record<string, unknown>): CustomProviderSettings {
|
||||
const out: CustomProviderSettings = {};
|
||||
if (typeof raw.baseUrl === "string") {
|
||||
const base = normalizeBaseUrl(raw.baseUrl);
|
||||
if (base) out.baseUrl = base;
|
||||
}
|
||||
if (Array.isArray(raw.models)) {
|
||||
const byId = new Map<string, CustomModel>();
|
||||
for (const m of raw.models) {
|
||||
if (typeof m !== "object" || m === null) continue;
|
||||
const rec = m as Record<string, unknown>;
|
||||
if (typeof rec.id !== "string") continue;
|
||||
const id = rec.id.trim();
|
||||
if (!id) continue;
|
||||
const model: CustomModel = { id };
|
||||
if (rec.image === true) model.image = true;
|
||||
const context = positiveInt(rec.context);
|
||||
if (context) model.context = context;
|
||||
const maxTokens = positiveInt(rec.maxTokens);
|
||||
if (maxTokens) model.maxTokens = maxTokens;
|
||||
byId.set(id, model);
|
||||
}
|
||||
out.models = [...byId.values()];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function sanitizeBooleanMap(raw: unknown): Record<string, boolean> | undefined {
|
||||
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
||||
const out: Record<string, boolean> = {};
|
||||
@@ -641,6 +758,9 @@ export function sanitizeUserSettings(raw: unknown): UserSettings | null {
|
||||
const models = sanitizeModels(r.models as Record<string, unknown>);
|
||||
if (Object.keys(models).length) out.models = models;
|
||||
}
|
||||
if (r.customProvider !== null && typeof r.customProvider === "object" && !Array.isArray(r.customProvider)) {
|
||||
out.customProvider = sanitizeCustomProvider(r.customProvider as Record<string, unknown>);
|
||||
}
|
||||
if (r.graph !== null && typeof r.graph === "object" && !Array.isArray(r.graph)) {
|
||||
out.graph = sanitizeGraph(r.graph as Record<string, unknown>);
|
||||
if (r.compaction !== undefined && r.compaction !== null && typeof r.compaction === "object") {
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* THE CUSTOM OPENAI-COMPATIBLE ENDPOINT (custom-endpoint wave): the deep
|
||||
* module that turns the user's non-secret config (base URL + discovered model
|
||||
* ids, core/settings.ts `customProvider`) into a real pi-ai provider registered
|
||||
* in the process-wide `piModels()` collection — so its models fill the same
|
||||
* strong/cheap/vision slots and background roles as any built-in.
|
||||
*
|
||||
* WHY A MODULE OWNS THIS (information hiding): the fact "a custom endpoint is
|
||||
* an OpenAI-completions provider at a user URL with an optional key" would
|
||||
* otherwise leak into the catalog, the keys seam, and the settings route. It
|
||||
* lives here instead; those surfaces consult the pure `customProviderUsable`
|
||||
* predicate (core) and call `syncCustomProvider`. The single-endpoint scope is
|
||||
* special-purpose, but the builder is written general (`buildOpenAiCompatible
|
||||
* Provider`) so a future multi-endpoint wave reuses it unchanged.
|
||||
*
|
||||
* LIFECYCLE (no restart, mirroring the keys seam): `syncCustomProvider` is
|
||||
* idempotent and cheap — called at boot (stores.ts, the composition root) and
|
||||
* after every connect (POST /api/custom-provider). It replaces
|
||||
* or removes the provider in `piModels()` by id. The base URL is fixed at
|
||||
* provider construction, so a base-URL change is a rebuild — which is exactly
|
||||
* what a re-sync does.
|
||||
*
|
||||
* AUTH: pi-ai resolves a provider's key at request time. We read the reserved
|
||||
* env var through the keys seam (`keyValue` — env-wins, file fallback, honors
|
||||
* the fresh-user switch), and fall back to a placeholder when none is set so
|
||||
* KEYLESS local servers (Ollama / LM Studio / llama.cpp) work. The placeholder
|
||||
* is an internal detail of `resolve()` — no caller ever sees it.
|
||||
*/
|
||||
|
||||
import { createProvider, type ApiKeyAuth, type Api, type Model, type Provider } from "@earendil-works/pi-ai";
|
||||
import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
|
||||
import { piModels } from "../agent/models.ts";
|
||||
import { CUSTOM_PROVIDER } from "../core/auth-keys.ts";
|
||||
import {
|
||||
CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW,
|
||||
CUSTOM_MODEL_DEFAULT_MAX_TOKENS,
|
||||
customProviderUsable,
|
||||
normalizeBaseUrl,
|
||||
type CustomModel,
|
||||
type CustomProviderSettings,
|
||||
} from "../core/settings.ts";
|
||||
import { keyValue } from "./keys.ts";
|
||||
|
||||
/** Placeholder bearer sent to keyless endpoints. Many local OpenAI-compatible
|
||||
* servers ignore the Authorization header entirely; the OpenAI client still
|
||||
* requires *a* key, so we hand it this. Never shown to the user. */
|
||||
const KEYLESS_PLACEHOLDER = "not-needed";
|
||||
|
||||
/** Narrow an unknown to a plain object (not null, not an array). */
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
/** The custom endpoint's auth: resolve the reserved key through the keys
|
||||
* seam, else the keyless placeholder. `resolve` is the only method pi-ai
|
||||
* needs to treat the provider as configured. */
|
||||
const customEndpointAuth: ApiKeyAuth = {
|
||||
name: `${CUSTOM_PROVIDER.label} API key`,
|
||||
resolve: async () => {
|
||||
const key = keyValue(CUSTOM_PROVIDER.envVar)?.trim();
|
||||
// Keyless local servers ignore the header; the OpenAI client still
|
||||
// needs *a* key, so hand it the placeholder.
|
||||
return { auth: { apiKey: key || KEYLESS_PLACEHOLDER }, source: key ? CUSTOM_PROVIDER.envVar : "keyless" };
|
||||
},
|
||||
};
|
||||
|
||||
/** Map one discovered custom model to a pi-ai chat model entry. The endpoint's
|
||||
* /models reports ids plus whatever metadata it exposes; `image` sets `input`
|
||||
* so a Vision-capable model is selectable for the Vision slot (PDFs, images),
|
||||
* and `context` / `maxTokens` carry the endpoint's reported token limits.
|
||||
* Anything the endpoint omits falls back to the CUSTOM_MODEL_DEFAULT_* values
|
||||
* (self-hosted = free, modest window). `baseUrl` is stamped per-model (pi reads
|
||||
* it from the model, not the provider). */
|
||||
function toPiModel(model: CustomModel, baseUrl: string): Model<Api> {
|
||||
return {
|
||||
id: model.id,
|
||||
name: model.id,
|
||||
api: "openai-completions",
|
||||
provider: CUSTOM_PROVIDER.id,
|
||||
baseUrl,
|
||||
reasoning: false,
|
||||
input: model.image ? ["text", "image"] : ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: model.context ?? CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: model.maxTokens ?? CUSTOM_MODEL_DEFAULT_MAX_TOKENS,
|
||||
type: "chat",
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a general OpenAI-compatible provider from parts (single-use today,
|
||||
* general by design). `baseUrl` must already be normalized. */
|
||||
export function buildOpenAiCompatibleProvider(input: {
|
||||
baseUrl: string;
|
||||
models: readonly CustomModel[];
|
||||
}): Provider<Api> {
|
||||
return createProvider<Api>({
|
||||
id: CUSTOM_PROVIDER.id,
|
||||
name: CUSTOM_PROVIDER.label,
|
||||
baseUrl: input.baseUrl,
|
||||
auth: { apiKey: customEndpointAuth },
|
||||
models: input.models.map((m) => toPiModel(m, input.baseUrl)),
|
||||
api: openAICompletionsApi(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-sync the custom provider into `piModels()` from the resolved section
|
||||
* (idempotent). Usable → (re)build and `setProvider` (replaces by id, so a
|
||||
* base-URL/model change lands with no restart). Not usable → `deleteProvider`
|
||||
* so it stops federating and any slot pinning one of its models falls back to
|
||||
* the honest models_not_configured refusal. Never throws: a bad config simply
|
||||
* leaves the provider absent.
|
||||
*/
|
||||
export function syncCustomProvider(cp: CustomProviderSettings | undefined): void {
|
||||
const models = piModels();
|
||||
if (!customProviderUsable(cp)) {
|
||||
models.deleteProvider(CUSTOM_PROVIDER.id);
|
||||
return;
|
||||
}
|
||||
// `customProviderUsable` narrowed cp to a defined section with a baseUrl.
|
||||
models.setProvider(
|
||||
buildOpenAiCompatibleProvider({ baseUrl: normalizeBaseUrl(cp.baseUrl), models: cp.models ?? [] }),
|
||||
);
|
||||
}
|
||||
|
||||
/** Outcome of a model-discovery probe. */
|
||||
export type DiscoverResult = { ok: true; models: CustomModel[] } | { ok: false; error: string };
|
||||
|
||||
/**
|
||||
* Probe an endpoint's `GET {baseUrl}/models` (the OpenAI list-models route)
|
||||
* and return the model ids. The scheme is validated via normalizeBaseUrl
|
||||
* (http/https only — the SSRF guard: this is a local app, but a user paste of
|
||||
* `file://` or a stray string must never reach fetch). The key rides as a
|
||||
* bearer when one is configured; keyless endpoints answer without it. A
|
||||
* non-2xx or unparseable body is an honest error, never a silent empty list.
|
||||
*/
|
||||
export async function discoverModels(baseUrl: string, apiKey?: string): Promise<DiscoverResult> {
|
||||
const base = normalizeBaseUrl(baseUrl);
|
||||
if (!base) return { ok: false, error: "Base URL must be a valid http(s) URL" };
|
||||
const key = apiKey?.trim() || keyValue(CUSTOM_PROVIDER.envVar)?.trim();
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 15_000);
|
||||
try {
|
||||
const res = await fetch(`${base}/models`, {
|
||||
headers: key ? { authorization: `Bearer ${key}` } : {},
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) return { ok: false, error: `Endpoint returned ${res.status} ${res.statusText}` };
|
||||
const body = (await res.json().catch(() => null)) as unknown;
|
||||
const list = extractModels(body);
|
||||
if (list === null) return { ok: false, error: "Endpoint response was not a model list" };
|
||||
return { ok: true, models: list };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? (err.name === "AbortError" ? "Endpoint timed out" : err.message) : "Request failed";
|
||||
return { ok: false, error: msg };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/** The model rows in a list response, or null when the shape is unrecognized.
|
||||
* Accepts the OpenAI `{data:[…]}`, the Ollama-style `{models:[…]}`, or a
|
||||
* bare array. */
|
||||
function modelRows(body: unknown): unknown[] | null {
|
||||
if (Array.isArray(body)) return body;
|
||||
if (isRecord(body)) {
|
||||
if (Array.isArray(body.data)) return body.data;
|
||||
if (Array.isArray(body.models)) return body.models;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Does a /models row advertise image input? Endpoints are inconsistent, so
|
||||
* scan the common modality/capability fields (OpenRouter's
|
||||
* `architecture.input_modalities`, plus `input_modalities` / `modalities` /
|
||||
* `supported_modalities` / `capabilities`) for a value mentioning image or
|
||||
* vision. Absent/unrecognized -> false (text-only); the Keys row exposes a
|
||||
* manual toggle for endpoints that report nothing. */
|
||||
function detectImage(row: Record<string, unknown>): boolean {
|
||||
const architecture = isRecord(row.architecture) ? row.architecture : undefined;
|
||||
const candidates = [
|
||||
architecture?.input_modalities,
|
||||
row.input_modalities,
|
||||
row.modalities,
|
||||
row.supported_modalities,
|
||||
row.capabilities,
|
||||
];
|
||||
for (const c of candidates) {
|
||||
if (Array.isArray(c) && c.some((v) => typeof v === "string" && /image|vision/i.test(v))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** First positive integer among `keys` on a /models row, else undefined.
|
||||
* Endpoints name the token limits inconsistently, so the caller passes an
|
||||
* ordered list of candidate field names. */
|
||||
function firstPositive(row: Record<string, unknown>, keys: readonly string[]): number | undefined {
|
||||
for (const key of keys) {
|
||||
const value = row[key];
|
||||
if (typeof value === "number" && Number.isInteger(value) && value > 0) return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Field names seen in the wild for each limit (first present wins).
|
||||
const CONTEXT_KEYS = ["max_input_tokens", "context_length", "context_window", "max_context", "n_ctx", "max_seq_len", "max_position_embeddings"] as const;
|
||||
const MAX_TOKEN_KEYS = ["max_output_tokens"] as const;
|
||||
|
||||
/** Pull models out of a list response (entries are `{id, ...}` objects or bare
|
||||
* strings), each carrying its detected image capability and token limits.
|
||||
* Returns null when the shape is unrecognized - the caller reports an honest
|
||||
* error rather than a misleading empty "no models". */
|
||||
function extractModels(body: unknown): CustomModel[] | null {
|
||||
const rows = modelRows(body);
|
||||
if (rows === null) return null;
|
||||
const byId = new Map<string, CustomModel>();
|
||||
for (const row of rows) {
|
||||
if (typeof row === "string") {
|
||||
if (row.trim()) byId.set(row, { id: row });
|
||||
continue;
|
||||
}
|
||||
if (isRecord(row) && typeof row.id === "string" && row.id.trim()) {
|
||||
const model: CustomModel = { id: row.id };
|
||||
if (detectImage(row)) model.image = true;
|
||||
const context = firstPositive(row, CONTEXT_KEYS);
|
||||
if (context) model.context = context;
|
||||
const maxTokens = firstPositive(row, MAX_TOKEN_KEYS);
|
||||
if (maxTokens) model.maxTokens = maxTokens;
|
||||
byId.set(row.id, model);
|
||||
}
|
||||
}
|
||||
return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id));
|
||||
}
|
||||
@@ -45,3 +45,7 @@ export * from "./observability.ts";
|
||||
export * from "./model-catalog.ts";
|
||||
export { subscriptionControlsResponse, subscriptionBrowserResponse } from "./web-subscription-http.ts";
|
||||
export { sessionCost } from "./pricing.ts";
|
||||
// The custom OpenAI-compatible endpoint (custom-endpoint wave): the deep
|
||||
// module that registers the user's endpoint into piModels() and probes it for
|
||||
// models. Importing it has no side effects; the boot sync lives in stores.ts.
|
||||
export * from "./custom-provider.ts";
|
||||
|
||||
+10
-4
@@ -43,6 +43,7 @@ import {
|
||||
AUTH_KEYS,
|
||||
AUTH_PROVIDER_KEYS,
|
||||
AUTH_PROVIDER_ENV_VARS,
|
||||
CUSTOM_PROVIDER,
|
||||
type AuthKeyInfo,
|
||||
type AuthKeyStatus,
|
||||
genericProviderKeyGuard,
|
||||
@@ -72,10 +73,12 @@ function boot(): AuthBoot {
|
||||
return g[BOOT_KEY];
|
||||
}
|
||||
|
||||
/** Is `id` a WRITABLE key name — curated catalog or model-provider env
|
||||
* var (provider-keys interlude: PUT/DELETE accept both). */
|
||||
/** Is `id` a WRITABLE key name — curated catalog, model-provider env
|
||||
* var (provider-keys interlude), or the custom endpoint's reserved key
|
||||
* (custom-endpoint wave: writable through the same seam, generic shape
|
||||
* guard, but NOT part of the test-pinned AUTH_PROVIDER_KEYS table). */
|
||||
export function isWritableAuthKey(id: string): boolean {
|
||||
return isKnownAuthKey(id) || isProviderEnvVar(id);
|
||||
return isKnownAuthKey(id) || isProviderEnvVar(id) || id === CUSTOM_PROVIDER.envVar;
|
||||
}
|
||||
|
||||
/** The settings page's provider dropdown: every pi-ai provider that takes
|
||||
@@ -213,7 +216,10 @@ export function providerKeyPresent(provider: string): boolean {
|
||||
* reports shadowedByEnv so the page can explain it).
|
||||
*/
|
||||
export function saveKey(id: string, value: string): { ok: true; status: AuthKeyStatus } | { ok: false; error: string } {
|
||||
const shape = isProviderEnvVar(id) ? genericProviderKeyGuard(id, value) : validateKeyShape(id, value);
|
||||
const shape =
|
||||
isProviderEnvVar(id) || id === CUSTOM_PROVIDER.envVar
|
||||
? genericProviderKeyGuard(id, value)
|
||||
: validateKeyShape(id, value);
|
||||
if (!shape.ok) return { ok: false, error: shape.error };
|
||||
const trimmed = value.trim();
|
||||
authStore.setAuthVar(id, trimmed);
|
||||
|
||||
@@ -48,6 +48,8 @@ import type {
|
||||
} from "../core/contracts/models.ts";
|
||||
import type { EffectiveModelSpec, EffectiveSlot } from "../core/settings-resolution.ts";
|
||||
import { AUTH_PROVIDER_KEYS } from "../core/auth-keys.ts";
|
||||
import { CUSTOM_PROVIDER } from "../core/auth-keys.ts";
|
||||
import { customProviderUsable } from "../core/settings.ts";
|
||||
import { BACKGROUND_MODEL_ROLES, MODEL_SLOTS, type BackgroundModelRole, type ModelSlotName, type ModelsSettings } from "../core/settings.ts";
|
||||
|
||||
/** The picker's client-safe model entry (contract: core/contracts/models.ts). */
|
||||
@@ -142,6 +144,15 @@ export function catalogProviders(): CatalogProvider[] {
|
||||
})).concat([
|
||||
{ id: "chatgpt", envVar: "", keyPresent: chatGPTConnected() },
|
||||
...NATIVE_SUBSCRIPTIONS.map((p) => ({ id: p.logicalId, envVar: "", keyPresent: nativeSubscriptionConnected(p.providerId) })),
|
||||
// The custom endpoint (custom-endpoint wave): a row only when usable (a
|
||||
// base URL is set). Its key is OPTIONAL (keyless local servers), so
|
||||
// keyPresent here reflects USABILITY, not key presence — the picker
|
||||
// federates exactly the usable providers. `customProviderUsable` is the
|
||||
// single source of that rule, so this row and the sync in custom-provider.ts
|
||||
// can never disagree.
|
||||
...(customProviderUsable(effectiveSettings().customProvider)
|
||||
? [{ id: CUSTOM_PROVIDER.id, envVar: CUSTOM_PROVIDER.envVar, keyPresent: true }]
|
||||
: []),
|
||||
]).sort((a, b) => a.id.localeCompare(b.id));
|
||||
}
|
||||
|
||||
|
||||
@@ -387,7 +387,9 @@ export function parseDictationSettingsBody(body: unknown): SettingsBodyResult<Di
|
||||
}
|
||||
|
||||
/** The section→parser map for PUT /api/settings/[section] (the route also
|
||||
* carries the special prompts sub-resource). */
|
||||
* carries the special prompts sub-resource). The custom endpoint is NOT a
|
||||
* section here — it is one more provider in the Keys list, connected through
|
||||
* POST /api/custom-provider (base URL + key + discovery in one action). */
|
||||
export const SETTINGS_SECTIONS = {
|
||||
general: parseGeneralSettingsBody,
|
||||
chat: parseSettingsBody,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { graphStore } from "../store/graph.ts";
|
||||
import { jobStore } from "../store/jobs.ts";
|
||||
import { settingsStore } from "../store/settings.ts";
|
||||
import { resolveSettings, type EffectiveSettings } from "../core/settings-resolution.ts";
|
||||
import { syncCustomProvider } from "./custom-provider.ts";
|
||||
/** The wired adapters. Satisfies core/graph.ts's GraphDeps structurally. */
|
||||
export const stores = {
|
||||
chatStore,
|
||||
@@ -32,3 +33,9 @@ export type Stores = typeof stores;
|
||||
export function effectiveSettings(): EffectiveSettings {
|
||||
return resolveSettings(settingsStore.readSettings());
|
||||
}
|
||||
|
||||
// Boot: register the custom OpenAI-compatible endpoint (when configured) into
|
||||
// the process-wide piModels() so its models federate with no restart — the
|
||||
// exact lifecycle the keys seam runs for keys (initAuthEnv at import). The
|
||||
// POST /api/custom-provider route re-syncs after every connect.
|
||||
syncCustomProvider(effectiveSettings().customProvider);
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Tests for the custom OpenAI-compatible endpoint (custom-endpoint wave).
|
||||
* Covers the pure core (normalizeBaseUrl, customProviderUsable, the sanitizer,
|
||||
* resolution), the deep server module (provider build + live sync + model
|
||||
* discovery incl. image-modality detection), and the catalog federation row.
|
||||
* No network: the discovery probe runs against a stubbed global fetch.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { after, describe, it } from "node:test";
|
||||
import {
|
||||
CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW,
|
||||
CUSTOM_MODEL_DEFAULT_MAX_TOKENS,
|
||||
customProviderUsable,
|
||||
normalizeBaseUrl,
|
||||
sanitizeUserSettings,
|
||||
} from "../src/core/settings.ts";
|
||||
import { resolveSettings } from "../src/core/settings-resolution.ts";
|
||||
import { CUSTOM_PROVIDER } from "../src/core/auth-keys.ts";
|
||||
import {
|
||||
buildOpenAiCompatibleProvider,
|
||||
discoverModels,
|
||||
syncCustomProvider,
|
||||
} from "../src/server/custom-provider.ts";
|
||||
import { piModels } from "../src/agent/models.ts";
|
||||
|
||||
// ─── normalizeBaseUrl ────────────────────────────────────────────────────────
|
||||
describe("normalizeBaseUrl", () => {
|
||||
it("drops trailing slashes and keeps the path", () => {
|
||||
assert.equal(normalizeBaseUrl("https://host:8000/v1/"), "https://host:8000/v1");
|
||||
assert.equal(normalizeBaseUrl(" https://host/v1 "), "https://host/v1");
|
||||
assert.equal(normalizeBaseUrl("http://localhost:11434"), "http://localhost:11434");
|
||||
});
|
||||
it("rejects non-http(s) schemes and junk with empty string", () => {
|
||||
assert.equal(normalizeBaseUrl("file:///etc/passwd"), "");
|
||||
assert.equal(normalizeBaseUrl("not a url"), "");
|
||||
assert.equal(normalizeBaseUrl(""), "");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── customProviderUsable ────────────────────────────────────────────────────
|
||||
describe("customProviderUsable", () => {
|
||||
it("is true exactly when a base URL is set (the key is optional)", () => {
|
||||
assert.equal(customProviderUsable({ baseUrl: "https://h/v1" }), true);
|
||||
assert.equal(customProviderUsable({ baseUrl: "" }), false);
|
||||
assert.equal(customProviderUsable({}), false);
|
||||
assert.equal(customProviderUsable(undefined), false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── sanitizer ───────────────────────────────────────────────────────────────
|
||||
describe("sanitizeUserSettings — customProvider", () => {
|
||||
it("normalizes the base URL and cleans the model list (trim, dedupe, image flag)", () => {
|
||||
const out = sanitizeUserSettings({
|
||||
customProvider: {
|
||||
baseUrl: "https://host:8000/v1/",
|
||||
models: [
|
||||
{ id: " llama-3 " }, // trims id; image absent
|
||||
{ id: "gpt", image: true },
|
||||
{ id: "gpt", image: false }, // dup id wins (last) → image dropped
|
||||
{ id: "" }, // dropped: no usable id
|
||||
"not-an-object", // dropped
|
||||
],
|
||||
},
|
||||
});
|
||||
const cp = out?.customProvider;
|
||||
assert.equal(cp?.baseUrl, "https://host:8000/v1");
|
||||
assert.deepEqual(cp?.models, [{ id: "llama-3" }, { id: "gpt" }]);
|
||||
});
|
||||
it("keeps image:true and drops a non-array models list / invalid base URL", () => {
|
||||
const out = sanitizeUserSettings({ customProvider: { baseUrl: "ftp://x", models: "nope" } });
|
||||
assert.equal(out?.customProvider?.baseUrl, undefined);
|
||||
assert.equal(out?.customProvider?.models, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── resolution ──────────────────────────────────────────────────────────────
|
||||
describe("resolveSettings — customProvider", () => {
|
||||
it("resolves an absent section to concrete defaults", () => {
|
||||
assert.deepEqual(resolveSettings(null).customProvider, { baseUrl: "", models: [] });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── provider build + live sync ──────────────────────────────────────────────
|
||||
describe("buildOpenAiCompatibleProvider / syncCustomProvider", () => {
|
||||
after(() => piModels().deleteProvider(CUSTOM_PROVIDER.id));
|
||||
|
||||
it("maps ids to defaults and marks image-capable models image-input", () => {
|
||||
const provider = buildOpenAiCompatibleProvider({
|
||||
baseUrl: "http://h/v1",
|
||||
models: [{ id: "m1" }, { id: "m2", image: true }],
|
||||
});
|
||||
assert.equal(provider.id, CUSTOM_PROVIDER.id);
|
||||
const [m1, m2] = provider.getModels();
|
||||
assert.equal(m1!.name, "m1");
|
||||
assert.equal(m1!.api, "openai-completions");
|
||||
assert.equal(m1!.provider, CUSTOM_PROVIDER.id);
|
||||
assert.equal(m1!.baseUrl, "http://h/v1");
|
||||
assert.equal(m1!.contextWindow, CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW);
|
||||
assert.equal(m1!.maxTokens, CUSTOM_MODEL_DEFAULT_MAX_TOKENS);
|
||||
assert.deepEqual(m1!.input, ["text"]); // text-only by default
|
||||
assert.deepEqual(m2!.input, ["text", "image"]); // image-capable → Vision-eligible
|
||||
assert.equal(m1!.contextWindow, CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW); // no endpoint value → default
|
||||
assert.equal(m1!.maxTokens, CUSTOM_MODEL_DEFAULT_MAX_TOKENS);
|
||||
});
|
||||
|
||||
it("carries endpoint token limits when present", () => {
|
||||
const provider = buildOpenAiCompatibleProvider({
|
||||
baseUrl: "http://h/v1",
|
||||
models: [{ id: "big", context: 196608, maxTokens: 393216 }],
|
||||
});
|
||||
const [big] = provider.getModels();
|
||||
assert.equal(big!.contextWindow, 196608); // 192k from the endpoint
|
||||
assert.equal(big!.maxTokens, 393216);
|
||||
});
|
||||
|
||||
it("registers on a usable sync and removes when the base URL clears", () => {
|
||||
syncCustomProvider({ baseUrl: "http://h/v1", models: [{ id: "live" }] });
|
||||
assert.ok(piModels().getProvider(CUSTOM_PROVIDER.id), "provider registered");
|
||||
assert.ok(piModels().getModel(CUSTOM_PROVIDER.id, "live"), "model resolvable");
|
||||
syncCustomProvider({ baseUrl: "", models: [{ id: "live" }] });
|
||||
assert.equal(piModels().getProvider(CUSTOM_PROVIDER.id), undefined, "provider removed");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── discovery (stubbed fetch) ───────────────────────────────────────────────
|
||||
describe("discoverModels", () => {
|
||||
const realFetch = globalThis.fetch;
|
||||
after(() => {
|
||||
globalThis.fetch = realFetch;
|
||||
});
|
||||
|
||||
function stubFetch(handler: (url: string) => { status: number; body: unknown }) {
|
||||
globalThis.fetch = (async (input: unknown) => {
|
||||
const res = handler(String(input));
|
||||
return new Response(JSON.stringify(res.body), { status: res.status });
|
||||
}) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
it("reads OpenAI {data:[{id}]} and Ollama {models:[{id}]} shapes", async () => {
|
||||
stubFetch(() => ({ status: 200, body: { data: [{ id: "b" }, { id: "a" }] } }));
|
||||
const openai = await discoverModels("http://h/v1");
|
||||
assert.ok(openai.ok);
|
||||
assert.deepEqual(openai.models, [{ id: "a" }, { id: "b" }]);
|
||||
stubFetch(() => ({ status: 200, body: { models: [{ id: "x" }] } }));
|
||||
const r = await discoverModels("http://h/v1");
|
||||
assert.ok(r.ok && r.models.length === 1 && r.models[0]!.id === "x");
|
||||
});
|
||||
it("detects image capability from common modality fields", async () => {
|
||||
stubFetch(() => ({
|
||||
status: 200,
|
||||
body: {
|
||||
data: [
|
||||
{ id: "vision-model", architecture: { input_modalities: ["text", "image"] } },
|
||||
{ id: "cap-model", capabilities: ["completion", "vision"] },
|
||||
{ id: "text-model", architecture: { input_modalities: ["text"] } },
|
||||
],
|
||||
},
|
||||
}));
|
||||
const r = await discoverModels("http://h/v1");
|
||||
assert.ok(r.ok);
|
||||
const byId = Object.fromEntries(r.models.map((m) => [m.id, Boolean(m.image)]));
|
||||
assert.equal(byId["vision-model"], true);
|
||||
assert.equal(byId["cap-model"], true);
|
||||
assert.equal(byId["text-model"], false);
|
||||
});
|
||||
it("detects token limits from common metadata fields", async () => {
|
||||
stubFetch(() => ({
|
||||
status: 200,
|
||||
body: {
|
||||
data: [
|
||||
{ id: "big", max_input_tokens: 196608, max_output_tokens: 393216 },
|
||||
{ id: "ctx", context_length: 32768 },
|
||||
{ id: "plain" },
|
||||
],
|
||||
},
|
||||
}));
|
||||
const r = await discoverModels("http://h/v1");
|
||||
assert.ok(r.ok);
|
||||
const byId = Object.fromEntries(r.models.map((m) => [m.id, m]));
|
||||
assert.equal(byId["big"]!.context, 196608);
|
||||
assert.equal(byId["big"]!.maxTokens, 393216);
|
||||
assert.equal(byId["ctx"]!.context, 32768);
|
||||
assert.equal(byId["ctx"]!.maxTokens, undefined);
|
||||
assert.equal(byId["plain"]!.context, undefined);
|
||||
});
|
||||
it("guards the scheme before any fetch", async () => {
|
||||
let called = false;
|
||||
globalThis.fetch = (async () => {
|
||||
called = true;
|
||||
return new Response("{}", { status: 200 });
|
||||
}) as unknown as typeof fetch;
|
||||
const r = await discoverModels("file:///etc/passwd");
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(called, false, "no fetch for a non-http scheme");
|
||||
});
|
||||
it("reports a non-2xx and an unparseable shape as honest errors", async () => {
|
||||
stubFetch(() => ({ status: 500, body: {} }));
|
||||
assert.equal((await discoverModels("http://h/v1")).ok, false);
|
||||
stubFetch(() => ({ status: 200, body: { unexpected: true } }));
|
||||
assert.equal((await discoverModels("http://h/v1")).ok, false);
|
||||
});
|
||||
});
|
||||
@@ -236,7 +236,9 @@ describe("parseCompactionSettingsBody (owner round 14)", () => {
|
||||
describe("SETTINGS_SECTIONS (the route's section map)", () => {
|
||||
it("maps exactly the eight record sections", () => {
|
||||
// compaction joined (owner round 14): its own section PUT — the
|
||||
// threshold is engine behavior, not an agent-section key.
|
||||
// threshold is engine behavior, not an agent-section key. The custom
|
||||
// endpoint is NOT a section: it is one more provider in the Keys list,
|
||||
// connected through POST /api/custom-provider.
|
||||
assert.deepEqual(
|
||||
Object.keys(SETTINGS_SECTIONS),
|
||||
["general", "chat", "agent", "appearance", "graph", "compaction", "models", "dictation"],
|
||||
|
||||
Reference in New Issue
Block a user