From 07b6403d9423e5bb051a66358d003a4e7aa11ea9 Mon Sep 17 00:00:00 2001 From: Daniel Buckner Date: Sat, 24 Oct 2026 09:53:10 -0700 Subject: [PATCH] feat: custom OpenAI-compatible endpoint as a first-class provider (vision + context from /models) --- app/api/custom-provider/route.ts | 84 ++++++++++ app/api/settings/[section]/route.ts | 4 +- components/settings/keys-section.tsx | 184 ++++++++++++++++++++- components/setup-dialog.tsx | 107 ++++++++---- src/core/auth-keys.ts | 18 +++ src/core/settings-resolution.ts | 10 ++ src/core/settings.ts | 120 ++++++++++++++ src/server/custom-provider.ts | 232 +++++++++++++++++++++++++++ src/server/index.ts | 4 + src/server/keys.ts | 14 +- src/server/model-catalog.ts | 11 ++ src/server/settings-body.ts | 4 +- src/server/stores.ts | 7 + tests/custom-endpoint.test.ts | 203 +++++++++++++++++++++++ tests/settings-body.test.ts | 4 +- 15 files changed, 965 insertions(+), 41 deletions(-) create mode 100644 app/api/custom-provider/route.ts create mode 100644 src/server/custom-provider.ts create mode 100644 tests/custom-endpoint.test.ts 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" + /> + + +
+ {status && status.models.length > 0 && ( +
+ + Models — check the ones that accept images (needed for the Vision slot) + + {status.models.map((m) => ( + + ))} +
+ )} + {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 ( +
+ +
+ ); +} 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" - /> - +
+ {isCustom && ( + 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" + /> + )} +
+ 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" + /> + +
)} {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"],