From 07b6403d9423e5bb051a66358d003a4e7aa11ea9 Mon Sep 17 00:00:00 2001 From: Daniel Buckner Date: Sat, 24 Oct 2026 09:53:10 -0700 Subject: [PATCH 1/9] 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"], From 870a1ad34383189a0caff81aad0832e2b0520356 Mon Sep 17 00:00:00 2001 From: Daniel Buckner Date: Sat, 24 Oct 2026 10:18:13 -0700 Subject: [PATCH 2/9] feat: custom endpoint as a first-class provider (add via dropdown, required key, vision ordering) --- app/api/custom-provider/route.ts | 17 +- components/settings/agent-section.tsx | 8 +- components/settings/keys-section.tsx | 364 +++++++++++++---------- components/settings/model-menu.tsx | 6 + components/settings/search-menu-list.tsx | 13 +- components/setup-cta.tsx | 10 +- components/setup-dialog.tsx | 13 +- tests/setup-dialog-interaction.test.tsx | 7 +- 8 files changed, 242 insertions(+), 196 deletions(-) diff --git a/app/api/custom-provider/route.ts b/app/api/custom-provider/route.ts index ac12fc0..e14bcd8 100644 --- a/app/api/custom-provider/route.ts +++ b/app/api/custom-provider/route.ts @@ -39,25 +39,10 @@ export async function GET() { } export async function POST(request: Request) { - const body = (await request.json().catch(() => null)) as { - baseUrl?: unknown; - key?: unknown; - models?: unknown; - } | null; + const body = (await request.json().catch(() => null)) as { baseUrl?: unknown; key?: 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"; diff --git a/components/settings/agent-section.tsx b/components/settings/agent-section.tsx index 33f5512..aa1271f 100644 --- a/components/settings/agent-section.tsx +++ b/components/settings/agent-section.tsx @@ -600,9 +600,10 @@ export function AgentSection({ const chosen = coord ? catalog?.find((m) => m.id === coord.id && m.provider === coord.provider) : undefined; const levels = chosen ? chosen.supportedLevels : THINKING_LEVELS; const thisMenuOpen = openMenu?.target === slot ? openMenu.kind : null; - // Vision slots pick from image-capable models only. - const slotCatalog = - slot === "vision" ? catalog?.filter((m) => m.input.includes("image")) : catalog; + // Vision slot: image-capable models float first, but all stay + // selectable (a custom endpoint that doesn't report modality must + // remain pickable) — so this orders, never filters. + const slotCatalog = catalog; return ( { // Auto-populate (chunk 2, owner: "populate everywhere"): the // fresh pick writes a level — the stored one rides as-is diff --git a/components/settings/keys-section.tsx b/components/settings/keys-section.tsx index 9777756..cd80df5 100644 --- a/components/settings/keys-section.tsx +++ b/components/settings/keys-section.tsx @@ -45,12 +45,28 @@ interface KeysResponse { providers?: ProviderKeyInfo[]; } +/** The custom endpoint's non-secret status (GET /api/custom-provider). */ +interface CustomStatus { + baseUrl: string; + models: CustomModel[]; + keyPresent: boolean; +} + /** The curated tools group, pinned by id (the catalog owns the copy; * these ids are the contract). Derived from the catalog (search wave, * 2026-09-20: the Google CSE pair retired, Tavily + Brave joined) — the * section can never drift from core/auth-keys.ts again. */ const TOOL_IDS = AUTH_KEYS.map((k) => k.id) as readonly string[]; +/** The custom endpoint as it appears in the Add-provider dropdown — a synthetic + * entry (it is not one of pi-ai's 33), offered only while unconfigured; once + * connected it graduates to its own row like any stored provider. */ +const CUSTOM_PROVIDER_INFO: ProviderKeyInfo = { + id: CUSTOM_PROVIDER.id, + envVar: CUSTOM_PROVIDER.envVar, + label: CUSTOM_PROVIDER.label, +}; + /** The classification driving the table groups. Pure — exported for * tests. OpenRouter is un-pinned (wave-5 owner ruling): no synthesis, * no special row — it rides the same provider classification. */ @@ -303,7 +319,7 @@ function AddProviderRow({ onClick={() => setOpen((v) => !v)} className="flex w-full items-center justify-between gap-3 rounded-sm px-1 py-1.5 text-left font-ui text-xs text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground" > - + Add provider key + + Add a provider {addable.length > 0 && {addable.length}} {open && ( @@ -346,13 +362,16 @@ export function KeysSection({ /** The paste editor for a freshly chosen Add-provider target. */ const [adding, setAdding] = useState(null); const [addError, setAddError] = useState(null); + /** The custom endpoint's status + whether its connect editor is open. */ + const [custom, setCustom] = useState(null); + const [addingCustom, setAddingCustom] = useState(false); // Escape folds any open editor (unfolded row or the Add-provider paste) // WITHOUT closing the dialog. Claimed at document CAPTURE phase — the // only stopPropagation that outruns both the input's React handler and // the dialog's window-level listener. useEffect(() => { - if (openRow === null && adding === null) return; + if (openRow === null && adding === null && !addingCustom) return; const onKeyDown = (e: KeyboardEvent) => { if (e.key !== "Escape") return; // A language dropdown above this editor in the dismissal stack owns @@ -362,20 +381,35 @@ export function KeysSection({ e.stopPropagation(); setOpenRow(null); setAdding(null); + setAddingCustom(false); }; document.addEventListener("keydown", onKeyDown, true); return () => document.removeEventListener("keydown", onKeyDown, true); - }, [openRow, adding]); + }, [openRow, adding, addingCustom]); - const reload = () => - fetch("/api/keys") - .then((r) => r.json()) - .then((body: KeysResponse) => { - setStatuses(body.keys ?? []); - if (body.providers) setProviders(body.providers); - }) - .catch(() => setStatuses([])) - .finally(() => setLoading(false)); + const reload = async () => { + try { + const body = (await fetch("/api/keys").then((r) => r.json())) as KeysResponse; + setStatuses(body.keys ?? []); + if (body.providers) setProviders(body.providers); + } catch { + setStatuses([]); + } finally { + setLoading(false); + } + // Best-effort and independent: the custom endpoint's status must never + // blank the provider-key list above if its fetch fails. + try { + const cp = (await fetch("/api/custom-provider").then((r) => r.json())) as Partial; + setCustom({ + baseUrl: cp.baseUrl ?? "", + models: cp.models ?? [], + keyPresent: Boolean(cp.keyPresent), + }); + } catch { + /* custom status unavailable — leave it unset */ + } + }; useEffect(() => { void reload(); @@ -448,16 +482,52 @@ export function KeysSection({ testid={`key-${status.id.toLowerCase()}`} /> ))} + {custom?.baseUrl && ( + setAddingCustom((v) => !v)} + /> + )} + {custom?.baseUrl && addingCustom && ( + { + setAddingCustom(false); + await reload(); + notifySettingsSaved("keys"); + }} + onCancel={() => setAddingCustom(false)} + /> + )} {providers !== undefined && ( { - setAdding(p); setAddError(null); + if (p.id === CUSTOM_PROVIDER.id) { + setAdding(null); + setAddingCustom(true); + } else { + setAddingCustom(false); + setAdding(p); + } }} /> )} - + {!custom?.baseUrl && addingCustom && ( + { + setAddingCustom(false); + await reload(); + notifySettingsSaved("keys"); + }} + onCancel={() => setAddingCustom(false)} + /> + )} {adding && (
{adding.label} @@ -567,31 +637,65 @@ 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(""); +/** The custom endpoint's status row in the provider list — one more provider, + * shown only once it's configured (exactly like a built-in appears only once + * its key is stored). Clicking re-opens the connect editor. */ +function CustomEndpointRow({ + status, + expanded, + onToggle, +}: { + status: CustomStatus; + expanded: boolean; + onToggle: () => void; +}) { + const count = status.models.length; + const line = `${status.baseUrl} · ${count} model${count === 1 ? "" : "s"}${status.keyPresent ? "" : " · keyless"}`; + return ( +
+ +
+ ); +} + +/** The custom endpoint's connect editor — base URL + optional key, exactly the + * built-ins' key-paste flow plus the one extra URL field. Connect POSTs to + * /api/custom-provider (validate + discover + register); on success the parent + * reloads so the row appears and the catalog widens with no restart. Image + * capability is detected automatically from the endpoint — there is nothing to + * tick here. Keyless local servers (Ollama / LM Studio) connect with no key. */ +function CustomEndpointEditor({ + baseUrl0, + keyPresent, + onSaved, + onCancel, +}: { + baseUrl0: string; + keyPresent: boolean; + onSaved: () => void | Promise; + onCancel: () => void; +}) { + const [baseUrl, setBaseUrl] = useState(baseUrl0); 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(); - }, []); + // A key is required to enable chatting (the send gate needs a provider key); + // local servers accept any value. When one is already stored, leaving the + // field blank keeps it. + const needsKey = !keyPresent; + const canConnect = Boolean(baseUrl.trim()) && (!needsKey || Boolean(key.trim())); async function save() { if (busy || !baseUrl.trim()) return; @@ -608,138 +712,68 @@ function CustomProviderRow() { setError(res ? (b?.error ?? `Connect failed (${res.status})`) : "Connect failed. Check your connection."); return; } - setExpanded(false); - setKey(""); - setKeyTouched(false); - await reload(); - notifySettingsSaved("keys"); + await onSaved(); } - // 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 ( -
- +
+
+ 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={keyPresent ? "Key set — paste to replace, leave empty to keep" : "API key — required (any value works 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" + /> + + +
+ {needsKey && ( +

+ A key is required to enable chatting — local servers (Ollama, LM Studio) accept any value. +

+ )} + {error && ( +

+ {error} +

+ )}
); } diff --git a/components/settings/model-menu.tsx b/components/settings/model-menu.tsx index 792ae23..99d4f78 100644 --- a/components/settings/model-menu.tsx +++ b/components/settings/model-menu.tsx @@ -39,6 +39,7 @@ export function ModelMenuList({ effectiveProvider, onPick, footer, + prioritizeImage, }: { /** The full catalog — undefined while it loads. */ models: ModelCatalogEntry[] | undefined; @@ -53,6 +54,10 @@ export function ModelMenuList({ onPick: (entry: ModelCatalogEntry) => void; /** Quiet footer note under the list (context-specific). */ footer?: string; + /** Vision slot: image-capable models float to the top (still all + * selectable — a custom endpoint that doesn't report modality must + * stay pickable, so this orders rather than filters). */ + prioritizeImage?: boolean; }) { return ( <> @@ -61,6 +66,7 @@ export function ModelMenuList({ itemKey={(m) => `${m.provider}/${m.id}`} searchTerms={(m) => [m.provider, m.id, m.name]} compare={(a, b) => a.id.localeCompare(b.id)} + prioritize={prioritizeImage ? (m) => m.input.includes("image") : undefined} isSelected={(m) => m.id === effectiveId && m.provider === (effectiveProvider ?? m.provider)} onPick={onPick} searchTestid="model-search" diff --git a/components/settings/search-menu-list.tsx b/components/settings/search-menu-list.tsx index 08a4fa6..f864e3a 100644 --- a/components/settings/search-menu-list.tsx +++ b/components/settings/search-menu-list.tsx @@ -7,7 +7,7 @@ import { fuzzyScore } from "@/src/core"; * Models and dictation languages supply only their catalog and row content. * The caller owns the shared MenuShell and dismissal behavior. */ export function SearchMenuList({ - items, itemKey, searchTerms, compare, isSelected, onPick, renderItem, + items, itemKey, searchTerms, compare, prioritize, isSelected, onPick, renderItem, searchTestid, listTestid, optionTestid, placeholder, ariaLabel, loading, empty, disabled = false, }: { @@ -15,6 +15,10 @@ export function SearchMenuList({ itemKey: (item: T) => string; searchTerms: (item: T) => string[]; compare?: (a: T, b: T) => number; + /** Optional grouping key: items it answers true for sort ahead of the + * rest, fuzzy rank still deciding within each group (a stable partition, + * never a filter — nothing is hidden). */ + prioritize?: (item: T) => boolean; isSelected: (item: T) => boolean; onPick: (item: T) => void; renderItem: (item: T) => ReactNode; @@ -41,7 +45,12 @@ export function SearchMenuList({ const results = (items ?? []) .map((item) => ({ item, score: fuzzyScore(query, ...searchTerms(item)) })) .filter((result) => result.score !== null) - .sort((a, b) => b.score! - a.score! || (compare?.(a.item, b.item) ?? 0)) + .sort( + (a, b) => + (prioritize ? Number(prioritize(b.item)) - Number(prioritize(a.item)) : 0) || + b.score! - a.score! || + (compare?.(a.item, b.item) ?? 0), + ) .map(({ item }) => item); return <> diff --git a/components/setup-cta.tsx b/components/setup-cta.tsx index 299715d..2e34caa 100644 --- a/components/setup-cta.tsx +++ b/components/setup-cta.tsx @@ -16,7 +16,7 @@ */ import { useEffect, useState } from "react"; -import { MODEL_SLOTS, type AuthKeyStatus } from "@/src/core"; +import { CUSTOM_PROVIDER, MODEL_SLOTS, type AuthKeyStatus } from "@/src/core"; import { classifyKeys, type ProviderKeyInfo } from "@/components/settings/keys-section"; import { useModelCatalog } from "@/components/model-catalog"; import type { ModelCatalogResponse } from "@/src/core"; @@ -30,7 +30,13 @@ export function hasProviderKey( statuses: readonly AuthKeyStatus[], providers: readonly ProviderKeyInfo[] | undefined, ): boolean { - return classifyKeys(statuses, providers).providerRows.some((s) => s.present); + // A stored custom-endpoint key counts too: classifyKeys drops it from + // providerRows (it has its own dedicated row), but a connected custom + // endpoint IS a way to run inference, so it clears the zero-keys gate. + return ( + classifyKeys(statuses, providers).providerRows.some((s) => s.present) || + statuses.some((s) => s.id === CUSTOM_PROVIDER.envVar && s.present) + ); } /** Completeness is saved identity, not current provider availability. */ diff --git a/components/setup-dialog.tsx b/components/setup-dialog.tsx index 4c1d9ec..3face30 100644 --- a/components/setup-dialog.tsx +++ b/components/setup-dialog.tsx @@ -66,7 +66,7 @@ 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 + * Stage 1 roster, connected by a base URL + 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 }; @@ -160,7 +160,7 @@ export function SetupKeysBody({ /** 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 + * key field (a key is required — local servers accept any value). All three * optional so the plain-key callers (and tests) stay unchanged. */ isCustom?: boolean; baseUrl?: string; @@ -218,9 +218,9 @@ export function SetupKeysBody({ value={value} onChange={(e) => onValue(e.target.value)} onKeyDown={(e) => { - if (e.key === "Enter" && (isCustom ? baseUrl?.trim() : value.trim())) onSave(); + if (e.key === "Enter" && value.trim()) onSave(); }} - placeholder={isCustom ? "API key (optional for local servers)" : "Paste key…"} + placeholder={isCustom ? "API key — required (any value works for local servers)" : "Paste key…"} aria-label={`${provider.label} key`} data-testid="setup-key-input" disabled={saving} @@ -230,7 +230,7 @@ export function SetupKeysBody({ + {/* Disconnect rides beside Connect, exactly the stored-key rows' Delete: + quiet text, one click, no confirm — and only once there IS an + endpoint (a fresh add has nothing to delete). */} + {onDelete && baseUrl0.trim() && ( + + )}
{needsKey && (

From 695948271f014c37c85fb68f25c0286ea33ad650 Mon Sep 17 00:00:00 2001 From: Daniel Buckner Date: Tue, 27 Oct 2026 16:25:03 -0700 Subject: [PATCH 7/9] feat: user pins for local model limits (context, max output, thinking) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A local endpoint that publishes no limits — LM Studio's /models says nothing — left every model on the 8192/4096 defaults with no way to say "this one has a 131k window". Each discovered model now gets three optional pins in the endpoint editor: context window, max output tokens, thinking (auto/on/off). A pin always wins: user pin → what the endpoint published → compiled default. Blank means no opinion, so discovery keeps ruling wherever you don't pin. Pins live in customProvider.overrides keyed by model id, stored apart from the discovery snapshot: a reconnect refreshes the models and prunes the pins of models that vanished while keeping the rest. Pins are written by PATCH /api/custom-provider, which never probes — the whole point is configuring a server that isn't answering /models correctly. Connect still probes and still fails loudly when the endpoint is down. A pin under 2048 tokens is rejected on the write side and clamped up on the read path, matching the compaction-floor discipline. --- app/api/custom-provider/route.ts | 52 ++++++-- components/settings/keys-section.tsx | 175 ++++++++++++++++++++++++++- src/core/settings-resolution.ts | 7 +- src/core/settings.ts | 54 +++++++++ src/server/custom-provider.ts | 38 ++++-- src/server/settings-body.ts | 42 ++++++- tests/custom-endpoint.test.ts | 115 +++++++++++++++++- 7 files changed, 455 insertions(+), 28 deletions(-) diff --git a/app/api/custom-provider/route.ts b/app/api/custom-provider/route.ts index 05becc5..193dd2c 100644 --- a/app/api/custom-provider/route.ts +++ b/app/api/custom-provider/route.ts @@ -1,24 +1,28 @@ /** * The custom endpoint's connect surface (one provider in the Keys list). - * GET → non-secret config { baseUrl, models } + keyPresent (masked). - * POST → connect: validate + normalize the base URL, probe /models (fail - * fast), store the key, persist { baseUrl, models }, re-sync the - * provider into piModels(). Discovery lives here because connecting - * and discovering are one user action. + * GET → non-secret config { baseUrl, models, overrides } + keyPresent (masked). + * POST → connect: validate + normalize the base URL, probe /models (fail + * fast), store the key, persist { baseUrl, models }, re-sync the + * provider into piModels(). Discovery lives here because connecting + * and discovering are one user action. Stored user pins survive a + * reconnect, pruned to the ids the endpoint still advertises. + * PATCH → store per-model pins only. No probe: pins must be editable with + * the local server switched off (the case they exist for). * DELETE → disconnect: drop the stored config + the stored key and unregister - * the provider (the mirror image of POST; idempotent). + * the provider (the mirror image of POST; idempotent). */ import { deleteKey, discoverModels, effectiveSettings, keyValue, + parseCustomModelOverridesBody, readSettingsRecord, saveKey, syncCustomProvider, writeSettingsRecord, } from "@/src/server"; -import { CUSTOM_PROVIDER, normalizeBaseUrl } from "@/src/core"; +import { CUSTOM_PROVIDER, normalizeBaseUrl, sanitizeCustomModelOverrides } from "@/src/core"; export const dynamic = "force-dynamic"; @@ -27,6 +31,7 @@ export async function GET() { return Response.json({ baseUrl: cp.baseUrl, models: cp.models, + overrides: cp.overrides, keyPresent: keyValue(CUSTOM_PROVIDER.envVar) !== undefined, }); } @@ -54,11 +59,42 @@ export async function POST(request: Request) { } const record = readSettingsRecord() ?? { version: 1 }; - const next = writeSettingsRecord({ ...record, customProvider: { baseUrl: base, models: found.models } }); + // User pins are never rewritten by discovery — only pruned to what the + // endpoint still advertises (the single prune law, core/settings.ts). + const overrides = sanitizeCustomModelOverrides( + record.customProvider?.overrides, + found.models.map((m) => m.id), + ); + const next = writeSettingsRecord({ + ...record, + customProvider: { baseUrl: base, models: found.models, overrides }, + }); syncCustomProvider(next.customProvider); return Response.json({ ok: true, models: next.customProvider?.models ?? [] }); } +/** Store the per-model pins (replace-whole). Pruned against the STORED model + * list, never a probe — an offline endpoint keeps its pins editable. */ +export async function PATCH(request: Request) { + const parsed = parseCustomModelOverridesBody(await request.json().catch(() => null)); + if (!parsed.ok) return Response.json({ error: parsed.error }, { status: 400 }); + + const cp = effectiveSettings().customProvider; + if (!cp.baseUrl) return Response.json({ error: "No custom endpoint configured" }, { status: 409 }); + + const overrides = sanitizeCustomModelOverrides( + parsed.settings, + cp.models.map((m) => m.id), + ); + const record = readSettingsRecord() ?? { version: 1 }; + const next = writeSettingsRecord({ + ...record, + customProvider: { baseUrl: cp.baseUrl, models: cp.models, overrides }, + }); + syncCustomProvider(next.customProvider); + return Response.json({ ok: true, overrides: next.customProvider?.overrides ?? {} }); +} + /** Disconnect: forget the endpoint AND its key, then unregister the provider. * Deleting the section (rather than blanking baseUrl) keeps settings.json the * same shape as a never-connected install. Idempotent by construction. */ diff --git a/components/settings/keys-section.tsx b/components/settings/keys-section.tsx index 8b28ad3..5aac78d 100644 --- a/components/settings/keys-section.tsx +++ b/components/settings/keys-section.tsx @@ -24,7 +24,15 @@ */ import { useEffect, useRef, useState } from "react"; -import { AUTH_KEYS, CUSTOM_PROVIDER, type AuthKeyStatus, type CustomModel } from "@/src/core"; +import { + AUTH_KEYS, + CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW, + CUSTOM_MODEL_DEFAULT_MAX_TOKENS, + CUSTOM_PROVIDER, + type AuthKeyStatus, + type CustomModel, + type CustomModelOverride, +} from "@/src/core"; import { SettingsGroup } from "./controls.tsx"; import { SubscriptionConnections } from "./subscription-connections.tsx"; import { ProviderMenuList } from "./model-menu.tsx"; @@ -49,6 +57,8 @@ interface KeysResponse { interface CustomStatus { baseUrl: string; models: CustomModel[]; + /** User pins by model id (context / max output / thinking). */ + overrides: Record; keyPresent: boolean; } @@ -402,6 +412,7 @@ export function KeysSection({ setCustom({ baseUrl: cp.baseUrl ?? "", models: cp.models ?? [], + overrides: cp.overrides ?? {}, keyPresent: Boolean(cp.keyPresent), }); } catch { @@ -500,11 +511,17 @@ export function KeysSection({ { setAddingCustom(false); await reload(); notifySettingsSaved("keys"); }} + onPinsSaved={async () => { + await reload(); + notifySettingsSaved("keys"); + }} onDelete={disconnectCustom} onCancel={() => setAddingCustom(false)} /> @@ -663,18 +680,68 @@ function CustomEndpointRow({ ); } +/** A pin row's raw input state — "" means "no pin": the endpoint's own value, + * then the compiled default, decides. `reasoning` is tri-state. */ +interface PinDraft { + context: string; + maxTokens: string; + reasoning: "" | "on" | "off"; +} + +const pinDraft = (pin: CustomModelOverride | undefined): PinDraft => ({ + context: pin?.context === undefined ? "" : String(pin.context), + maxTokens: pin?.maxTokens === undefined ? "" : String(pin.maxTokens), + reasoning: pin?.reasoning === undefined ? "" : pin.reasoning ? "on" : "off", +}); + +/** One draft row per discovered model, seeded from the stored pins. */ +function draftsFor( + models: readonly CustomModel[], + overrides: Record, +): Record { + return Object.fromEntries(models.map((m) => [m.id, pinDraft(overrides[m.id])])); +} + +/** Drafts → the PATCH payload (replace-whole): a blank field sends nothing and + * a fully blank model is absent — which IS the un-pin. */ +function pinsPayload(drafts: Record): Record { + const out: Record = {}; + for (const [id, d] of Object.entries(drafts)) { + const pin: CustomModelOverride = {}; + if (d.context.trim()) pin.context = Number(d.context); + if (d.maxTokens.trim()) pin.maxTokens = Number(d.maxTokens); + if (d.reasoning !== "") pin.reasoning = d.reasoning === "on"; + if (Object.keys(pin).length) out[id] = pin; + } + return out; +} + +/** testids must survive ids carrying slashes, colons, dots. */ +const pinTestid = (prefix: string, id: string) => `${prefix}-${id.replace(/[^a-z0-9-]/gi, "_")}`; + /** The custom endpoint's connect editor: base URL + key → POST /api/custom-provider - * (validate + discover + register). Image capability is detected automatically. */ + * (validate + discover + register), and the per-model limits form → PATCH (pins, + * no probe, so it works with the local server switched off). Image capability is + * detected automatically. */ function CustomEndpointEditor({ baseUrl0, keyPresent, + models, + overrides, onSaved, + onPinsSaved, onCancel, onDelete, }: { baseUrl0: string; keyPresent: boolean; + /** The discovered models — one limits row each. */ + models: readonly CustomModel[]; + /** Their stored user pins. */ + overrides: Record; onSaved: () => void | Promise; + /** Pins saved — the editor stays open (a form, not a connect). */ + onPinsSaved: () => void | Promise; onCancel: () => void; onDelete?: () => void | Promise; }) { @@ -683,11 +750,46 @@ function CustomEndpointEditor({ const [keyTouched, setKeyTouched] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + // The limits form: edited drafts over the stored pins, plus the last saved + // payload as the dirty baseline (the server's echo is what gets stored). + const [drafts, setDrafts] = useState>(() => draftsFor(models, overrides)); + const [baseline, setBaseline] = useState(() => JSON.stringify(pinsPayload(draftsFor(models, overrides)))); + const [pinsBusy, setPinsBusy] = useState(false); + const [pinsError, setPinsError] = useState(null); // A key is required to enable chatting; local servers accept any value. A // stored key is kept when the field is left blank. const needsKey = !keyPresent; const canConnect = Boolean(baseUrl.trim()) && (!needsKey || Boolean(key.trim())); + const draftOf = (m: CustomModel): PinDraft => drafts[m.id] ?? pinDraft(overrides[m.id]); + const setDraft = (m: CustomModel, patch: Partial) => + setDrafts((prev) => ({ ...prev, [m.id]: { ...draftOf(m), ...patch } })); + const current = Object.fromEntries(models.map((m) => [m.id, draftOf(m)])) as Record; + const dirty = JSON.stringify(pinsPayload(current)) !== baseline; + + async function savePins() { + if (pinsBusy || !dirty) return; + setPinsBusy(true); + setPinsError(null); + const res = await fetch("/api/custom-provider", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ overrides: pinsPayload(current) }), + }).catch(() => null); + setPinsBusy(false); + if (!res || !res.ok) { + const b = (await res?.json().catch(() => ({}))) as { error?: string } | undefined; + setPinsError(res ? (b?.error ?? `Save failed (${res.status})`) : "Save failed. Check your connection."); + return; + } + // The server's stored map is the truth (it floors + prunes) — echo it back. + const body = (await res.json().catch(() => null)) as { overrides?: Record } | null; + const next = draftsFor(models, body?.overrides ?? pinsPayload(current)); + setDrafts(next); + setBaseline(JSON.stringify(pinsPayload(next))); + await onPinsSaved(); + } + async function save() { if (busy || !baseUrl.trim()) return; setBusy(true); @@ -769,6 +871,75 @@ function CustomEndpointEditor({ )}

+ {models.length > 0 && ( +
+ + Model limits — blank uses the endpoint’s value, then the default + +
+ {models.map((m) => { + const d = draftOf(m); + return ( +
+ + {m.id} + + setDraft(m, { context: e.target.value })} + placeholder={String(m.context ?? CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW)} + aria-label={`${m.id} context window in tokens`} + data-testid={pinTestid("custom-pin-context", m.id)} + disabled={pinsBusy} + className="h-7 w-20 shrink-0 rounded-sm border border-border bg-background px-1.5 text-right font-mono text-[11px] text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50" + /> + setDraft(m, { maxTokens: e.target.value })} + placeholder={String(m.maxTokens ?? CUSTOM_MODEL_DEFAULT_MAX_TOKENS)} + aria-label={`${m.id} max output tokens`} + data-testid={pinTestid("custom-pin-maxtokens", m.id)} + disabled={pinsBusy} + className="h-7 w-20 shrink-0 rounded-sm border border-border bg-background px-1.5 text-right font-mono text-[11px] text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50" + /> + +
+ ); + })} +
+
+ + {pinsError && ( +

+ {pinsError} +

+ )} +
+
+ )} {needsKey && (

A key is required to enable chatting — local servers (Ollama, LM Studio) accept any value. diff --git a/src/core/settings-resolution.ts b/src/core/settings-resolution.ts index a7682de..ed1974b 100644 --- a/src/core/settings-resolution.ts +++ b/src/core/settings-resolution.ts @@ -38,6 +38,7 @@ import { type DictationSettings, type CompactionSettings, type CustomModel, + type CustomModelOverride, type GraphSettings, type ModelSlot, type ModelSlotName, @@ -96,8 +97,9 @@ export interface EffectiveSettings { /** Compaction (owner round 14): resolved concrete — absent record * section resolves to the factory 150k. */ compaction: Required; - /** Custom endpoint, resolved concrete: baseUrl defaults "", models default []. */ - customProvider: { baseUrl: string; models: CustomModel[] }; + /** Custom endpoint, resolved concrete: baseUrl defaults "", models and + * user pins default to empty. */ + customProvider: { baseUrl: string; models: CustomModel[]; overrides: Record }; } /** The conversation pair's chain (BYOK, owner 2026-09-22): the chat's own @@ -227,6 +229,7 @@ export function resolveSettings(record: UserSettings | null): EffectiveSettings customProvider: { baseUrl: record?.customProvider?.baseUrl ?? "", models: record?.customProvider?.models ?? [], + overrides: record?.customProvider?.overrides ?? {}, }, }; } diff --git a/src/core/settings.ts b/src/core/settings.ts index d495e07..8e5d638 100644 --- a/src/core/settings.ts +++ b/src/core/settings.ts @@ -217,6 +217,12 @@ export interface ModelsSettings { export const CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW = 8192; export const CUSTOM_MODEL_DEFAULT_MAX_TOKENS = 4096; +/** Floor for a user-pinned token count (owner, 2026-10-27): the read side + * clamps up, the write side rejects below — the compaction-floor discipline. + * Pins exist because endpoints that publish no limits (LM Studio) otherwise + * sit on the 8192 default with no way to say better. */ +export const CUSTOM_MODEL_PIN_FLOOR = 2_048; + /** One discovered custom model. `image`/`reasoning`/`context`/`maxTokens` are * detected from the endpoint's /models metadata; anything it omits falls back * to a default. */ @@ -233,12 +239,26 @@ export interface CustomModel { maxTokens?: number; } +/** A user pin for one custom model: it beats what the endpoint publishes + * (owner ruling — explicit user intent wins; a local server's number is + * often wrong or absent). Absent field = no pin. `reasoning` is tri-state: + * true forces thinking on, false forces it off (a server that advertises + * reasoning but rejects reasoning_effort), absent = what discovery found. */ +export interface CustomModelOverride { + context?: number; + maxTokens?: number; + reasoning?: boolean; +} + /** The single custom endpoint's stored config (delta section). */ export interface CustomProviderSettings { /** Normalized base URL (see normalizeBaseUrl). Empty/absent = not configured. */ baseUrl?: string; /** Models discovered from the endpoint (refreshed on connect). */ models?: CustomModel[]; + /** User pins keyed by model id — pure user intent, never rewritten by + * discovery, pruned to the endpoint's live ids on connect. */ + overrides?: Record; } /** Canonical base-URL form (single source): trims, requires absolute http(s), @@ -562,6 +582,38 @@ function positiveInt(value: unknown): number | undefined { return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined; } +/** One pin, clamped up to the floor (clamp-never-reject on read). */ +function sanitizeCustomModelOverride(raw: unknown): CustomModelOverride | undefined { + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return undefined; + const rec = raw as Record; + const out: CustomModelOverride = {}; + const context = positiveInt(rec.context); + if (context) out.context = Math.max(CUSTOM_MODEL_PIN_FLOOR, context); + const maxTokens = positiveInt(rec.maxTokens); + if (maxTokens) out.maxTokens = Math.max(CUSTOM_MODEL_PIN_FLOOR, maxTokens); + if (typeof rec.reasoning === "boolean") out.reasoning = rec.reasoning; + return Object.keys(out).length ? out : undefined; +} + +/** THE pins map + THE prune law: well-formed entries only, ids trimmed, and + * when `liveIds` is given every entry for a model the endpoint no longer + * advertises drops. POST passes the discovered ids, PATCH the stored list. */ +export function sanitizeCustomModelOverrides( + raw: unknown, + liveIds?: Iterable, +): Record { + const out: Record = {}; + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return out; + const live = liveIds ? new Set(liveIds) : undefined; + for (const [key, value] of Object.entries(raw)) { + const id = key.trim(); + if (!id || (live && !live.has(id))) continue; + const pin = sanitizeCustomModelOverride(value); + if (pin) out[id] = pin; + } + return out; +} + /** The customProvider section: normalized base URL (invalid → dropped) and * discovered models (ids trimmed, dupes/empties dropped; image kept only when * true; token counts only as positive ints). Clamp-never-reject. */ @@ -590,6 +642,8 @@ function sanitizeCustomProvider(raw: Record): CustomProviderSet } out.models = [...byId.values()]; } + const overrides = sanitizeCustomModelOverrides(raw.overrides); + if (Object.keys(overrides).length) out.overrides = overrides; return out; } diff --git a/src/server/custom-provider.ts b/src/server/custom-provider.ts index c6015aa..b9f9b8c 100644 --- a/src/server/custom-provider.ts +++ b/src/server/custom-provider.ts @@ -15,6 +15,7 @@ import { customProviderUsable, normalizeBaseUrl, type CustomModel, + type CustomModelOverride, type CustomProviderSettings, } from "../core/settings.ts"; import { keyValue } from "./keys.ts"; @@ -36,23 +37,31 @@ const customEndpointAuth: ApiKeyAuth = { }; /** Map a discovered model to a pi-ai entry: image→input, reasoning→thinking - * levels, context/maxTokens→limits (else CUSTOM_MODEL_DEFAULT_*). baseUrl is - * stamped per-model. */ -function toPiModel(model: CustomModel, baseUrl: string): Model { + * levels, context/maxTokens→limits. Resolution ladder (owner, 2026-10-27): + * the user's pin wins, then the endpoint's own metadata, then the compiled + * default — a local server that says nothing (or says 8k) can be corrected. + * baseUrl is stamped per-model. */ +function toPiModel( + model: CustomModel, + baseUrl: string, + overrides: Record, +): Model { + const pin = overrides[model.id]; return { id: model.id, name: model.id, api: "openai-completions", provider: CUSTOM_PROVIDER.id, baseUrl, - // Detected from the endpoint's metadata (see detectReasoning). true makes - // pi-ai offer the standard levels and send reasoning_effort on the wire; - // no thinkingLevelMap → the default off/minimal/low/medium/high set. - reasoning: Boolean(model.reasoning), + // Pin wins; else detected from the endpoint's metadata (see + // detectReasoning). true makes pi-ai offer the standard levels and send + // reasoning_effort on the wire; no thinkingLevelMap → the default + // off/minimal/low/medium/high set. + reasoning: pin?.reasoning ?? Boolean(model.reasoning), 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, + contextWindow: pin?.context ?? model.context ?? CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW, + maxTokens: pin?.maxTokens ?? model.maxTokens ?? CUSTOM_MODEL_DEFAULT_MAX_TOKENS, type: "chat", }; } @@ -61,13 +70,16 @@ function toPiModel(model: CustomModel, baseUrl: string): Model { export function buildOpenAiCompatibleProvider(input: { baseUrl: string; models: readonly CustomModel[]; + /** User pins by model id (see CustomModelOverride) — absent = none. */ + overrides?: Record; }): Provider { + const overrides = input.overrides ?? {}; 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)), + models: input.models.map((m) => toPiModel(m, input.baseUrl, overrides)), api: openAICompletionsApi(), }); } @@ -81,7 +93,11 @@ export function syncCustomProvider(cp: CustomProviderSettings | undefined): void return; } models.setProvider( - buildOpenAiCompatibleProvider({ baseUrl: normalizeBaseUrl(cp.baseUrl), models: cp.models ?? [] }), + buildOpenAiCompatibleProvider({ + baseUrl: normalizeBaseUrl(cp.baseUrl), + models: cp.models ?? [], + overrides: cp.overrides ?? {}, + }), ); } diff --git a/src/server/settings-body.ts b/src/server/settings-body.ts index a733003..d07cd6e 100644 --- a/src/server/settings-body.ts +++ b/src/server/settings-body.ts @@ -18,13 +18,14 @@ import type { AppearanceSettings, BackgroundModelRole, CompactionSettings, + CustomModelOverride, GeneralSettings, DictationSettings, GraphSettings, ModelsSettings, RoleModelOverride, } from "../core/settings.ts"; -import { BACKGROUND_MODEL_ROLES, COMPACTION_THRESHOLD_FLOOR, EXTRACTION_CHUNK_TOKENS_FLOOR, MODEL_SLOTS } from "../core/settings.ts"; +import { BACKGROUND_MODEL_ROLES, COMPACTION_THRESHOLD_FLOOR, CUSTOM_MODEL_PIN_FLOOR, EXTRACTION_CHUNK_TOKENS_FLOOR, MODEL_SLOTS } from "../core/settings.ts"; import { WRITABLE_TOOL_KEYS } from "../core/pending.ts"; import { isDictationLanguage } from "../core/dictation.ts"; @@ -386,6 +387,45 @@ export function parseDictationSettingsBody(body: unknown): SettingsBodyResult> { + const raw = asRecord(body); + if (!raw) return { ok: false, error: "Invalid JSON body" }; + const map = asRecord(raw.overrides); + if (!map) return { ok: false, error: "overrides must be a { modelId: { context?, maxTokens?, reasoning? } } map" }; + const out: Record = {}; + for (const [key, value] of Object.entries(map)) { + const id = key.trim(); + if (!id) return { ok: false, error: "override keys must be non-empty model ids" }; + const entry = asRecord(value); + if (!entry) return { ok: false, error: `override for ${id} must be an object` }; + const pin: CustomModelOverride = {}; + for (const field of ["context", "maxTokens"] as const) { + const v = entry[field]; + if (v === undefined) continue; + if (typeof v !== "number" || !Number.isInteger(v) || v < CUSTOM_MODEL_PIN_FLOOR) { + return { + ok: false, + error: `${field} for ${id} must be a whole number of tokens at least ${CUSTOM_MODEL_PIN_FLOOR}`, + }; + } + pin[field] = v; + } + if (entry.reasoning !== undefined) { + if (typeof entry.reasoning !== "boolean") return { ok: false, error: `reasoning for ${id} must be true or false` }; + pin.reasoning = entry.reasoning; + } + out[id] = pin; + } + return { ok: true, settings: out }; +} + /** The section→parser map for PUT /api/settings/[section]. The custom endpoint * is NOT a section — it connects via POST /api/custom-provider. */ export const SETTINGS_SECTIONS = { diff --git a/tests/custom-endpoint.test.ts b/tests/custom-endpoint.test.ts index d1d4845..8b64253 100644 --- a/tests/custom-endpoint.test.ts +++ b/tests/custom-endpoint.test.ts @@ -2,8 +2,9 @@ * Tests for the custom OpenAI-compatible endpoint (custom-endpoint wave). * Covers the pure core (normalizeBaseUrl, customProviderUsable, the sanitizer, * resolution), the deep server module (provider build + live sync + model - * discovery incl. image-modality and reasoning detection), and the catalog - * federation row. No network: the discovery probe runs against a stubbed fetch. + * discovery incl. image-modality, reasoning detection and the per-model user + * pins), the pins' strict write-side parser, and the catalog federation row. + * No network: the discovery probe runs against a stubbed fetch. */ import assert from "node:assert/strict"; @@ -11,11 +12,14 @@ import { after, describe, it } from "node:test"; import { CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW, CUSTOM_MODEL_DEFAULT_MAX_TOKENS, + CUSTOM_MODEL_PIN_FLOOR, customProviderUsable, normalizeBaseUrl, + sanitizeCustomModelOverrides, sanitizeUserSettings, } from "../src/core/settings.ts"; import { resolveSettings } from "../src/core/settings-resolution.ts"; +import { parseCustomModelOverridesBody } from "../src/server/settings-body.ts"; import { CUSTOM_PROVIDER } from "../src/core/auth-keys.ts"; import { buildOpenAiCompatibleProvider, @@ -84,10 +88,86 @@ describe("sanitizeUserSettings — customProvider", () => { }); }); -// ─── resolution ────────────────────────────────────────────────────────────── +// ─── per-model pins: the read side + THE prune law ─────────────────────────── +describe("sanitizeUserSettings / sanitizeCustomModelOverrides — pins", () => { + it("keeps well-formed pins, clamps token counts up to the floor, keeps reasoning:false", () => { + const out = sanitizeUserSettings({ + customProvider: { + baseUrl: "https://h/v1", + overrides: { + llama: { context: 131072, maxTokens: 8192, reasoning: true }, + qwen: { context: 10 }, // under the floor → clamped up (clamp-never-reject on read) + quiet: { reasoning: false }, // a real pin, not an absent one + junk: { context: "big", maxTokens: -1, reasoning: "yes" }, // every field junk + " ": { context: 4096 }, // no usable id + nope: "not-an-object", + }, + }, + }); + assert.deepEqual(out?.customProvider?.overrides, { + llama: { context: 131072, maxTokens: 8192, reasoning: true }, + qwen: { context: CUSTOM_MODEL_PIN_FLOOR }, + quiet: { reasoning: false }, + }); + }); + it("prunes pins for models the endpoint no longer advertises — the single prune law", () => { + const pins = { a: { context: 32768 }, b: { context: 32768 } }; + assert.deepEqual(Object.keys(sanitizeCustomModelOverrides(pins, ["a"])), ["a"]); + assert.deepEqual( + Object.keys(sanitizeCustomModelOverrides(pins)), + ["a", "b"], + "no liveIds → no prune (that is the routes' call, not the record sanitizer's)", + ); + assert.deepEqual(sanitizeCustomModelOverrides("nope"), {}, "a non-object map resolves empty"); + }); + it("drops an invalid overrides section from the record entirely", () => { + const out = sanitizeUserSettings({ customProvider: { baseUrl: "https://h/v1", overrides: "nope" } }); + assert.equal(out?.customProvider?.overrides, undefined); + }); +}); + +// ─── pins: the strict write side (PATCH) ────────────────────────────────── +describe("parseCustomModelOverridesBody — the strict write side", () => { + it("accepts whole counts at or above the floor and the tri-state reasoning pin", () => { + const r = parseCustomModelOverridesBody({ + overrides: { + a: { context: CUSTOM_MODEL_PIN_FLOOR, reasoning: false }, + b: { maxTokens: 8192 }, + c: {}, // an empty pin is legal — the sanitizer drops it on write + }, + }); + assert.ok(r.ok); + assert.deepEqual(r.settings, { + a: { context: CUSTOM_MODEL_PIN_FLOOR, reasoning: false }, + b: { maxTokens: 8192 }, + c: {}, + }); + }); + it("rejects a count under the floor, naming the floor", () => { + const r = parseCustomModelOverridesBody({ overrides: { a: { context: CUSTOM_MODEL_PIN_FLOOR - 1 } } }); + assert.equal(r.ok, false); + if (!r.ok) assert.ok(r.error.includes(String(CUSTOM_MODEL_PIN_FLOOR)), r.error); + }); + it("rejects a fractional count, a non-boolean reasoning, and a missing overrides map", () => { + assert.equal(parseCustomModelOverridesBody({ overrides: { a: { context: 4096.5 } } }).ok, false); + assert.equal(parseCustomModelOverridesBody({ overrides: { a: { reasoning: "on" } } }).ok, false); + assert.equal(parseCustomModelOverridesBody({ overrides: { a: "nope" } }).ok, false); + assert.equal(parseCustomModelOverridesBody({}).ok, false); + assert.equal(parseCustomModelOverridesBody(null).ok, false); + }); +}); + +// ─── resolution ────────────────────────────────────────────────────────── describe("resolveSettings — customProvider", () => { it("resolves an absent section to concrete defaults", () => { - assert.deepEqual(resolveSettings(null).customProvider, { baseUrl: "", models: [] }); + assert.deepEqual(resolveSettings(null).customProvider, { baseUrl: "", models: [], overrides: {} }); + }); + it("carries stored pins through to the concrete view", () => { + const resolved = resolveSettings({ + version: 1, + customProvider: { baseUrl: "http://h/v1", models: [{ id: "a" }], overrides: { a: { context: 32768 } } }, + }); + assert.deepEqual(resolved.customProvider.overrides, { a: { context: 32768 } }); }); }); @@ -124,6 +204,33 @@ describe("buildOpenAiCompatibleProvider / syncCustomProvider", () => { assert.equal(big!.maxTokens, 393216); }); + it("a user pin beats the endpoint, which beats the compiled default", () => { + const provider = buildOpenAiCompatibleProvider({ + baseUrl: "http://h/v1", + models: [{ id: "pinned", context: 32768, maxTokens: 4096 }, { id: "detected", context: 65536 }, { id: "bare" }], + overrides: { pinned: { context: 131072, maxTokens: 16384 } }, + }); + const [pinned, detected, bare] = provider.getModels(); + assert.equal(pinned!.contextWindow, 131072, "the pin wins over the endpoint's own number"); + assert.equal(pinned!.maxTokens, 16384); + assert.equal(detected!.contextWindow, 65536, "no pin → the endpoint's value"); + assert.equal(detected!.maxTokens, CUSTOM_MODEL_DEFAULT_MAX_TOKENS, "endpoint silent → the compiled default"); + assert.equal(bare!.contextWindow, CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW); + }); + + it("the thinking pin is tri-state: false beats a detected-true endpoint, true beats silence", () => { + const provider = buildOpenAiCompatibleProvider({ + baseUrl: "http://h/v1", + models: [{ id: "liar", reasoning: true }, { id: "silent" }], + overrides: { liar: { reasoning: false }, silent: { reasoning: true } }, + }); + const [liar, silent] = provider.getModels(); + assert.equal(liar!.reasoning, false, "a server that advertises reasoning but rejects it can be pinned off"); + assert.deepEqual(getSupportedThinkingLevels(liar!), ["off"]); + assert.equal(silent!.reasoning, true, "LM Studio publishes nothing; the user says otherwise"); + assert.deepEqual(getSupportedThinkingLevels(silent!), ["off", "minimal", "low", "medium", "high"]); + }); + it("gives a reasoning-capable model the standard thinking levels (others stay \"off\")", () => { const provider = buildOpenAiCompatibleProvider({ baseUrl: "http://h/v1", From b4a13f5c3b8e1cab861e0a7574ef49d2fefb1fab Mon Sep 17 00:00:00 2001 From: iamabhishekt Date: Wed, 28 Oct 2026 16:16:30 -0400 Subject: [PATCH 8/9] feat: manual model IDs when /models is unavailable Hosted OpenAI-compatible APIs often serve chat but not GET /models (Volcengine Ark Agent Plan returns 404), so connect failed. Accept a typed list of model IDs: merged into discovered models when /models works, used alone when it does not. The typed list is stored as customProvider.manualModels so reconnects and pin saves keep it. --- app/api/custom-provider/route.ts | 19 +++++++++----- components/settings/keys-section.tsx | 18 ++++++++++++- src/core/settings-resolution.ts | 3 ++- src/core/settings.ts | 9 +++++++ src/server/custom-provider.ts | 6 +++++ tests/custom-endpoint.test.ts | 39 +++++++++++++++++++++++++++- 6 files changed, 84 insertions(+), 10 deletions(-) diff --git a/app/api/custom-provider/route.ts b/app/api/custom-provider/route.ts index 193dd2c..5280609 100644 --- a/app/api/custom-provider/route.ts +++ b/app/api/custom-provider/route.ts @@ -16,13 +16,14 @@ import { discoverModels, effectiveSettings, keyValue, + mergeManualModels, parseCustomModelOverridesBody, readSettingsRecord, saveKey, syncCustomProvider, writeSettingsRecord, } from "@/src/server"; -import { CUSTOM_PROVIDER, normalizeBaseUrl, sanitizeCustomModelOverrides } from "@/src/core"; +import { CUSTOM_PROVIDER, normalizeBaseUrl, parseModelIds, sanitizeCustomModelOverrides } from "@/src/core"; export const dynamic = "force-dynamic"; @@ -32,12 +33,13 @@ export async function GET() { baseUrl: cp.baseUrl, models: cp.models, overrides: cp.overrides, + manualModels: cp.manualModels, keyPresent: keyValue(CUSTOM_PROVIDER.envVar) !== undefined, }); } export async function POST(request: Request) { - const body = (await request.json().catch(() => null)) as { baseUrl?: unknown; key?: unknown } | null; + const body = (await request.json().catch(() => null)) as { baseUrl?: unknown; key?: unknown; manualModels?: 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 }); @@ -45,9 +47,12 @@ export async function POST(request: Request) { 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. + const manualModels = parseModelIds(body?.manualModels); const found = await discoverModels(base, key ? key : undefined); - if (!found.ok) return Response.json({ error: found.error }, { status: 502 }); + if (!found.ok && !manualModels.length) { + return Response.json({ error: `${found.error}. If this endpoint has no /models list, add model IDs manually.` }, { status: 502 }); + } + const models = mergeManualModels(found.ok ? found.models : [], manualModels); if (keyProvided) { if (key) { @@ -63,11 +68,11 @@ export async function POST(request: Request) { // endpoint still advertises (the single prune law, core/settings.ts). const overrides = sanitizeCustomModelOverrides( record.customProvider?.overrides, - found.models.map((m) => m.id), + models.map((m) => m.id), ); const next = writeSettingsRecord({ ...record, - customProvider: { baseUrl: base, models: found.models, overrides }, + customProvider: { baseUrl: base, models, overrides, ...(manualModels.length ? { manualModels } : {}) }, }); syncCustomProvider(next.customProvider); return Response.json({ ok: true, models: next.customProvider?.models ?? [] }); @@ -89,7 +94,7 @@ export async function PATCH(request: Request) { const record = readSettingsRecord() ?? { version: 1 }; const next = writeSettingsRecord({ ...record, - customProvider: { baseUrl: cp.baseUrl, models: cp.models, overrides }, + customProvider: { baseUrl: cp.baseUrl, models: cp.models, overrides, ...(cp.manualModels.length ? { manualModels: cp.manualModels } : {}) }, }); syncCustomProvider(next.customProvider); return Response.json({ ok: true, overrides: next.customProvider?.overrides ?? {} }); diff --git a/components/settings/keys-section.tsx b/components/settings/keys-section.tsx index 5aac78d..95a848f 100644 --- a/components/settings/keys-section.tsx +++ b/components/settings/keys-section.tsx @@ -59,6 +59,7 @@ interface CustomStatus { models: CustomModel[]; /** User pins by model id (context / max output / thinking). */ overrides: Record; + manualModels: string[]; keyPresent: boolean; } @@ -413,6 +414,7 @@ export function KeysSection({ baseUrl: cp.baseUrl ?? "", models: cp.models ?? [], overrides: cp.overrides ?? {}, + manualModels: cp.manualModels ?? [], keyPresent: Boolean(cp.keyPresent), }); } catch { @@ -513,6 +515,7 @@ export function KeysSection({ keyPresent={custom?.keyPresent ?? false} models={custom?.models ?? []} overrides={custom?.overrides ?? {}} + manualModels0={custom?.manualModels ?? []} onSaved={async () => { setAddingCustom(false); await reload(); @@ -728,6 +731,7 @@ function CustomEndpointEditor({ keyPresent, models, overrides, + manualModels0, onSaved, onPinsSaved, onCancel, @@ -739,6 +743,7 @@ function CustomEndpointEditor({ models: readonly CustomModel[]; /** Their stored user pins. */ overrides: Record; + manualModels0: readonly string[]; onSaved: () => void | Promise; /** Pins saved — the editor stays open (a form, not a connect). */ onPinsSaved: () => void | Promise; @@ -748,6 +753,7 @@ function CustomEndpointEditor({ const [baseUrl, setBaseUrl] = useState(baseUrl0); const [key, setKey] = useState(""); const [keyTouched, setKeyTouched] = useState(false); + const [manualModels, setManualModels] = useState(manualModels0.join(", ")); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); // The limits form: edited drafts over the stored pins, plus the last saved @@ -797,7 +803,7 @@ function CustomEndpointEditor({ const res = await fetch("/api/custom-provider", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ baseUrl, ...(keyTouched ? { key } : {}) }), + body: JSON.stringify({ baseUrl, manualModels, ...(keyTouched ? { key } : {}) }), }).catch(() => null); setBusy(false); if (!res || !res.ok) { @@ -824,6 +830,16 @@ function CustomEndpointEditor({ 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" />

+ setManualModels(e.target.value)} + placeholder="Model IDs, comma-separated — optional, needed when the endpoint has no /models list" + aria-label="Custom endpoint model IDs" + data-testid="custom-models-input" + disabled={busy} + className="h-7 min-w-0 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" + />
; /** Custom endpoint, resolved concrete: baseUrl defaults "", models and * user pins default to empty. */ - customProvider: { baseUrl: string; models: CustomModel[]; overrides: Record }; + customProvider: { baseUrl: string; models: CustomModel[]; overrides: Record; manualModels: string[] }; } /** The conversation pair's chain (BYOK, owner 2026-09-22): the chat's own @@ -230,6 +230,7 @@ export function resolveSettings(record: UserSettings | null): EffectiveSettings baseUrl: record?.customProvider?.baseUrl ?? "", models: record?.customProvider?.models ?? [], overrides: record?.customProvider?.overrides ?? {}, + manualModels: record?.customProvider?.manualModels ?? [], }, }; } diff --git a/src/core/settings.ts b/src/core/settings.ts index 8e5d638..18b3e1d 100644 --- a/src/core/settings.ts +++ b/src/core/settings.ts @@ -259,6 +259,13 @@ export interface CustomProviderSettings { /** User pins keyed by model id — pure user intent, never rewritten by * discovery, pruned to the endpoint's live ids on connect. */ overrides?: Record; + manualModels?: string[]; +} + +export function parseModelIds(raw: unknown): string[] { + const items = typeof raw === "string" ? raw.split(/[\s,]+/) : Array.isArray(raw) ? raw : []; + const ids = items.filter((v): v is string => typeof v === "string").map((v) => v.trim()).filter(Boolean); + return [...new Set(ids)]; } /** Canonical base-URL form (single source): trims, requires absolute http(s), @@ -644,6 +651,8 @@ function sanitizeCustomProvider(raw: Record): CustomProviderSet } const overrides = sanitizeCustomModelOverrides(raw.overrides); if (Object.keys(overrides).length) out.overrides = overrides; + const manualModels = parseModelIds(raw.manualModels); + if (manualModels.length) out.manualModels = manualModels; return out; } diff --git a/src/server/custom-provider.ts b/src/server/custom-provider.ts index b9f9b8c..4dc3eb6 100644 --- a/src/server/custom-provider.ts +++ b/src/server/custom-provider.ts @@ -131,6 +131,12 @@ export async function discoverModels(baseUrl: string, apiKey?: string): Promise< } } +export function mergeManualModels(discovered: readonly CustomModel[], manualIds: readonly string[]): CustomModel[] { + const byId = new Map(discovered.map((m) => [m.id, m])); + for (const id of manualIds) if (!byId.has(id)) byId.set(id, { id }); + return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id)); +} + /** Model rows from a list response ({data:[…]} | {models:[…]} | bare array), * or null if unrecognized. */ function modelRows(body: unknown): unknown[] | null { diff --git a/tests/custom-endpoint.test.ts b/tests/custom-endpoint.test.ts index 8b64253..d477b79 100644 --- a/tests/custom-endpoint.test.ts +++ b/tests/custom-endpoint.test.ts @@ -15,6 +15,7 @@ import { CUSTOM_MODEL_PIN_FLOOR, customProviderUsable, normalizeBaseUrl, + parseModelIds, sanitizeCustomModelOverrides, sanitizeUserSettings, } from "../src/core/settings.ts"; @@ -24,6 +25,7 @@ import { CUSTOM_PROVIDER } from "../src/core/auth-keys.ts"; import { buildOpenAiCompatibleProvider, discoverModels, + mergeManualModels, syncCustomProvider, } from "../src/server/custom-provider.ts"; import { piModels } from "../src/agent/models.ts"; @@ -160,7 +162,7 @@ describe("parseCustomModelOverridesBody — the strict write side", () => { // ─── resolution ────────────────────────────────────────────────────────── describe("resolveSettings — customProvider", () => { it("resolves an absent section to concrete defaults", () => { - assert.deepEqual(resolveSettings(null).customProvider, { baseUrl: "", models: [], overrides: {} }); + assert.deepEqual(resolveSettings(null).customProvider, { baseUrl: "", models: [], overrides: {}, manualModels: [] }); }); it("carries stored pins through to the concrete view", () => { const resolved = resolveSettings({ @@ -169,6 +171,41 @@ describe("resolveSettings — customProvider", () => { }); assert.deepEqual(resolved.customProvider.overrides, { a: { context: 32768 } }); }); + it("carries stored manual model ids through to the concrete view", () => { + const resolved = resolveSettings({ + version: 1, + customProvider: { baseUrl: "http://h/v1", models: [{ id: "a" }], manualModels: ["a"] }, + }); + assert.deepEqual(resolved.customProvider.manualModels, ["a"]); + }); +}); + +describe("manual model ids", () => { + it("parseModelIds splits a typed list on commas and whitespace, trims and dedupes", () => { + assert.deepEqual(parseModelIds(" doubao-seed-2.1-pro, kimi-k3.1-pro\nqwen/qwen3-max ,, kimi-k3.1-pro "), [ + "doubao-seed-2.1-pro", + "kimi-k3.1-pro", + "qwen/qwen3-max", + ]); + }); + it("parseModelIds accepts a string array and ignores non-strings and junk", () => { + assert.deepEqual(parseModelIds(["a", " b ", "", 3, null, "a"]), ["a", "b"]); + assert.deepEqual(parseModelIds(undefined), []); + assert.deepEqual(parseModelIds({ id: "a" }), []); + }); + it("the sanitizer keeps a clean manual list and drops an empty one", () => { + const kept = sanitizeUserSettings({ version: 1, customProvider: { baseUrl: "http://h/v1", manualModels: [" a ", "a", "b"] } }); + assert.deepEqual(kept?.customProvider?.manualModels, ["a", "b"]); + const dropped = sanitizeUserSettings({ version: 1, customProvider: { baseUrl: "http://h/v1", manualModels: [] } }); + assert.equal(dropped?.customProvider?.manualModels, undefined); + }); + it("mergeManualModels adds typed ids with defaults and never replaces discovered metadata", () => { + const merged = mergeManualModels([{ id: "b", image: true, context: 65536 }], ["c", "b", "a"]); + assert.deepEqual(merged, [{ id: "a" }, { id: "b", image: true, context: 65536 }, { id: "c" }]); + }); + it("mergeManualModels alone builds the list when discovery found nothing", () => { + assert.deepEqual(mergeManualModels([], ["x"]), [{ id: "x" }]); + }); }); // ─── provider build + live sync ────────────────────────────────────────────── From ef37c26433eecbf22138c897f85b59765caceb03 Mon Sep 17 00:00:00 2001 From: d-buckner <51932404+d-buckner@users.noreply.github.com> Date: Wed, 28 Oct 2026 13:56:53 -0700 Subject: [PATCH 9/9] fix: never lose custom endpoint models or pins on a failed probe A connect that fell back to typed model ids replaced the stored model list with the typed ids alone, then pruned the user pins against it. A transient /models failure (503, timeout) therefore deleted every discovered model, its detected metadata, and its pins, and answered 200. - DiscoverResult carries the endpoint's HTTP status. A 401/403 fails the connect even when ids are typed: a rejected key is not a missing model list. - A failed probe keeps the stored snapshot for the SAME base URL, so the prune cannot delete pins for a probe that failed. A new base URL starts clean, so an older endpoint's models never follow the URL. - Comment corrections: the route contract and the CustomProviderSettings fields described the behaviour from before the manual-ids change. - tests/custom-provider-route.test.ts covers the four branches: transient failure, rejected key, new endpoint with no model list, working probe. tsc and eslint clean. Suite: 2554 tests, 2 failures, both also failing on 6959482 (render-sidebar date drift, subscription-auth install artifact). --- app/api/custom-provider/route.ts | 39 ++++++--- src/core/settings.ts | 9 +- src/server/custom-provider.ts | 13 ++- tests/custom-provider-route.test.ts | 129 ++++++++++++++++++++++++++++ 4 files changed, 175 insertions(+), 15 deletions(-) create mode 100644 tests/custom-provider-route.test.ts diff --git a/app/api/custom-provider/route.ts b/app/api/custom-provider/route.ts index 5280609..f77ad78 100644 --- a/app/api/custom-provider/route.ts +++ b/app/api/custom-provider/route.ts @@ -1,11 +1,17 @@ /** * The custom endpoint's connect surface (one provider in the Keys list). - * GET → non-secret config { baseUrl, models, overrides } + keyPresent (masked). - * POST → connect: validate + normalize the base URL, probe /models (fail - * fast), store the key, persist { baseUrl, models }, re-sync the - * provider into piModels(). Discovery lives here because connecting - * and discovering are one user action. Stored user pins survive a - * reconnect, pruned to the ids the endpoint still advertises. + * GET → non-secret config { baseUrl, models, overrides, manualModels } + + * keyPresent (masked). + * POST → connect: validate + normalize the base URL, probe /models, store + * the key, persist { baseUrl, models, overrides, manualModels }, + * re-sync the provider into piModels(). Discovery lives here because + * connecting and discovering are one user action. A failed probe is + * fatal UNLESS the user typed model IDs: an endpoint that serves chat + * but no model list is configured from those ids alone. A failed + * probe for the endpoint already stored keeps its last snapshot, so + * a blip cannot prune the stored pins. A 401/403 always fails: a + * rejected key is not a missing model list. Stored user pins survive + * a reconnect, pruned to the ids the connect ends up offering. * PATCH → store per-model pins only. No probe: pins must be editable with * the local server switched off (the case they exist for). * DELETE → disconnect: drop the stored config + the stored key and unregister @@ -48,11 +54,25 @@ export async function POST(request: Request) { const key = keyProvided ? (body!.key as string).trim() : undefined; const manualModels = parseModelIds(body?.manualModels); + const record = readSettingsRecord() ?? { version: 1 }; const found = await discoverModels(base, key ? key : undefined); + // A rejected key is not a missing model list: never let typed ids turn an + // auth failure into a stored "connected" endpoint. + if (!found.ok && (found.status === 401 || found.status === 403)) { + return Response.json({ error: found.error }, { status: 502 }); + } if (!found.ok && !manualModels.length) { return Response.json({ error: `${found.error}. If this endpoint has no /models list, add model IDs manually.` }, { status: 502 }); } - const models = mergeManualModels(found.ok ? found.models : [], manualModels); + // A failed probe says nothing about what the endpoint offers: keep the last + // snapshot of the SAME endpoint, so a transient failure cannot shrink the + // model list and prune its pins. A different base URL starts clean. + const discovered = found.ok + ? found.models + : record.customProvider?.baseUrl === base + ? (record.customProvider?.models ?? []) + : []; + const models = mergeManualModels(discovered, manualModels); if (keyProvided) { if (key) { @@ -63,9 +83,8 @@ export async function POST(request: Request) { } } - const record = readSettingsRecord() ?? { version: 1 }; - // User pins are never rewritten by discovery — only pruned to what the - // endpoint still advertises (the single prune law, core/settings.ts). + // User pins are never rewritten by discovery — only pruned to the ids this + // connect offers (discovered ∪ typed). const overrides = sanitizeCustomModelOverrides( record.customProvider?.overrides, models.map((m) => m.id), diff --git a/src/core/settings.ts b/src/core/settings.ts index 18b3e1d..22bf9d9 100644 --- a/src/core/settings.ts +++ b/src/core/settings.ts @@ -254,11 +254,16 @@ export interface CustomModelOverride { export interface CustomProviderSettings { /** Normalized base URL (see normalizeBaseUrl). Empty/absent = not configured. */ baseUrl?: string; - /** Models discovered from the endpoint (refreshed on connect). */ + /** Models this endpoint offers: advertised ids merged with the typed + * manual ones (rewritten on connect; a failed probe keeps the same + * endpoint's last snapshot). */ models?: CustomModel[]; /** User pins keyed by model id — pure user intent, never rewritten by - * discovery, pruned to the endpoint's live ids on connect. */ + * discovery, pruned on connect to the ids that connect offers. */ overrides?: Record; + /** Model ids the user typed by hand, for an endpoint that publishes no + * usable /models list. Kept apart from the snapshot so a reconnect can + * re-merge them after discovery refreshes. */ manualModels?: string[]; } diff --git a/src/server/custom-provider.ts b/src/server/custom-provider.ts index 4dc3eb6..2d947e6 100644 --- a/src/server/custom-provider.ts +++ b/src/server/custom-provider.ts @@ -101,8 +101,12 @@ export function syncCustomProvider(cp: CustomProviderSettings | undefined): void ); } -/** Outcome of a model-discovery probe. */ -export type DiscoverResult = { ok: true; models: CustomModel[] } | { ok: false; error: string }; +/** Outcome of a model-discovery probe. `status` is the endpoint's HTTP status + * when it answered at all, so callers can tell a rejected key from a missing + * model list. */ +export type DiscoverResult = + | { ok: true; models: CustomModel[] } + | { ok: false; error: string; status?: number }; /** Probe GET {baseUrl}/models for model ids. Scheme validated (http/https * only); keyless endpoints answer without a bearer. Non-2xx or unparseable @@ -118,7 +122,7 @@ export async function discoverModels(baseUrl: string, apiKey?: string): Promise< headers: key ? { authorization: `Bearer ${key}` } : {}, signal: controller.signal, }); - if (!res.ok) return { ok: false, error: `Endpoint returned ${res.status} ${res.statusText}` }; + if (!res.ok) return { ok: false, error: `Endpoint returned ${res.status} ${res.statusText}`, status: res.status }; 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" }; @@ -131,6 +135,9 @@ export async function discoverModels(baseUrl: string, apiKey?: string): Promise< } } +/** The connect's model list: what the endpoint advertises, plus every typed id + * it does not advertise (at default limits, so the pins can correct them). + * Discovered metadata always wins over a typed id of the same name. */ export function mergeManualModels(discovered: readonly CustomModel[], manualIds: readonly string[]): CustomModel[] { const byId = new Map(discovered.map((m) => [m.id, m])); for (const id of manualIds) if (!byId.has(id)) byId.set(id, { id }); diff --git a/tests/custom-provider-route.test.ts b/tests/custom-provider-route.test.ts new file mode 100644 index 0000000..6315ded --- /dev/null +++ b/tests/custom-provider-route.test.ts @@ -0,0 +1,129 @@ +/** + * POST /api/custom-provider — the connect policy around manually typed model + * ids. Two rules earn a route test (the pure pieces live in + * custom-endpoint.test.ts): + * 1. Typed ids make a FAILED probe survivable, but never a rejected key. + * 2. A failed probe never shrinks the snapshot it already stored: that prune + * would take the model metadata and the user's pins with it. + */ +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { after, before, describe, it } from "node:test"; + +const dir = fs.mkdtempSync(path.join(os.tmpdir(), "lh-custom-route-")); +process.env.LH_DATA_DIR = dir; +process.env.LH_AUTH_FILE = path.join(dir, "auth.json"); +const SETTINGS_FILE = path.join(dir, "settings.json"); + +const realFetch = globalThis.fetch; +after(() => { + globalThis.fetch = realFetch; + fs.rmSync(dir, { recursive: true, force: true }); + delete process.env.LH_DATA_DIR; + delete process.env.LH_AUTH_FILE; +}); + +type Route = typeof import("../app/api/custom-provider/route.ts"); +let route: Route; +before(async () => { + route = await import("../app/api/custom-provider/route.ts"); +}); + +const BASE = "http://h/v1"; +/** A connected endpoint with two discovered models, a pin on each, one typed id. */ +const CONNECTED = { + version: 1, + customProvider: { + baseUrl: BASE, + models: [{ id: "disc-1", context: 131072 }, { id: "disc-2" }], + overrides: { "disc-1": { context: 200000 }, "disc-2": { maxTokens: 8192 } }, + manualModels: ["m1"], + }, +}; + +function seed(record: unknown): void { + fs.writeFileSync(SETTINGS_FILE, JSON.stringify(record, null, "\t"), "utf-8"); +} + +function stored(): { customProvider?: { models?: { id: string }[]; overrides?: unknown; manualModels?: string[] } } { + return JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf-8")); +} + +function answerWith(status: number, statusText: string, body?: unknown): void { + globalThis.fetch = (async () => (body === undefined ? new Response("nope", { status, statusText }) : Response.json(body))) as typeof fetch; +} + +function post(payload: unknown): Promise { + return route.POST( + new Request("http://localhost/api/custom-provider", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + }), + ); +} + +describe("POST /api/custom-provider — typed ids and a failed probe", () => { + it("a transient failure keeps the stored models, their metadata, and their pins", async () => { + seed(CONNECTED); + answerWith(503, "Service Unavailable"); + + const res = await post({ baseUrl: BASE, manualModels: "m1" }); + assert.equal(res.status, 200); + + const cp = stored().customProvider; + assert.deepEqual(cp?.models?.map((m) => m.id), ["disc-1", "disc-2", "m1"]); + assert.deepEqual(cp?.models?.[0], { id: "disc-1", context: 131072 }, "detected metadata survives"); + assert.deepEqual(cp?.overrides, CONNECTED.customProvider.overrides, "the prune must not fire on a failed probe"); + assert.deepEqual(cp?.manualModels, ["m1"]); + }); + + it("a rejected key fails the connect even when ids are typed", async () => { + seed(CONNECTED); + answerWith(401, "Unauthorized"); + const before = stored(); + + const res = await post({ baseUrl: BASE, key: "sk-abcdefghijk", manualModels: "m1" }); + assert.equal(res.status, 502); + assert.deepEqual(stored(), before, "nothing is stored on an auth failure"); + }); + + it("a new endpoint with no model list starts from the typed ids alone", async () => { + seed(CONNECTED); + answerWith(404, "Not Found"); + + const res = await post({ baseUrl: "http://other/v1", manualModels: "y, x" }); + assert.equal(res.status, 200); + + const cp = stored().customProvider; + assert.deepEqual(cp?.models?.map((m) => m.id), ["x", "y"], "the previous endpoint's models do not follow the URL"); + assert.equal(cp?.overrides, undefined, "no pin survives a list the old pins do not belong to"); + assert.deepEqual(cp?.manualModels, ["y", "x"], "the typed order is kept as typed"); + }); + + it("no model list and no typed ids still refuses, with the hint", async () => { + answerWith(404, "Not Found"); + + const res = await post({ baseUrl: BASE }); + assert.equal(res.status, 502); + const body = (await res.json()) as { error: string }; + assert.match(body.error, /404/); + assert.match(body.error, /add model IDs manually/); + }); + + it("a working probe merges the typed ids and keeps discovered metadata", async () => { + seed(CONNECTED); + answerWith(200, "OK", { data: [{ id: "disc-1", context_length: 65536 }] }); + + const res = await post({ baseUrl: BASE, manualModels: "disc-1, m2" }); + assert.equal(res.status, 200); + + const cp = stored().customProvider; + assert.deepEqual(cp?.models?.map((m) => m.id), ["disc-1", "m2"]); + assert.deepEqual(cp?.models?.[0], { id: "disc-1", context: 65536 }, "discovery is not replaced by a bare typed id"); + assert.deepEqual(cp?.overrides, { "disc-1": { context: 200000 } }, "pins follow the ids this connect offers"); + assert.deepEqual(cp?.manualModels, ["disc-1", "m2"]); + }); +});