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({