diff --git a/app/api/custom-provider/route.ts b/app/api/custom-provider/route.ts
new file mode 100644
index 0000000..ac12fc0
--- /dev/null
+++ b/app/api/custom-provider/route.ts
@@ -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 ?? [] });
+}
diff --git a/app/api/settings/[section]/route.ts b/app/api/settings/[section]/route.ts
index 2c955cf..460b593 100644
--- a/app/api/settings/[section]/route.ts
+++ b/app/api/settings/[section]/route.ts
@@ -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 });
}
diff --git a/components/settings/keys-section.tsx b/components/settings/keys-section.tsx
index 198e93d..9777756 100644
--- a/components/settings/keys-section.tsx
+++ b/components/settings/keys-section.tsx
@@ -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({
}}
/>
)}
+
{adding && (
{adding.label}
@@ -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
(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 (
+
+
+ Custom endpoint
+ 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"
+ />
+
+
+ {
+ 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"
+ />
+ 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"}
+
+ 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
+
+
+ {status && status.models.length > 0 && (
+
+
+ Models — check the ones that accept images (needed for the Vision slot)
+
+ {status.models.map((m) => (
+
+ void toggleImage(m.id)}
+ data-testid={`custom-image-${m.id.replace(/[^a-z0-9-]/gi, "_")}`}
+ />
+ {m.id}
+ images
+
+ ))}
+
+ )}
+ {error && (
+
+ {error}
+
+ )}
+
+ );
+ }
+
+ 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 (
+
+ {
+ 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"
+ >
+ Custom endpoint
+
+ {line}
+
+
+
+ );
+}
diff --git a/components/setup-dialog.tsx b/components/setup-dialog.tsx
index 053f3eb..4c1d9ec 100644
--- a/components/setup-dialog.tsx
+++ b/components/setup-dialog.tsx
@@ -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 = {
};
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;
+ /** 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 (
@@ -185,30 +199,44 @@ export function SetupKeysBody({
)}
{provider && (
-
-
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"
- />
-
- {saving ? "Saving…" : "Save key"}
-
+
)}
{error && (
@@ -264,6 +292,7 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) {
const [keyProviders, setKeyProviders] = useState
(undefined);
const [keyProvider, setKeyProvider] = useState(null);
const [keyValue, setKeyValue] = useState("");
+ const [customBaseUrl, setCustomBaseUrl] = useState("");
const [keySaving, setKeySaving] = useState(false);
const [keyError, setKeyError] = useState(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 = setKeyMenuOpen((v) => !v)}
onPick={(p) => { setKeyProvider(p); setKeyMenuOpen(false); setKeyError(null); }}
onValue={setKeyValue}
diff --git a/src/core/auth-keys.ts b/src/core/auth-keys.ts
index e8e5cc9..008118c 100644
--- a/src/core/auth-keys.ts
+++ b/src/core/auth-keys.ts
@@ -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). */
diff --git a/src/core/settings-resolution.ts b/src/core/settings-resolution.ts
index 1a1cbf9..4c41d2b 100644
--- a/src/core/settings-resolution.ts
+++ b/src/core/settings-resolution.ts
@@ -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;
+ /** 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 ?? [],
+ },
};
}
diff --git a/src/core/settings.ts b/src/core/settings.ts
index f7966f1..560e8d9 100644
--- a/src/core/settings.ts
+++ b/src/core/settings.ts
@@ -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): 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): 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();
+ for (const m of raw.models) {
+ if (typeof m !== "object" || m === null) continue;
+ const rec = m as Record;
+ 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 | undefined {
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return undefined;
const out: Record = {};
@@ -641,6 +758,9 @@ export function sanitizeUserSettings(raw: unknown): UserSettings | null {
const models = sanitizeModels(r.models as Record);
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);
+ }
if (r.graph !== null && typeof r.graph === "object" && !Array.isArray(r.graph)) {
out.graph = sanitizeGraph(r.graph as Record);
if (r.compaction !== undefined && r.compaction !== null && typeof r.compaction === "object") {
diff --git a/src/server/custom-provider.ts b/src/server/custom-provider.ts
new file mode 100644
index 0000000..6c0cf3f
--- /dev/null
+++ b/src/server/custom-provider.ts
@@ -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 =>
+ 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 {
+ 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 {
+ return createProvider({
+ 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 {
+ 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): 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, 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();
+ 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));
+}
diff --git a/src/server/index.ts b/src/server/index.ts
index a04dfb5..591054c 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -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";
diff --git a/src/server/keys.ts b/src/server/keys.ts
index 706de52..ce05359 100644
--- a/src/server/keys.ts
+++ b/src/server/keys.ts
@@ -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);
diff --git a/src/server/model-catalog.ts b/src/server/model-catalog.ts
index 11fbe0f..2fa8819 100644
--- a/src/server/model-catalog.ts
+++ b/src/server/model-catalog.ts
@@ -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));
}
diff --git a/src/server/settings-body.ts b/src/server/settings-body.ts
index 9b1a173..595067e 100644
--- a/src/server/settings-body.ts
+++ b/src/server/settings-body.ts
@@ -387,7 +387,9 @@ export function parseDictationSettingsBody(body: unknown): SettingsBodyResult {
+ 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);
+ });
+});
diff --git a/tests/settings-body.test.ts b/tests/settings-body.test.ts
index 16c0ac9..c194c60 100644
--- a/tests/settings-body.test.ts
+++ b/tests/settings-body.test.ts
@@ -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"],