A local endpoint that publishes no limits — LM Studio's /models says nothing — left every model on the 8192/4096 defaults with no way to say "this one has a 131k window". Each discovered model now gets three optional pins in the endpoint editor: context window, max output tokens, thinking (auto/on/off). A pin always wins: user pin → what the endpoint published → compiled default. Blank means no opinion, so discovery keeps ruling wherever you don't pin. Pins live in customProvider.overrides keyed by model id, stored apart from the discovery snapshot: a reconnect refreshes the models and prunes the pins of models that vanished while keeping the rest. Pins are written by PATCH /api/custom-provider, which never probes — the whole point is configuring a server that isn't answering /models correctly. Connect still probes and still fails loudly when the endpoint is down. A pin under 2048 tokens is rejected on the write side and clamped up on the read path, matching the compaction-floor discipline.
956 lines
35 KiB
TypeScript
956 lines
35 KiB
TypeScript
"use client";
|
||
/**
|
||
* 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,
|
||
type AuthKeyStatus,
|
||
type CustomModel,
|
||
type CustomModelOverride,
|
||
} from "@/src/core";
|
||
import { SettingsGroup } from "./controls.tsx";
|
||
import { SubscriptionConnections } from "./subscription-connections.tsx";
|
||
import { ProviderMenuList } from "./model-menu.tsx";
|
||
import { MenuShell } from "@/components/menu-shell";
|
||
import { notifySettingsSaved } from "@/components/settings-signal";
|
||
import { DictationLanguageSetting } from "@/components/dictation-language";
|
||
|
||
/** 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 {
|
||
baseUrl: string;
|
||
models: CustomModel[];
|
||
/** User pins by model id (context / max output / thinking). */
|
||
overrides: Record<string, CustomModelOverride>;
|
||
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. */
|
||
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): string {
|
||
if (!status.present || !status.masked) return "not set";
|
||
const tail = status.masked.replace(/ \((\d+) chars\)$/, "");
|
||
const chars = status.masked.match(/\((\d+) chars\)$/)?.[1] ?? "?";
|
||
return `${tail} · ${chars}${status.source === "env" ? " · 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<string | null>;
|
||
onDelete?: () => Promise<void>;
|
||
/** 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 envOwned = status.source === "env";
|
||
const [value, setValue] = useState("");
|
||
const [error, setError] = useState<string | null>(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 (
|
||
<div data-testid={testid}>
|
||
<div className="flex items-center gap-2 rounded-sm px-1 py-1.5" data-testid={`${testid}-editor`}>
|
||
<span className="shrink-0 font-ui text-xs text-foreground">{label}</span>
|
||
<input
|
||
type="text"
|
||
value={value}
|
||
onChange={(e) => setValue(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") void save();
|
||
}}
|
||
placeholder="Paste key…"
|
||
aria-label={`${label} key`}
|
||
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"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => void save()}
|
||
disabled={busy || !value.trim()}
|
||
data-testid={`${testid}-save`}
|
||
className="shrink-0 text-xs text-foreground underline-offset-2 hover:underline disabled:text-muted-foreground/40 disabled:no-underline"
|
||
>
|
||
Save
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={onCancel}
|
||
disabled={busy}
|
||
data-testid={`${testid}-cancel`}
|
||
className="shrink-0 text-xs text-muted-foreground/70 underline-offset-2 hover:text-foreground hover:underline"
|
||
>
|
||
Cancel
|
||
</button>
|
||
{status.present && status.source === "file" && onDelete && (
|
||
<button
|
||
type="button"
|
||
onClick={() => void onDelete()}
|
||
disabled={busy}
|
||
data-testid={`${testid}-delete`}
|
||
className="shrink-0 text-xs text-muted-foreground/70 underline-offset-2 hover:text-red-500 hover:underline"
|
||
>
|
||
Delete
|
||
</button>
|
||
)}
|
||
</div>
|
||
{error && (
|
||
<p role="alert" data-testid={`${testid}-error`} className="px-1 text-xs text-red-500">
|
||
{error}
|
||
</p>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div data-testid={testid} className={envOwned ? "opacity-40" : undefined}>
|
||
<button
|
||
type="button"
|
||
aria-expanded={expanded}
|
||
data-testid={`${testid}-row`}
|
||
onClick={() => !envOwned && onToggle()}
|
||
className={`flex w-full items-center justify-between gap-3 rounded-sm px-1 py-1.5 text-left ${
|
||
envOwned ? "cursor-default" : "cursor-pointer transition-colors hover:bg-accent/50"
|
||
}`}
|
||
>
|
||
<span className="font-ui text-xs text-foreground">{label}</span>
|
||
<span className="flex items-center gap-2">
|
||
{docsUrl && (
|
||
<a
|
||
href={docsUrl}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
onClick={(e) => e.stopPropagation()}
|
||
className="text-[10px] text-muted-foreground/60 underline-offset-2 hover:text-foreground hover:underline"
|
||
>
|
||
key ↗
|
||
</a>
|
||
)}
|
||
<span data-testid={`${testid}-status`} className="font-mono text-[11px] text-muted-foreground">
|
||
{keyStatusLine(status)}
|
||
</span>
|
||
</span>
|
||
</button>
|
||
{error && (
|
||
<p role="alert" data-testid={`${testid}-error`} className="pl-6 text-xs text-red-500">
|
||
{error}
|
||
</p>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 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 [open, setOpen] = useState(false);
|
||
const anchorRef = useRef<HTMLDivElement>(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 (
|
||
<div ref={anchorRef} className="relative">
|
||
<button
|
||
type="button"
|
||
aria-expanded={open}
|
||
data-testid="add-provider"
|
||
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"
|
||
>
|
||
<span>+ Add a provider</span>
|
||
{addable.length > 0 && <span className="font-mono text-[11px]">{addable.length}</span>}
|
||
</button>
|
||
{open && (
|
||
<MenuShell
|
||
anchorRef={anchorRef}
|
||
testid="add-provider-menu"
|
||
preferredSide="down"
|
||
menuClass="flex w-72 flex-col rounded-xl border border-border bg-card p-2 text-xs shadow-xl"
|
||
>
|
||
{/* Close on pick (owner, fix wave): a chosen provider ends
|
||
the dropdown — the paste row is the next step. */}
|
||
<ProviderMenuList
|
||
providers={addable}
|
||
onPick={(p) => {
|
||
setOpen(false);
|
||
onPick(p);
|
||
}}
|
||
/>
|
||
</MenuShell>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 [statuses, setStatuses] = useState<AuthKeyStatus[] | null>(null);
|
||
const [providers, setProviders] = useState<ProviderKeyInfo[] | undefined>(undefined);
|
||
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<string | null>(initialRow ?? null);
|
||
/** The paste editor for a freshly chosen Add-provider target. */
|
||
const [adding, setAdding] = useState<ProviderKeyInfo | null>(null);
|
||
const [addError, setAddError] = useState<string | null>(null);
|
||
/** The custom endpoint's status + whether its connect editor is open. */
|
||
const [custom, setCustom] = useState<CustomStatus | null>(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"]')) 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 {
|
||
const cp = (await fetch("/api/custom-provider").then((r) => r.json())) as Partial<CustomStatus>;
|
||
setCustom({
|
||
baseUrl: cp.baseUrl ?? "",
|
||
models: cp.models ?? [],
|
||
overrides: cp.overrides ?? {},
|
||
keyPresent: Boolean(cp.keyPresent),
|
||
});
|
||
} 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);
|
||
}, [initialRow]);
|
||
|
||
async function saveKey(id: string, value: string): Promise<string | null> {
|
||
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<void> {
|
||
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(): Promise<void> {
|
||
await fetch("/api/custom-provider", { method: "DELETE" }).catch(() => undefined);
|
||
setAddingCustom(false);
|
||
await reload();
|
||
notifySettingsSaved("keys");
|
||
}
|
||
|
||
if (loading || statuses === null) {
|
||
return <p className="px-6 py-5 text-xs text-muted-foreground/70">Loading keys…</p>;
|
||
}
|
||
|
||
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 (
|
||
<>
|
||
<SettingsGroup label="Model API keys" testid="group-provider-keys">
|
||
{providerRows.map((status) => (
|
||
<KeyTableRow
|
||
key={status.id}
|
||
label={providerKeyLabel(status.id, providers) ?? status.info?.label ?? status.id}
|
||
docsUrl={status.info?.docsUrl}
|
||
status={status}
|
||
expanded={openRow === status.id}
|
||
onToggle={() => 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 && (
|
||
<CustomEndpointRow
|
||
status={custom}
|
||
expanded={addingCustom}
|
||
onToggle={() => setAddingCustom((v) => !v)}
|
||
/>
|
||
)}
|
||
{addingCustom && (
|
||
<CustomEndpointEditor
|
||
baseUrl0={custom?.baseUrl ?? ""}
|
||
keyPresent={custom?.keyPresent ?? false}
|
||
models={custom?.models ?? []}
|
||
overrides={custom?.overrides ?? {}}
|
||
onSaved={async () => {
|
||
setAddingCustom(false);
|
||
await reload();
|
||
notifySettingsSaved("keys");
|
||
}}
|
||
onPinsSaved={async () => {
|
||
await reload();
|
||
notifySettingsSaved("keys");
|
||
}}
|
||
onDelete={disconnectCustom}
|
||
onCancel={() => setAddingCustom(false)}
|
||
/>
|
||
)}
|
||
{providers !== undefined && (
|
||
<AddProviderRow
|
||
addable={custom?.baseUrl ? addable : [...addable, CUSTOM_PROVIDER_INFO]}
|
||
onPick={(p) => {
|
||
setAddError(null);
|
||
if (p.id === CUSTOM_PROVIDER.id) {
|
||
setAdding(null);
|
||
setAddingCustom(true);
|
||
} else {
|
||
setAddingCustom(false);
|
||
setAdding(p);
|
||
}
|
||
}}
|
||
/>
|
||
)}
|
||
{adding && (
|
||
<div className="flex items-center gap-2 rounded-sm px-1 py-1.5" data-testid={`key-${adding.id}-editor`}>
|
||
<span className="shrink-0 font-ui text-xs text-foreground">{adding.label}</span>
|
||
<AddProviderEditor provider={adding} onSave={(v) => void saveNewProvider(adding, v)} onCancel={() => setAdding(null)} error={addError} />
|
||
</div>
|
||
)}
|
||
{unknownRows.length > 0 && <div data-testid="group-unknown-keys">
|
||
{unknownRows.map((status) => <div key={status.id} data-testid={`unknown-${status.id}`} className="flex items-center justify-between gap-3 px-1 py-1.5 opacity-40">
|
||
<span className="font-mono text-xs text-foreground">{status.id}</span>
|
||
<span className="font-mono text-[11px] text-muted-foreground">{status.masked}</span>
|
||
</div>)}
|
||
</div>}
|
||
</SettingsGroup>
|
||
|
||
<SettingsGroup label="Subscriptions" testid="group-subscriptions">
|
||
<SubscriptionConnections />
|
||
</SettingsGroup>
|
||
|
||
{(["Dictation", "Search"] as const).map((label) => <SettingsGroup key={label} label={label} testid={label === "Dictation" ? "group-dictation" : "group-search"}>
|
||
{toolRows.filter((status) => label === "Dictation" ? status.id === "DEEPGRAM_API_KEY" : status.id !== "DEEPGRAM_API_KEY").map((status) => (
|
||
<KeyTableRow
|
||
key={status.id}
|
||
label={status.info?.label ?? status.id}
|
||
docsUrl={status.info?.docsUrl}
|
||
status={status}
|
||
expanded={openRow === status.id}
|
||
onToggle={() => 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" && <>
|
||
<DictationLanguageSetting />
|
||
<p data-testid="dictation-setup-hint" className="px-1 text-[11px] leading-relaxed text-muted-foreground/70">
|
||
Create a <a
|
||
href="https://console.deepgram.com/"
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="underline underline-offset-2 hover:text-foreground"
|
||
>Deepgram API key</a> with the Member role or higher, then add it above.
|
||
</p>
|
||
</>}
|
||
</SettingsGroup>)}
|
||
|
||
</>
|
||
);
|
||
}
|
||
|
||
/** 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 [value, setValue] = useState("");
|
||
return (
|
||
<>
|
||
<input
|
||
type="text"
|
||
value={value}
|
||
onChange={(e) => setValue(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" && value.trim()) onSave(value);
|
||
}}
|
||
placeholder="Paste key…"
|
||
aria-label={`${provider.label} key`}
|
||
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"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => value.trim() && onSave(value)}
|
||
disabled={!value.trim()}
|
||
data-testid={`key-${provider.id}-save`}
|
||
className="shrink-0 text-xs text-foreground underline-offset-2 hover:underline disabled:text-muted-foreground/40 disabled:no-underline"
|
||
>
|
||
Save
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={onCancel}
|
||
data-testid={`key-${provider.id}-cancel`}
|
||
className="shrink-0 text-xs text-muted-foreground/70 underline-offset-2 hover:text-foreground hover:underline"
|
||
>
|
||
Cancel
|
||
</button>
|
||
{error && (
|
||
<p role="alert" className="w-full pl-0 text-xs text-red-500" data-testid={`key-${provider.id}-error`}>
|
||
{error}
|
||
</p>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
/** 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 (
|
||
<div data-testid="custom-endpoint-row">
|
||
<button
|
||
type="button"
|
||
data-testid="custom-endpoint-row-btn"
|
||
aria-expanded={expanded}
|
||
onClick={onToggle}
|
||
className="flex w-full items-center justify-between gap-3 rounded-sm px-1 py-1.5 text-left cursor-pointer transition-colors hover:bg-accent/50"
|
||
>
|
||
<span className="font-ui text-xs text-foreground">Custom endpoint</span>
|
||
<span data-testid="custom-endpoint-status" className="max-w-[60%] truncate font-mono text-[11px] text-muted-foreground">
|
||
{line}
|
||
</span>
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 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<string, CustomModelOverride>,
|
||
): Record<string, PinDraft> {
|
||
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<string, PinDraft>): Record<string, CustomModelOverride> {
|
||
const out: Record<string, CustomModelOverride> = {};
|
||
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,
|
||
onSaved,
|
||
onPinsSaved,
|
||
onCancel,
|
||
onDelete,
|
||
}: {
|
||
baseUrl0: string;
|
||
keyPresent: boolean;
|
||
/** The discovered models — one limits row each. */
|
||
models: readonly CustomModel[];
|
||
/** Their stored user pins. */
|
||
overrides: Record<string, CustomModelOverride>;
|
||
onSaved: () => void | Promise<void>;
|
||
/** Pins saved — the editor stays open (a form, not a connect). */
|
||
onPinsSaved: () => void | Promise<void>;
|
||
onCancel: () => void;
|
||
onDelete?: () => void | Promise<void>;
|
||
}) {
|
||
const [baseUrl, setBaseUrl] = useState(baseUrl0);
|
||
const [key, setKey] = useState("");
|
||
const [keyTouched, setKeyTouched] = useState(false);
|
||
const [busy, setBusy] = useState(false);
|
||
const [error, setError] = useState<string | null>(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<Record<string, PinDraft>>(() => draftsFor(models, overrides));
|
||
const [baseline, setBaseline] = useState(() => JSON.stringify(pinsPayload(draftsFor(models, overrides))));
|
||
const [pinsBusy, setPinsBusy] = useState(false);
|
||
const [pinsError, setPinsError] = useState<string | null>(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<PinDraft>) =>
|
||
setDrafts((prev) => ({ ...prev, [m.id]: { ...draftOf(m), ...patch } }));
|
||
const current = Object.fromEntries(models.map((m) => [m.id, draftOf(m)])) as Record<string, PinDraft>;
|
||
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<string, CustomModelOverride> } | 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, ...(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 (
|
||
<div data-testid="custom-endpoint-editor" className="flex flex-col gap-2 rounded-sm px-1 py-1.5">
|
||
<div className="flex items-center gap-2">
|
||
<span className="shrink-0 font-ui text-xs text-foreground">Custom endpoint</span>
|
||
<input
|
||
type="text"
|
||
value={baseUrl}
|
||
onChange={(e) => 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"
|
||
/>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
type="text"
|
||
value={key}
|
||
onChange={(e) => {
|
||
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"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => void save()}
|
||
disabled={busy || !canConnect}
|
||
data-testid="custom-save"
|
||
className="shrink-0 text-xs text-foreground underline-offset-2 hover:underline disabled:text-muted-foreground/40 disabled:no-underline"
|
||
>
|
||
{busy ? "Connecting…" : "Connect"}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={onCancel}
|
||
disabled={busy}
|
||
data-testid="custom-cancel"
|
||
className="shrink-0 text-xs text-muted-foreground/70 underline-offset-2 hover:text-foreground hover:underline"
|
||
>
|
||
Cancel
|
||
</button>
|
||
{/* 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() && (
|
||
<button
|
||
type="button"
|
||
onClick={() => void onDelete()}
|
||
disabled={busy}
|
||
data-testid="custom-delete"
|
||
className="shrink-0 text-xs text-muted-foreground/70 underline-offset-2 hover:text-red-500 hover:underline"
|
||
>
|
||
Disconnect
|
||
</button>
|
||
)}
|
||
</div>
|
||
{models.length > 0 && (
|
||
<div className="flex flex-col gap-1" data-testid="custom-pins">
|
||
<span className="text-[11px] text-muted-foreground">
|
||
Model limits — blank uses the endpoint’s value, then the default
|
||
</span>
|
||
<div className="flex max-h-56 flex-col gap-1 overflow-y-auto pr-1">
|
||
{models.map((m) => {
|
||
const d = draftOf(m);
|
||
return (
|
||
<div key={m.id} className="flex items-center gap-2" data-testid={pinTestid("custom-pin-row", m.id)}>
|
||
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-foreground" title={m.id}>
|
||
{m.id}
|
||
</span>
|
||
<input
|
||
type="text"
|
||
inputMode="numeric"
|
||
value={d.context}
|
||
onChange={(e) => 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"
|
||
/>
|
||
<input
|
||
type="text"
|
||
inputMode="numeric"
|
||
value={d.maxTokens}
|
||
onChange={(e) => 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"
|
||
/>
|
||
<select
|
||
value={d.reasoning}
|
||
onChange={(e) => setDraft(m, { reasoning: e.target.value as PinDraft["reasoning"] })}
|
||
aria-label={`${m.id} thinking`}
|
||
data-testid={pinTestid("custom-pin-reasoning", m.id)}
|
||
disabled={pinsBusy}
|
||
className="h-7 shrink-0 rounded-sm border border-border bg-background px-1 font-ui text-[11px] text-foreground outline-none focus:border-muted-foreground/50"
|
||
>
|
||
<option value="">think: auto</option>
|
||
<option value="on">think: on</option>
|
||
<option value="off">think: off</option>
|
||
</select>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => void savePins()}
|
||
disabled={pinsBusy || !dirty}
|
||
data-testid="custom-pins-save"
|
||
className="shrink-0 text-xs text-foreground underline-offset-2 hover:underline disabled:text-muted-foreground/40 disabled:no-underline"
|
||
>
|
||
{pinsBusy ? "Saving…" : "Save limits"}
|
||
</button>
|
||
{pinsError && (
|
||
<p role="alert" data-testid="custom-pins-error" className="text-[11px] text-red-500">
|
||
{pinsError}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{needsKey && (
|
||
<p className="text-[11px] text-muted-foreground">
|
||
A key is required to enable chatting — local servers (Ollama, LM Studio) accept any value.
|
||
</p>
|
||
)}
|
||
{error && (
|
||
<p role="alert" data-testid="custom-error" className="text-xs text-red-500">
|
||
{error}
|
||
</p>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|