diff --git a/app/api/custom-provider/route.ts b/app/api/custom-provider/route.ts new file mode 100644 index 0000000..f77ad78 --- /dev/null +++ b/app/api/custom-provider/route.ts @@ -0,0 +1,132 @@ +/** + * The custom endpoint's connect surface (one provider in the Keys list). + * 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 + * the provider (the mirror image of POST; idempotent). + */ +import { + deleteKey, + discoverModels, + effectiveSettings, + keyValue, + mergeManualModels, + parseCustomModelOverridesBody, + readSettingsRecord, + saveKey, + syncCustomProvider, + writeSettingsRecord, +} from "@/src/server"; +import { CUSTOM_PROVIDER, normalizeBaseUrl, parseModelIds, sanitizeCustomModelOverrides } 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, + 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; 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 }); + + // Empty key string clears it, absent leaves it stored; only a real key rides the probe. + const keyProvided = typeof body?.key === "string"; + 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 }); + } + // 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) { + const saved = saveKey(CUSTOM_PROVIDER.envVar, key); + if (!saved.ok) return Response.json({ error: saved.error }, { status: 400 }); + } else { + deleteKey(CUSTOM_PROVIDER.envVar); + } + } + + // 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), + ); + const next = writeSettingsRecord({ + ...record, + customProvider: { baseUrl: base, models, overrides, ...(manualModels.length ? { manualModels } : {}) }, + }); + 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, ...(cp.manualModels.length ? { manualModels: cp.manualModels } : {}) }, + }); + 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. */ +export async function DELETE() { + const record = readSettingsRecord() ?? { version: 1 }; + const { customProvider: _dropped, ...rest } = record; + const next = writeSettingsRecord(rest); + deleteKey(CUSTOM_PROVIDER.envVar); + syncCustomProvider(next.customProvider); + return Response.json({ ok: true }); +} diff --git a/components/settings/agent-section.tsx b/components/settings/agent-section.tsx index 33f5512..6aaeedf 100644 --- a/components/settings/agent-section.tsx +++ b/components/settings/agent-section.tsx @@ -600,9 +600,6 @@ 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; 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 5d5f18e..a3ddbee 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, type AuthKeyStatus } 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 { ClaudeCodeInstallationStatus } from "./claude-code-status.tsx"; @@ -46,12 +54,29 @@ interface KeysResponse { providers?: ProviderKeyInfo[]; } +/** The custom endpoint's non-secret status (GET /api/custom-provider). */ +interface CustomStatus { + baseUrl: string; + models: CustomModel[]; + /** User pins by model id (context / max output / thinking). */ + overrides: Record; + manualModels: string[]; + 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's synthetic Add-provider entry (offered only while unconfigured). */ +export 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. */ @@ -84,6 +109,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 @@ -300,7 +329,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 && ( @@ -343,13 +372,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 @@ -359,20 +391,37 @@ 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 ?? [], + overrides: cp.overrides ?? {}, + manualModels: cp.manualModels ?? [], + keyPresent: Boolean(cp.keyPresent), + }); + } catch { + /* custom status unavailable — leave it unset */ + } + }; useEffect(() => { void reload(); @@ -409,6 +458,15 @@ export function KeysSection({ notifySettingsSaved("keys"); } + /** Forget the custom endpoint (config + key server-side), same rhythm as + * deleteKey: fire, re-read, broadcast so the catalog narrows everywhere. */ + async function disconnectCustom(): Promise { + await fetch("/api/custom-provider", { method: "DELETE" }).catch(() => undefined); + setAddingCustom(false); + await reload(); + notifySettingsSaved("keys"); + } + if (loading || statuses === null) { return

Loading keys…

; } @@ -445,12 +503,45 @@ export function KeysSection({ testid={`key-${status.id.toLowerCase()}`} /> ))} + {custom?.baseUrl && ( + setAddingCustom((v) => !v)} + /> + )} + {addingCustom && ( + { + setAddingCustom(false); + await reload(); + notifySettingsSaved("keys"); + }} + onPinsSaved={async () => { + await reload(); + notifySettingsSaved("keys"); + }} + onDelete={disconnectCustom} + onCancel={() => setAddingCustom(false)} + /> + )} {providers !== undefined && ( { - setAdding(p); setAddError(null); + if (p.id === CUSTOM_PROVIDER.id) { + setAdding(null); + setAddingCustom(true); + } else { + setAddingCustom(false); + setAdding(p); + } }} /> )} @@ -566,3 +657,320 @@ function AddProviderEditor({ ); } + +/** The custom endpoint's status row (shown once configured); clicking re-opens the 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 ( +
+ +
+ ); +} + +/** 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), 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, + manualModels0, + onSaved, + onPinsSaved, + onCancel, + onDelete, +}: { + baseUrl0: string; + keyPresent: boolean; + /** The discovered models — one limits row each. */ + 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; + onCancel: () => void; + onDelete?: () => void | Promise; +}) { + 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 + // 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); + setError(null); + const res = await fetch("/api/custom-provider", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ baseUrl, manualModels, ...(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; + } + await onSaved(); + } + + 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" + /> +
+ 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" + /> +
+ { + 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" + /> + + + {/* 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() && ( + + )} +
+ {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. +

+ )} + {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/components/settings/model-menu.tsx b/components/settings/model-menu.tsx index 678ba09..35db3be 100644 --- a/components/settings/model-menu.tsx +++ b/components/settings/model-menu.tsx @@ -21,7 +21,7 @@ import { useEffect, useRef, useState } from "react"; import type { ModelCatalogEntry } from "@/src/core"; -import { CLAUDE_CODE_PROVIDER, fuzzyScore, subscriptionLabel } from "@/src/core"; +import { CLAUDE_CODE_PROVIDER, CUSTOM_PROVIDER, fuzzyScore, subscriptionLabel } from "@/src/core"; import { formatTokens } from "@/components/usage-format"; import { SearchMenuList } from "./search-menu-list"; @@ -40,6 +40,7 @@ export function ModelMenuList({ effectiveProvider, onPick, footer, + vision, }: { /** The full catalog — undefined while it loads. */ models: ModelCatalogEntry[] | undefined; @@ -54,14 +55,25 @@ export function ModelMenuList({ onPick: (entry: ModelCatalogEntry) => void; /** Quiet footer note under the list (context-specific). */ footer?: string; + /** Vision slot: only image-capable models are selectable — plus every + * custom-endpoint model, whose capability the endpoint may not report. + * Image-capable ones float to the top. */ + vision?: boolean; }) { + // Vision slot: built-ins must report image input; custom-endpoint models + // stay selectable (the endpoint may not report a modality). Image-capable + // ones float to the top of the list. + const visionItems = vision + ? models?.filter((m) => m.input.includes("image") || m.provider === CUSTOM_PROVIDER.id) + : models; return ( <> `${m.provider}/${m.id}`} searchTerms={(m) => [m.provider, m.id, m.name]} compare={(a, b) => a.id.localeCompare(b.id)} + prioritize={vision ? (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..347dd9f 100644 --- a/components/setup-cta.tsx +++ b/components/setup-cta.tsx @@ -16,10 +16,9 @@ */ import { useEffect, useState } from "react"; -import { MODEL_SLOTS, type AuthKeyStatus } from "@/src/core"; +import { CUSTOM_PROVIDER, MODEL_SLOTS, type AuthKeyStatus, type ModelCatalogResponse } from "@/src/core"; import { classifyKeys, type ProviderKeyInfo } from "@/components/settings/keys-section"; import { useModelCatalog } from "@/components/model-catalog"; -import type { ModelCatalogResponse } from "@/src/core"; /** Pure: does a MODEL-PROVIDER key exist among the statuses? Reuses * classifyKeys (the keys section's own classification — no drift): @@ -30,7 +29,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 053f3eb..98b6ad7 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"; @@ -48,7 +48,7 @@ import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-sig import { useModelCatalog } from "@/components/model-catalog"; import { savedSlotsConfigured } from "@/components/setup-cta"; import { Hint } from "@/components/ui/hint"; -import { type ProviderKeyInfo } from "@/components/settings/keys-section"; +import { type ProviderKeyInfo, CUSTOM_PROVIDER_INFO } from "@/components/settings/keys-section"; import { ProviderMenuList } from "@/components/settings/model-menu"; import { ChatGPTBackgroundAccess } from "@/components/settings/chatgpt-connection"; import { NativeSubscriptionBackgroundAccess, SubscriptionConnections } from "@/components/settings/subscription-connections"; @@ -134,6 +134,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 +154,11 @@ export function SetupKeysBody({ onSave: () => void; /** The MenuShell anchor — wired by the wrapper (its triggerRef). */ menuAnchor: React.RefObject; + /** Chosen provider is the custom endpoint: a base URL field joins the key + * field. All three optional so plain-key callers (and tests) stay unchanged. */ + isCustom?: boolean; + baseUrl?: string; + onBaseUrl?: (v: string) => void; }) { return (
@@ -185,30 +193,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" && value.trim()) onSave(); + }} + 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} + 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 +286,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 +397,23 @@ 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 (!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); + // Custom endpoint connects through its own route; 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 +422,7 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) { } setKeyProvider(null); setKeyValue(""); + setCustomBaseUrl(""); notifySettingsSaved("keys"); } @@ -420,13 +453,16 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) { } const keysBody = setKeyMenuOpen((v) => !v)} onPick={(p) => { setKeyProvider(p); setKeyMenuOpen(false); setKeyError(null); }} onValue={setKeyValue} @@ -483,7 +519,9 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) { // Unavailable metadata cannot erase or clamp a saved preference. const levelsForEntry = metadata?.supportedLevels ?? (entry ? Object.keys(THINKING_LABEL) : []); const available = catalog?.models ?? (failed ? [] : undefined); - const slotCatalog = slot === "vision" ? available?.filter((m) => m.input.includes("image")) : available; + const slotCatalog = slot === "vision" + ? available?.filter((m) => m.input.includes("image") || m.provider === CUSTOM_PROVIDER.id) + : available; return (
@@ -537,6 +575,7 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) { models={slotCatalog} effectiveId={entry?.id} effectiveProvider={entry?.provider} + vision={slot === "vision"} onPick={(m) => { editedSlots.current.add(slot); setPicked((prev) => ({ ...prev, [slot]: m })); diff --git a/src/core/auth-keys.ts b/src/core/auth-keys.ts index e8e5cc9..492ff15 100644 --- a/src/core/auth-keys.ts +++ b/src/core/auth-keys.ts @@ -122,6 +122,19 @@ 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: a user-configured OpenAI-compatible + * server registered into pi-ai at runtime (server/custom-provider.ts). NOT part + * of AUTH_PROVIDER_KEYS (that table is test-pinned to pi-ai's built-ins and is + * env-var-keyed); this one is keyed by a user-supplied base URL, and its key + * rides this reserved env var (optional — keyless local servers allowed). + */ +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..ca63d6a 100644 --- a/src/core/settings-resolution.ts +++ b/src/core/settings-resolution.ts @@ -37,6 +37,8 @@ import { type GeneralSettings, type DictationSettings, type CompactionSettings, + type CustomModel, + type CustomModelOverride, type GraphSettings, type ModelSlot, type ModelSlotName, @@ -95,6 +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 and + * user pins default to empty. */ + customProvider: { baseUrl: string; models: CustomModel[]; overrides: Record; manualModels: string[] }; } /** The conversation pair's chain (BYOK, owner 2026-09-22): the chat's own @@ -221,6 +226,12 @@ 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 ?? [], + overrides: record?.customProvider?.overrides ?? {}, + manualModels: record?.customProvider?.manualModels ?? [], + }, }; } diff --git a/src/core/settings.ts b/src/core/settings.ts index 2e8d3ae..8dc6a37 100644 --- a/src/core/settings.ts +++ b/src/core/settings.ts @@ -208,6 +208,96 @@ export interface ModelsSettings { vision?: ModelSlot; } +// ─── Custom OpenAI-compatible endpoint ─────────────────────────────────────── +// ONE user-configured OpenAI-compatible server (vLLM / Ollama / a proxy). It is +// one more provider in the Keys list, keyed by a base URL instead of an env var. +// NON-SECRET config only (base URL + discovered model ids); the API key is a +// secret in the auth store, never here. Registered into pi-ai as a normal +// provider (server/custom-provider.ts). + +/** Defaults for a custom model — self-hosted endpoints are free. */ +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. */ +export interface CustomModel { + id: string; + /** Accepts image input (Vision-capable). Default false (text-only). */ + image?: boolean; + /** Accepts a reasoning effort (thinking). Default false → the only + * thinking level is "off", exactly like a non-reasoning built-in. */ + reasoning?: boolean; + /** Context window in tokens, from the endpoint. Absent → the default. */ + context?: number; + /** Max output tokens, from the endpoint. Absent → the default. */ + 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 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 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[]; +} + +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), + * drops trailing slashes. Returns "" for anything invalid ("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?": a non-empty base URL. + * Key presence is deliberately excluded (keyless local servers are allowed). */ +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 @@ -375,6 +465,8 @@ export interface UserSettings { agent?: AgentSettings; /** The strong/cheap/vision model slots (model slots wave, 2026-09-20). */ models?: ModelsSettings; + /** The custom OpenAI-compatible endpoint (non-secret config only). */ + customProvider?: CustomProviderSettings; appearance?: AppearanceSettings; graph?: GraphSettings; compaction?: CompactionSettings; @@ -501,6 +593,78 @@ function sanitizeModels(raw: Record): ModelsSettings { return out; } +/** 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; +} + +/** 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. */ +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; + if (rec.reasoning === true) model.reasoning = 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()]; + } + 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; +} + function sanitizeBooleanMap(raw: unknown): Record | undefined { if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return undefined; const out: Record = {}; @@ -651,6 +815,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..2d947e6 --- /dev/null +++ b/src/server/custom-provider.ts @@ -0,0 +1,231 @@ +/** + * The custom OpenAI-compatible endpoint: turns the user's non-secret config + * (base URL + discovered models) into a pi-ai provider registered in + * `piModels()`, so its models fill the same slots as any built-in. Keyless + * local servers work via a placeholder key resolved at request time. + */ + +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 CustomModelOverride, + type CustomProviderSettings, +} from "../core/settings.ts"; +import { keyValue } from "./keys.ts"; + +/** Placeholder bearer for keyless endpoints (the OpenAI client needs *a* key). */ +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); + +/** Auth: the reserved key via the keys seam, else the keyless placeholder. */ +const customEndpointAuth: ApiKeyAuth = { + name: `${CUSTOM_PROVIDER.label} API key`, + resolve: async () => { + const key = keyValue(CUSTOM_PROVIDER.envVar)?.trim(); + return { auth: { apiKey: key || KEYLESS_PLACEHOLDER }, source: key ? CUSTOM_PROVIDER.envVar : "keyless" }; + }, +}; + +/** Map a discovered model to a pi-ai entry: image→input, reasoning→thinking + * 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, + // 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: pin?.context ?? model.context ?? CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW, + maxTokens: pin?.maxTokens ?? model.maxTokens ?? CUSTOM_MODEL_DEFAULT_MAX_TOKENS, + type: "chat", + }; +} + +/** Build an OpenAI-compatible provider from parts. baseUrl must be normalized. */ +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, overrides)), + api: openAICompletionsApi(), + }); +} + +/** Re-sync the provider into piModels() (idempotent): usable → rebuild; + * not usable → remove. Never throws. */ +export function syncCustomProvider(cp: CustomProviderSettings | undefined): void { + const models = piModels(); + if (!customProviderUsable(cp)) { + models.deleteProvider(CUSTOM_PROVIDER.id); + return; + } + models.setProvider( + buildOpenAiCompatibleProvider({ + baseUrl: normalizeBaseUrl(cp.baseUrl), + models: cp.models ?? [], + overrides: cp.overrides ?? {}, + }), + ); +} + +/** 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 + * body → 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}`, 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" }; + 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 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 }); + 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 { + 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? Scans the common modality/ + * capability fields; absent or unrecognized → false (text-only). */ +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; +} + +/** Does a /models row advertise reasoning? Metadata only — no name guessing, + * because a false positive sends reasoning_effort an endpoint will reject. + * Trusted signals: an explicit boolean (reasoning / supports_reasoning / + * thinking) or a reasoning token in the capability/parameter lists servers + * publish (vLLM + OpenRouter's supported_parameters, capabilities, tags). + * Absent or unrecognized → false (thinking stays "off"). */ +function detectReasoning(row: Record): boolean { + for (const key of ["reasoning", "supports_reasoning", "thinking"] as const) { + if (row[key] === true) return true; + } + const architecture = isRecord(row.architecture) ? row.architecture : undefined; + const candidates = [row.supported_parameters, row.capabilities, row.tags, architecture?.output_modalities]; + return candidates.some( + (list) => + Array.isArray(list) && + list.some((v) => typeof v === "string" && (/reasoning/i.test(v) || v.toLowerCase() === "thinking")), + ); +} + +/** First positive integer among `keys` on a row (endpoints name limits + * inconsistently). */ +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 from a list response (objects with `id` or bare strings), each + * with detected image + token limits. Null if the shape is unrecognized. */ +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; + if (detectReasoning(row)) model.reasoning = 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 0c3b002..9f24424 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -46,3 +46,6 @@ export * from "./model-catalog.ts"; export { subscriptionControlsResponse, subscriptionBrowserResponse } from "./web-subscription-http.ts"; export { sessionCost } from "./pricing.ts"; export { claudeCodeInstallationResponse } from "./claude-code.ts"; +// The custom endpoint module (registers into piModels() + probes for models). +// No import 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 2151e49..46b2be3 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, @@ -62,10 +63,10 @@ import { CLAUDE_CODE_PROVIDER } from "../agent/claude-code/constants.ts"; // without importing this store-backed module into the agent layer. import { authEnvRecord as boot } from "../core/auth-env.ts"; -/** 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, or + * the custom endpoint's reserved key (writable through the same seam). */ 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 @@ -209,7 +210,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 e585150..fa24801 100644 --- a/src/server/model-catalog.ts +++ b/src/server/model-catalog.ts @@ -49,10 +49,17 @@ import type { SlotPins, } from "../core/contracts/models.ts"; import type { EffectiveModelSpec, EffectiveSlot } from "../core/settings-resolution.ts"; -import { AUTH_PROVIDER_KEYS } from "../core/auth-keys.ts"; +import { AUTH_PROVIDER_KEYS, CUSTOM_PROVIDER } from "../core/auth-keys.ts"; import { claudeCodeAvailable } from "./claude-code-config.ts"; import { CLAUDE_CODE_PROVIDER } from "../agent/claude-code/constants.ts"; -import { BACKGROUND_MODEL_ROLES, MODEL_SLOTS, type BackgroundModelRole, type ModelSlotName, type ModelsSettings } from "../core/settings.ts"; +import { + BACKGROUND_MODEL_ROLES, + MODEL_SLOTS, + customProviderUsable, + type BackgroundModelRole, + type ModelSlotName, + type ModelsSettings, +} from "../core/settings.ts"; /** The picker's client-safe model entry (contract: core/contracts/models.ts). */ export type ModelDto = ModelCatalogEntry; @@ -149,6 +156,11 @@ export function catalogProviders(): CatalogProvider[] { { id: CLAUDE_CODE_PROVIDER, envVar: "", keyPresent: claudeCodeAvailable() }, ...NATIVE_SUBSCRIPTIONS.map((p) => ({ id: p.logicalId, envVar: "", keyPresent: nativeSubscriptionConnected(p.providerId) })), ...customProviderRows(), + // The custom endpoint: a row only when usable (base URL set). keyPresent + // reflects usability, not key presence (keyless local servers are allowed). + ...(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 81ae0f2..faad497 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"; @@ -395,8 +396,47 @@ 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 = { general: parseGeneralSettingsBody, chat: parseSettingsBody, diff --git a/src/server/stores.ts b/src/server/stores.ts index c63f546..27e6c3e 100644 --- a/src/server/stores.ts +++ b/src/server/stores.ts @@ -11,6 +11,7 @@ import { graphStore } from "../store/graph.ts"; import { jobStore } from "../store/jobs.ts"; import { settingsStore } from "../store/settings.ts"; import { resolveSettings, type EffectiveSettings } from "../core/settings-resolution.ts"; +import { syncCustomProvider } from "./custom-provider.ts"; /** The wired adapters. Satisfies core/graph.ts's GraphDeps structurally. */ export const stores = { chatStore, @@ -32,3 +33,7 @@ export type Stores = typeof stores; export function effectiveSettings(): EffectiveSettings { return resolveSettings(settingsStore.readSettings()); } + +// Boot: register the custom endpoint (when configured) into piModels(); the +// POST /api/custom-provider route re-syncs after every connect. +syncCustomProvider(effectiveSettings().customProvider); diff --git a/tests/custom-endpoint.test.ts b/tests/custom-endpoint.test.ts new file mode 100644 index 0000000..d477b79 --- /dev/null +++ b/tests/custom-endpoint.test.ts @@ -0,0 +1,393 @@ +/** + * 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, 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"; +import { after, describe, it } from "node:test"; +import { + CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW, + CUSTOM_MODEL_DEFAULT_MAX_TOKENS, + CUSTOM_MODEL_PIN_FLOOR, + customProviderUsable, + normalizeBaseUrl, + parseModelIds, + 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, + discoverModels, + mergeManualModels, + syncCustomProvider, +} from "../src/server/custom-provider.ts"; +import { piModels } from "../src/agent/models.ts"; +import { getSupportedThinkingLevels } from "@earendil-works/pi-ai"; + +// ─── normalizeBaseUrl ──────────────────────────────────────────────────────── +describe("normalizeBaseUrl", () => { + it("drops trailing slashes and keeps the path", () => { + assert.equal(normalizeBaseUrl("https://host:8000/v1/"), "https://host:8000/v1"); + assert.equal(normalizeBaseUrl(" https://host/v1 "), "https://host/v1"); + assert.equal(normalizeBaseUrl("http://localhost:11434"), "http://localhost:11434"); + }); + it("rejects non-http(s) schemes and junk with empty string", () => { + assert.equal(normalizeBaseUrl("file:///etc/passwd"), ""); + assert.equal(normalizeBaseUrl("not a url"), ""); + assert.equal(normalizeBaseUrl(""), ""); + }); +}); + +// ─── customProviderUsable ──────────────────────────────────────────────────── +describe("customProviderUsable", () => { + it("is true exactly when a base URL is set (the key is optional)", () => { + assert.equal(customProviderUsable({ baseUrl: "https://h/v1" }), true); + assert.equal(customProviderUsable({ baseUrl: "" }), false); + assert.equal(customProviderUsable({}), false); + assert.equal(customProviderUsable(undefined), false); + }); +}); + +// ─── sanitizer ─────────────────────────────────────────────────────────────── +describe("sanitizeUserSettings — customProvider", () => { + it("normalizes the base URL and cleans the model list (trim, dedupe, image flag)", () => { + const out = sanitizeUserSettings({ + customProvider: { + baseUrl: "https://host:8000/v1/", + models: [ + { id: " llama-3 " }, // trims id; image absent + { id: "gpt", image: true }, + { id: "gpt", image: false }, // dup id wins (last) → image dropped + { id: "" }, // dropped: no usable id + "not-an-object", // dropped + ], + }, + }); + const cp = out?.customProvider; + assert.equal(cp?.baseUrl, "https://host:8000/v1"); + assert.deepEqual(cp?.models, [{ id: "llama-3" }, { id: "gpt" }]); + }); + it("keeps image:true and drops a non-array models list / invalid base URL", () => { + const out = sanitizeUserSettings({ customProvider: { baseUrl: "ftp://x", models: "nope" } }); + assert.equal(out?.customProvider?.baseUrl, undefined); + assert.equal(out?.customProvider?.models, undefined); + }); + it("keeps reasoning:true and drops a non-true reasoning flag", () => { + const out = sanitizeUserSettings({ + customProvider: { + baseUrl: "https://h/v1", + models: [{ id: "thinker", reasoning: true }, { id: "plain", reasoning: "yes" }], + }, + }); + assert.deepEqual(out?.customProvider?.models, [{ id: "thinker", reasoning: true }, { id: "plain" }]); + }); +}); + +// ─── 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: [], overrides: {}, manualModels: [] }); + }); + 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 } }); + }); + 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 ────────────────────────────────────────────── +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("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", + models: [{ id: "thinker", reasoning: true }, { id: "plain" }], + }); + const [thinker, plain] = provider.getModels(); + assert.equal(thinker!.reasoning, true); + // No thinkingLevelMap → pi-ai's default set, same as any reasoning model. + assert.deepEqual(getSupportedThinkingLevels(thinker!), ["off", "minimal", "low", "medium", "high"]); + assert.equal(plain!.reasoning, false); + assert.deepEqual(getSupportedThinkingLevels(plain!), ["off"]); + }); + + 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("detects reasoning from endpoint metadata only — never from the model name", async () => { + stubFetch(() => ({ + status: 200, + body: { + data: [ + { id: "vllm-reasoner", supported_parameters: ["temperature", "reasoning", "reasoning_effort"] }, + { id: "bool-reasoner", reasoning: true }, + { id: "thinking-flag", thinking: true }, + { id: "cap-reasoner", capabilities: ["completion", "reasoning"] }, + { id: "deepseek-r1-distill" }, // a reasoning-sounding NAME, no metadata → stays off + ], + }, + })); + const r = await discoverModels("http://h/v1"); + assert.ok(r.ok); + const byId = Object.fromEntries(r.models.map((m) => [m.id, Boolean(m.reasoning)])); + assert.equal(byId["vllm-reasoner"], true); + assert.equal(byId["bool-reasoner"], true); + assert.equal(byId["thinking-flag"], true); + assert.equal(byId["cap-reasoner"], true); + assert.equal(byId["deepseek-r1-distill"], false, "name guessing would risk sending reasoning_effort to a server that rejects it"); + }); + + 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/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"]); + }); +}); diff --git a/tests/settings-body.test.ts b/tests/settings-body.test.ts index 7c7df16..1dcb0da 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", "claudeCode"], diff --git a/tests/setup-dialog-interaction.test.tsx b/tests/setup-dialog-interaction.test.tsx index 186cec6..4e3b8fb 100644 --- a/tests/setup-dialog-interaction.test.tsx +++ b/tests/setup-dialog-interaction.test.tsx @@ -172,10 +172,15 @@ describe("setup polish interactions", () => { assert.equal(closed, 0); }); - it("the empty vision list gives the approved next action without an em dash", async () => { + it("the vision slot filters out built-in text-only models (image-capable or custom only)", async () => { await mount({ keyed: true }); await click(element('[data-testid="setup-trigger-vision"]')); - assert.ok(element('[data-testid="setup-menu-vision"]').textContent?.includes("No image-capable models. Add a provider key with access to a vision model.")); + // The text-only openrouter fixture is neither image-capable nor a custom- + // endpoint model, so the vision slot filters it out (built-ins must report + // image input; only custom models stay selectable when capability is unknown). + const menu = element('[data-testid="setup-menu-vision"]'); + assert.ok(!menu.textContent?.includes("fixture-text")); + assert.ok(menu.textContent?.includes("No models match.")); }); it("catalog failure uses the approved two-sentence error", async () => {