"use client"; import { translator, type Translator } from "@/src/core"; import { LanguageSelect, useI18n } from "@/components/i18n"; /** * The Keys section (wave-5 polish, 2026-09-21 — owner design): a MINIMAL * key-value table. Provider left, masked key right, no description hints * (they took too much space), the "Get a key ↗" link a tiny muted guest. * Clicking a writable row unfolds a paste field + Save (+ Delete for a * file-sourced value) beneath it; Esc or a second click folds it away. * The Add-provider control is the LAST ROW of the table and uses the * app's shared dropdown look (ProviderMenuList — fuzzy search over the * 33 pi-ai providers). * * Owner rulings carried in: * - Env-owned rows (a real environment variable owns the name — dev * .env.local or launch env) render READ-ONLY + dimmed, no paste field: * a paste box on a key the UI cannot make live would mislead. * - OpenRouter is NOT special: one provider among 33, a row only when * stored, otherwise it lives in the Add dropdown like everyone else. * - Providers groups API keys, subscriptions, dictation and search. * Unknown variables stay masked/read-only at the end of Model API keys. * * API: GET /api/keys (masked statuses + the providers array), PUT * /api/keys/:id (shape-gated 400s land inline), DELETE. Saves take * effect immediately. Plaintext never returns from the store. */ import { useEffect, useRef, useState } from "react"; import { AUTH_KEYS, CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW, CUSTOM_MODEL_DEFAULT_MAX_TOKENS, CUSTOM_PROVIDER, normalizeBaseUrl, type AuthKeyStatus, type CustomModel, type CustomModelOverride, } from "@/src/core"; import { SettingsGroup, SettingsRow } from "./controls.tsx"; import { SubscriptionConnections } from "./subscription-connections.tsx"; import { ClaudeCodeInstallationStatus } from "./claude-code-status.tsx"; import { ProviderMenuList } from "./model-menu.tsx"; import { MenuShell } from "@/components/menu-shell"; import { notifySettingsSaved } from "@/components/settings-signal"; /** One pi-ai provider with plain API-key auth (the Platform seam — * derived from pi-ai's own env-key table; no drift possible). */ export interface ProviderKeyInfo { id: string; envVar: string; label: string; } interface KeysResponse { keys: AuthKeyStatus[]; providers?: ProviderKeyInfo[]; } /** The custom endpoint's non-secret status (GET /api/custom-provider). */ interface CustomStatus { keyReuseRequired: boolean; revision: string; authMode: "keyless" | "apiKey"; keySource: "env" | "file" | "none"; baseUrl: string; models: CustomModel[]; /** User pins by model id (context / max output / thinking). */ overrides: Record; manualModels: string[]; keyPresent: boolean; } async function readCustomStatus(inspectKey = false): Promise { const response = await fetch(`/api/custom-provider${inspectKey ? "?authMode=apiKey" : ""}`); if (!response.ok) throw new Error("Unable to load custom configuration."); const cp = await response.json() as Partial; return { keyReuseRequired: Boolean(cp.keyReuseRequired), revision: response.headers.get("etag") ?? "", authMode: cp.authMode ?? "keyless", keySource: cp.keySource ?? "none", baseUrl: cp.baseUrl ?? "", models: cp.models ?? [], overrides: cp.overrides ?? {}, manualModels: cp.manualModels ?? [], keyPresent: Boolean(cp.keyPresent) }; } /** 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. */ export function classifyKeys( statuses: readonly AuthKeyStatus[], providers: readonly ProviderKeyInfo[] | undefined, ): { /** Stored provider keys (env or file), in GET order. */ providerRows: AuthKeyStatus[]; /** The curated tools rows (synthesized when absent — always shown, * they carry the docs links a first boot needs). */ toolRows: AuthKeyStatus[]; /** Unknown file vars — preserved, read-only. */ unknownRows: AuthKeyStatus[]; /** Providers without a stored key — the Add dropdown's options. */ addable: ProviderKeyInfo[]; } { const byId = new Map(statuses.map((s) => [s.id, s])); const isTool = (id: string) => (TOOL_IDS as readonly string[]).includes(id); const providerEnv = new Set((providers ?? []).map((p) => p.envVar)); const synth = (id: string): AuthKeyStatus => ({ id, info: null, present: false, masked: null, source: "none", shadowedByEnv: false, }); const providerRows: AuthKeyStatus[] = []; 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 // renders as a provider row so nothing disappears) else providerRows.push(s); } return { providerRows, toolRows: TOOL_IDS.map((id) => byId.get(id) ?? synth(id)), unknownRows, addable: (providers ?? []).filter((p) => !byId.has(p.envVar)), }; } /** A key row names its credential, not whichever provider sorts first. * Pi exposes Zen and Go separately but both read OPENCODE_API_KEY. */ export function providerKeyLabel(id: string, providers: readonly ProviderKeyInfo[] | undefined): string | undefined { const matches = providers?.filter((p) => p.envVar === id); if (matches?.some((p) => p.id === "opencode") && matches.some((p) => p.id === "opencode-go")) return "OpenCode (Zen / Go)"; return matches?.[0]?.label; } /** The minimal status line, e.g. "…ab12 · 64 chars" (" · env" marks an * environment-owned value — the row itself is read-only then). Pure. */ export function keyStatusLine(status: AuthKeyStatus, t: Translator = translator("en")): string { if (!status.present || !status.masked) return t("not set"); const tail = status.masked.replace(/ \((\d+) chars\)$/, ""); const chars = status.masked.match(/\((\d+) chars\)$/)?.[1] ?? "?"; return `${tail} · ${chars}${status.source === "env" ? t(" · env") : ""}`; } /** One table row: label left, tiny docs link + status right. Writable * rows click-unfold an editor beneath (owner interaction); env-owned * rows are dimmed and read-only (a paste box there would mislead). */ function KeyTableRow({ label, docsUrl, status, expanded, onToggle, onSave, onDelete, onCancel, testid, }: { label: string; /** The "Get a key ↗" target — curated keys carry one. */ docsUrl?: string; status: AuthKeyStatus; expanded: boolean; onToggle: () => void; onSave: (value: string) => Promise; onDelete?: () => Promise; /** Folds the editor (owner, fix wave: the way OUT of a paste prompt * must be visible — Cancel beside Save/Delete, not only Esc). */ onCancel: () => void; testid: string; }) { const { t } = useI18n(); const envOwned = status.source === "env"; const [value, setValue] = useState(""); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); async function save() { if (!value.trim() || busy) return; setBusy(true); setError(null); const err = await onSave(value); if (err) setError(err); else setValue(""); setBusy(false); } // INLINE editing (owner, fix wave: "type the keys in line — more // table-like"): the expanded row becomes the editor itself — label // left, the paste field filling the middle, Save | Cancel (| Delete) // right — instead of a second line beneath the row. if (expanded && !envOwned) { return (
{label} setValue(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") void save(); }} placeholder={t("Paste key…")} aria-label={t("{v0} key", { v0: label })} data-testid={`${testid}-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" /> {status.present && status.source === "file" && onDelete && ( )}
{error && (

{t(error)}

)}
); } return (
{error && (

{t(error)}

)}
); } /** The Add-provider control — the table's last row, the app's shared * dropdown look (portaled menu, the search + arrow-walk machine). */ function AddProviderRow({ addable, onPick, }: { addable: ProviderKeyInfo[]; onPick: (p: ProviderKeyInfo) => void; }) { const { t } = useI18n(); const [open, setOpen] = useState(false); const anchorRef = useRef(null); useEffect(() => { if (!open) return; const onPointerDown = (e: PointerEvent) => { const target = e.target as Node; if (anchorRef.current?.contains(target)) return; if (target instanceof Element && target.closest("[data-lh-menu]")) return; if (target instanceof Element && target.closest('[role="dialog"]')) setOpen(false); }; const onKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { e.stopPropagation(); setOpen(false); } }; document.addEventListener("pointerdown", onPointerDown); document.addEventListener("keydown", onKeyDown); return () => { document.removeEventListener("pointerdown", onPointerDown); document.removeEventListener("keydown", onKeyDown); }; }, [open]); return (
{open && ( {/* Close on pick (owner, fix wave): a chosen provider ends the dropdown — the paste row is the next step. */} { setOpen(false); onPick(p); }} /> )}
); } export function KeysSection({ /** A deep-linked row to unfold on arrival (zero-keys wave): the * dictation error's click lands here on DEEPGRAM_API_KEY. Followed * while mounted too — the settings dialog's while-open retarget * pattern. undefined = nothing seeded. */ initialRow, }: { initialRow?: string; }) { const { t } = useI18n(); const [statuses, setStatuses] = useState(null); const [providers, setProviders] = useState(undefined); const followedProvider = useRef(null); const [loading, setLoading] = useState(true); /** The unfolded row (its id), null = all folded. Seeded by the * deep-link (initialRow) and re-followed when it retargets. */ const [openRow, setOpenRow] = useState(initialRow ?? null); /** 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 && !addingCustom) return; const onKeyDown = (e: KeyboardEvent) => { if (e.key !== "Escape") return; // A language dropdown above this editor in the dismissal stack owns // the first Escape; do not swallow it at document capture phase. if (document.querySelector('[data-testid="dictation-language-menu"], [data-testid="language-select-sttLanguage-menu"]')) return; e.preventDefault(); e.stopPropagation(); setOpenRow(null); setAdding(null); setAddingCustom(false); }; document.addEventListener("keydown", onKeyDown, true); return () => document.removeEventListener("keydown", onKeyDown, true); }, [openRow, adding, addingCustom]); 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 { setCustom(await readCustomStatus()); } catch { /* custom status unavailable — leave it unset */ } }; useEffect(() => { void reload(); }, []); // The deep-link follow: a retarget while the section is open (the // same while-open pattern the dialog's section nav uses) unfolds the // named row; a fresh mount seeds it via the useState initializer. useEffect(() => { if (initialRow !== undefined) setOpenRow(initialRow); followedProvider.current = null; }, [initialRow]); // A missing model-provider key has no table row yet. Deep-link into the // existing Add-provider editor instead (for example, OpenRouter STT setup). useEffect(() => { if (!initialRow || !statuses || !providers || followedProvider.current === initialRow) return; followedProvider.current = initialRow; if (statuses.some((status) => status.id === initialRow)) return; const provider = providers.find((provider) => provider.envVar === initialRow); if (provider) { setAdding(provider); setAddError(null); setOpenRow(null); } }, [initialRow, statuses, providers]); async function saveKey(id: string, value: string): Promise { const res = await fetch(`/api/keys/${id}`, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ value }), }); if (!res.ok) { const body = (await res.json().catch(() => ({}))) as { error?: string }; return body.error ?? `Save failed (${res.status})`; } await reload(); // The cross-surface keys signal (zero-keys wave): the zero-keys pill, // the setup dialog's stage advance, and any other keys reader hear // that a key landed — deletions included (see deleteKey below). notifySettingsSaved("keys"); return null; } async function deleteKey(id: string): Promise { await fetch(`/api/keys/${id}`, { method: "DELETE" }).catch(() => undefined); await reload(); 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(revision: string): Promise { const res = await fetch("/api/custom-provider", { method: "DELETE", headers: { "content-type": "application/json", "if-match": revision }, body: JSON.stringify({}) }).catch(() => null); if (!res?.ok) return res ? ((await res.json().catch(() => ({}))).error ?? "Disconnect failed.") : "Disconnect failed. Check your connection."; setAddingCustom(false); await reload(); notifySettingsSaved("keys"); return null; } if (loading || statuses === null) { return

{t("Loading keys…")}

; } const { providerRows, toolRows, unknownRows, addable } = classifyKeys(statuses, providers); async function saveNewProvider(p: ProviderKeyInfo, value: string) { const err = await saveKey(p.envVar, value); if (err) setAddError(err); else { setAdding(null); setAddError(null); } } return ( <> {providerRows.map((status) => ( setOpenRow(openRow === status.id ? null : status.id)} onSave={async (v) => { const err = await saveKey(status.id, v); if (!err) setOpenRow(null); // fold on success — rest state return err; }} onCancel={() => setOpenRow(null)} onDelete={() => deleteKey(status.id)} testid={`key-${status.id.toLowerCase()}`} /> ))} {custom?.baseUrl && ( setAddingCustom((v) => !v)} /> )} {addingCustom && ( { await reload(); setAddingCustom(false); }} baseUrl0={custom?.baseUrl ?? ""} keyPresent={custom?.keyPresent ?? false} models={custom?.models ?? []} overrides={custom?.overrides ?? {}} manualModels0={custom?.manualModels ?? []} onSaved={async () => { setAddingCustom(false); await reload(); notifySettingsSaved("keys"); }} onPinsSaved={async () => { await reload(); notifySettingsSaved("keys"); }} onDelete={disconnectCustom} onCancel={() => setAddingCustom(false)} /> )} {providers !== undefined && ( { setAddError(null); if (p.id === CUSTOM_PROVIDER.id) { setAdding(null); setAddingCustom(true); } else { setAddingCustom(false); setAdding(p); } }} /> )} {adding && (
{adding.label} void saveNewProvider(adding, v)} onCancel={() => setAdding(null)} error={addError} />
)} {unknownRows.length > 0 &&
{unknownRows.map((status) =>
{status.id} {status.masked}
)}
}
{(["Dictation", "Search"] as const).map((label) => {toolRows.filter((status) => label === "Dictation" ? status.id === "DEEPGRAM_API_KEY" : status.id !== "DEEPGRAM_API_KEY").map((status) => ( setOpenRow(openRow === status.id ? null : status.id)} onSave={async (v) => { const err = await saveKey(status.id, v); if (!err) setOpenRow(null); return err; }} onCancel={() => setOpenRow(null)} onDelete={() => deleteKey(status.id)} testid={`key-${status.id.toLowerCase()}`} /> ))} {label === "Dictation" && <>

Create a Deepgram API key with the Member role or higher, then add it above.

}
)} ); } /** The paste field for a provider chosen in the Add dropdown — rendered * beneath the Add row until saved or cancelled. */ function AddProviderEditor({ provider, onSave, onCancel, error, }: { provider: ProviderKeyInfo; onSave: (value: string) => void; onCancel: () => void; error: string | null; }) { const { t } = useI18n(); const [value, setValue] = useState(""); return ( <> setValue(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && value.trim()) onSave(value); }} placeholder={t("Paste key…")} aria-label={t("{v0} key", { v0: provider.label })} data-testid={`key-${provider.id}-input`} 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 && (

{t(error)}

)} ); } /** One donor editor for first setup and Settings; setup omits offline pin and * disconnect controls, while authentication/revisions/manual IDs stay identical. */ export function CustomEndpointSetup({ onSaved }: { onSaved: () => void | Promise }) { const { t } = useI18n(); const [status, setStatus] = useState(null); const [error, setError] = useState(false); async function reload() { try { setStatus(await readCustomStatus()); setError(false); } catch { setError(true); } } useEffect(() => { void reload(); }, []); if (!status || error) return
{t(error ? "Unable to load custom configuration." : "Loading custom configuration…")}{error && }
; return ; } /** 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 { t } = useI18n(); const count = status.models.length; const line = `${status.baseUrl} · ${count} model${count === 1 ? "" : "s"} · ${status.authMode === "keyless" ? "keyless" : status.keyPresent ? "API key" : "API key missing"}`; 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) Object.defineProperty(out, id, { value: pin, enumerable: true }); } 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({ setup = false, revision0, authMode0, keySource0, keyReuseRequired0, onReload, baseUrl0, keyPresent: initialKeyPresent, models, overrides, manualModels0, onSaved, onPinsSaved, onCancel, onDelete, }: { setup?: boolean; revision0: string; authMode0: "keyless" | "apiKey"; keySource0: "env" | "file" | "none"; keyReuseRequired0: boolean; onReload: () => void | Promise; 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?: (revision: string) => Promise; }) { const { t } = useI18n(); const [revision, setRevision] = useState(revision0); const [authMode, setAuthMode] = useState(authMode0); const [keySource, setKeySource] = useState(keySource0); const [keyPresent, setKeyPresent] = useState(initialKeyPresent); const [keyReuseRequired, setKeyReuseRequired] = useState(keyReuseRequired0); const [authLoading, setAuthLoading] = useState(false); const [reuseKey, setReuseKey] = useState(false); useEffect(() => { if (authMode !== "apiKey") return; let cancelled = false; setAuthLoading(true); readCustomStatus(true).then((status) => { if (cancelled) return; setKeySource(status.keySource); setKeyPresent(status.keyPresent); setKeyReuseRequired(status.keyReuseRequired); if (status.keySource === "env") setKey(""); }).catch(() => { if (!cancelled) setError("Unable to inspect effective key. Reload configuration and retry."); }) .finally(() => { if (!cancelled) setAuthLoading(false); }); return () => { cancelled = true; }; }, [authMode]); 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); const changedTarget = normalizeBaseUrl(baseUrl) !== normalizeBaseUrl(baseUrl0); const canConnect = Boolean(baseUrl.trim()) && (authMode === "keyless" || (!authLoading && ((Boolean(key.trim()) && keySource !== "env") || (keyPresent && (!(changedTarget || keyReuseRequired) || reuseKey))))); 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 || busy || !dirty) return; setPinsBusy(true); setPinsError(null); const res = await fetch("/api/custom-provider", { method: "PATCH", headers: { "content-type": "application/json", "if-match": revision }, body: JSON.stringify({ overrides: pinsPayload(current) }), }).catch(() => null); 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."); setPinsBusy(false); return; } setRevision(res.headers.get("etag") ?? revision); // The server's stored map is the truth — 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(); setPinsBusy(false); } async function save() { if (busy || pinsBusy || !canConnect) return; setBusy(true); setError(null); const res = await fetch("/api/custom-provider", { method: "POST", headers: { "content-type": "application/json", "if-match": revision }, body: JSON.stringify({ baseUrl, authMode, reuseKey, manualModels, ...(authMode === "apiKey" && keyTouched && key.trim() && keySource !== "env" ? { key } : {}) }), }).catch(() => null); 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."); setBusy(false); return; } await onSaved(); setBusy(false); } return (
{t("Custom endpoint")} setBaseUrl(e.target.value)} placeholder={t("Base URL, e.g. http://localhost:11434/v1")} aria-label={t("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={t("Model IDs, comma-separated — optional, needed when the endpoint has no /models list")} aria-label={t("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" /> {authMode === "apiKey" && keySource === "env" &&

{t("Environment supplies the effective key. Change the environment to replace it.")}

} {authMode === "apiKey" && keyPresent && (changedTarget || keyReuseRequired) && }
{authMode === "apiKey" && { setKey(e.target.value); setKeyTouched(true); if (!e.target.value.trim()) setAuthMode("keyless"); }} placeholder={keyPresent ? "Leave blank to keep effective key" : "API key"} aria-label={t("Custom endpoint API key")} data-testid="custom-key-input" disabled={busy || pinsBusy || keySource === "env"} 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() && ( )}
{!setup && models.length > 0 && (
{models.map((m) => { const d = draftOf(m); return (
{m.id} {t("Context:")} {d.context.trim() ? "user pin" : m.context ? `reported ${m.context}` : `fallback ${CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW}`} {t("· Output:")} {d.maxTokens.trim() ? "user pin" : m.maxTokens ? `reported ${m.maxTokens}` : `fallback ${CUSTOM_MODEL_DEFAULT_MAX_TOKENS}`} 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}

)}
)}
{!setup && models.length > 0 &&

{t("Budget pins do not resize backend context. Blank uses reported limits, then fallback (8192 context / 4096 output). Custom pricing is unknown.")}

}

{t("Reasoning support is a capability declaration. The effort picker’s Off may not disable a custom backend’s default thinking.")}

{(error || pinsError) && } {error && (

{error}

)}
); }