feat: custom endpoint as a first-class provider (add via dropdown, required key, vision ordering)
This commit is contained in:
@@ -39,25 +39,10 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const body = (await request.json().catch(() => null)) as {
|
const body = (await request.json().catch(() => null)) as { baseUrl?: unknown; key?: unknown } | null;
|
||||||
baseUrl?: unknown;
|
|
||||||
key?: unknown;
|
|
||||||
models?: unknown;
|
|
||||||
} | null;
|
|
||||||
const base = normalizeBaseUrl(typeof body?.baseUrl === "string" ? body.baseUrl : "");
|
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 });
|
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
|
// 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.
|
// field means "leave whatever is stored". Only a real key rides the probe.
|
||||||
const keyProvided = typeof body?.key === "string";
|
const keyProvided = typeof body?.key === "string";
|
||||||
|
|||||||
@@ -600,9 +600,10 @@ export function AgentSection({
|
|||||||
const chosen = coord ? catalog?.find((m) => m.id === coord.id && m.provider === coord.provider) : undefined;
|
const chosen = coord ? catalog?.find((m) => m.id === coord.id && m.provider === coord.provider) : undefined;
|
||||||
const levels = chosen ? chosen.supportedLevels : THINKING_LEVELS;
|
const levels = chosen ? chosen.supportedLevels : THINKING_LEVELS;
|
||||||
const thisMenuOpen = openMenu?.target === slot ? openMenu.kind : null;
|
const thisMenuOpen = openMenu?.target === slot ? openMenu.kind : null;
|
||||||
// Vision slots pick from image-capable models only.
|
// Vision slot: image-capable models float first, but all stay
|
||||||
const slotCatalog =
|
// selectable (a custom endpoint that doesn't report modality must
|
||||||
slot === "vision" ? catalog?.filter((m) => m.input.includes("image")) : catalog;
|
// remain pickable) — so this orders, never filters.
|
||||||
|
const slotCatalog = catalog;
|
||||||
return (
|
return (
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
key={slot}
|
key={slot}
|
||||||
@@ -650,6 +651,7 @@ export function AgentSection({
|
|||||||
models={slotCatalog ?? undefined}
|
models={slotCatalog ?? undefined}
|
||||||
effectiveId={modelId}
|
effectiveId={modelId}
|
||||||
effectiveProvider={provider}
|
effectiveProvider={provider}
|
||||||
|
prioritizeImage={slot === "vision"}
|
||||||
onPick={(entry) => {
|
onPick={(entry) => {
|
||||||
// Auto-populate (chunk 2, owner: "populate everywhere"): the
|
// Auto-populate (chunk 2, owner: "populate everywhere"): the
|
||||||
// fresh pick writes a level — the stored one rides as-is
|
// fresh pick writes a level — the stored one rides as-is
|
||||||
|
|||||||
@@ -45,12 +45,28 @@ interface KeysResponse {
|
|||||||
providers?: ProviderKeyInfo[];
|
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;
|
/** The curated tools group, pinned by id (the catalog owns the copy;
|
||||||
* these ids are the contract). Derived from the catalog (search wave,
|
* these ids are the contract). Derived from the catalog (search wave,
|
||||||
* 2026-09-20: the Google CSE pair retired, Tavily + Brave joined) — the
|
* 2026-09-20: the Google CSE pair retired, Tavily + Brave joined) — the
|
||||||
* section can never drift from core/auth-keys.ts again. */
|
* section can never drift from core/auth-keys.ts again. */
|
||||||
const TOOL_IDS = AUTH_KEYS.map((k) => k.id) as readonly string[];
|
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
|
/** The classification driving the table groups. Pure — exported for
|
||||||
* tests. OpenRouter is un-pinned (wave-5 owner ruling): no synthesis,
|
* tests. OpenRouter is un-pinned (wave-5 owner ruling): no synthesis,
|
||||||
* no special row — it rides the same provider classification. */
|
* no special row — it rides the same provider classification. */
|
||||||
@@ -303,7 +319,7 @@ function AddProviderRow({
|
|||||||
onClick={() => setOpen((v) => !v)}
|
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"
|
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 provider key</span>
|
<span>+ Add a provider</span>
|
||||||
{addable.length > 0 && <span className="font-mono text-[11px]">{addable.length}</span>}
|
{addable.length > 0 && <span className="font-mono text-[11px]">{addable.length}</span>}
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
@@ -346,13 +362,16 @@ export function KeysSection({
|
|||||||
/** The paste editor for a freshly chosen Add-provider target. */
|
/** The paste editor for a freshly chosen Add-provider target. */
|
||||||
const [adding, setAdding] = useState<ProviderKeyInfo | null>(null);
|
const [adding, setAdding] = useState<ProviderKeyInfo | null>(null);
|
||||||
const [addError, setAddError] = useState<string | 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)
|
// Escape folds any open editor (unfolded row or the Add-provider paste)
|
||||||
// WITHOUT closing the dialog. Claimed at document CAPTURE phase — the
|
// WITHOUT closing the dialog. Claimed at document CAPTURE phase — the
|
||||||
// only stopPropagation that outruns both the input's React handler and
|
// only stopPropagation that outruns both the input's React handler and
|
||||||
// the dialog's window-level listener.
|
// the dialog's window-level listener.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (openRow === null && adding === null) return;
|
if (openRow === null && adding === null && !addingCustom) return;
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key !== "Escape") return;
|
if (e.key !== "Escape") return;
|
||||||
// A language dropdown above this editor in the dismissal stack owns
|
// A language dropdown above this editor in the dismissal stack owns
|
||||||
@@ -362,20 +381,35 @@ export function KeysSection({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setOpenRow(null);
|
setOpenRow(null);
|
||||||
setAdding(null);
|
setAdding(null);
|
||||||
|
setAddingCustom(false);
|
||||||
};
|
};
|
||||||
document.addEventListener("keydown", onKeyDown, true);
|
document.addEventListener("keydown", onKeyDown, true);
|
||||||
return () => document.removeEventListener("keydown", onKeyDown, true);
|
return () => document.removeEventListener("keydown", onKeyDown, true);
|
||||||
}, [openRow, adding]);
|
}, [openRow, adding, addingCustom]);
|
||||||
|
|
||||||
const reload = () =>
|
const reload = async () => {
|
||||||
fetch("/api/keys")
|
try {
|
||||||
.then((r) => r.json())
|
const body = (await fetch("/api/keys").then((r) => r.json())) as KeysResponse;
|
||||||
.then((body: KeysResponse) => {
|
|
||||||
setStatuses(body.keys ?? []);
|
setStatuses(body.keys ?? []);
|
||||||
if (body.providers) setProviders(body.providers);
|
if (body.providers) setProviders(body.providers);
|
||||||
})
|
} catch {
|
||||||
.catch(() => setStatuses([]))
|
setStatuses([]);
|
||||||
.finally(() => setLoading(false));
|
} 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 ?? [],
|
||||||
|
keyPresent: Boolean(cp.keyPresent),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* custom status unavailable — leave it unset */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void reload();
|
void reload();
|
||||||
@@ -448,16 +482,52 @@ export function KeysSection({
|
|||||||
testid={`key-${status.id.toLowerCase()}`}
|
testid={`key-${status.id.toLowerCase()}`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{custom?.baseUrl && (
|
||||||
|
<CustomEndpointRow
|
||||||
|
status={custom}
|
||||||
|
expanded={addingCustom}
|
||||||
|
onToggle={() => setAddingCustom((v) => !v)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{custom?.baseUrl && addingCustom && (
|
||||||
|
<CustomEndpointEditor
|
||||||
|
baseUrl0={custom.baseUrl}
|
||||||
|
keyPresent={custom.keyPresent}
|
||||||
|
onSaved={async () => {
|
||||||
|
setAddingCustom(false);
|
||||||
|
await reload();
|
||||||
|
notifySettingsSaved("keys");
|
||||||
|
}}
|
||||||
|
onCancel={() => setAddingCustom(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{providers !== undefined && (
|
{providers !== undefined && (
|
||||||
<AddProviderRow
|
<AddProviderRow
|
||||||
addable={addable}
|
addable={custom?.baseUrl ? addable : [...addable, CUSTOM_PROVIDER_INFO]}
|
||||||
onPick={(p) => {
|
onPick={(p) => {
|
||||||
setAdding(p);
|
|
||||||
setAddError(null);
|
setAddError(null);
|
||||||
|
if (p.id === CUSTOM_PROVIDER.id) {
|
||||||
|
setAdding(null);
|
||||||
|
setAddingCustom(true);
|
||||||
|
} else {
|
||||||
|
setAddingCustom(false);
|
||||||
|
setAdding(p);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<CustomProviderRow />
|
{!custom?.baseUrl && addingCustom && (
|
||||||
|
<CustomEndpointEditor
|
||||||
|
baseUrl0=""
|
||||||
|
keyPresent={false}
|
||||||
|
onSaved={async () => {
|
||||||
|
setAddingCustom(false);
|
||||||
|
await reload();
|
||||||
|
notifySettingsSaved("keys");
|
||||||
|
}}
|
||||||
|
onCancel={() => setAddingCustom(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{adding && (
|
{adding && (
|
||||||
<div className="flex items-center gap-2 rounded-sm px-1 py-1.5" data-testid={`key-${adding.id}-editor`}>
|
<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>
|
<span className="shrink-0 font-ui text-xs text-foreground">{adding.label}</span>
|
||||||
@@ -567,31 +637,65 @@ function AddProviderEditor({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The custom OpenAI-compatible endpoint's row in the provider list — one more
|
/** The custom endpoint's status row in the provider list — one more provider,
|
||||||
* provider, connected by a base URL + (optional) key exactly like the built-ins
|
* shown only once it's configured (exactly like a built-in appears only once
|
||||||
* are connected by a key (the URL is the one extra field). Its status reads
|
* its key is stored). Clicking re-opens the connect editor. */
|
||||||
* GET /api/custom-provider; Connect POSTs the connect (validate + discover
|
function CustomEndpointRow({
|
||||||
* models + register) and dispatches the keys signal so the catalog widens with
|
status,
|
||||||
* no restart. Keyless local servers (Ollama / LM Studio) connect with no key. */
|
expanded,
|
||||||
function CustomProviderRow() {
|
onToggle,
|
||||||
const [status, setStatus] = useState<{ baseUrl: string; models: CustomModel[]; keyPresent: boolean } | null>(null);
|
}: {
|
||||||
const [expanded, setExpanded] = useState(false);
|
status: CustomStatus;
|
||||||
const [baseUrl, setBaseUrl] = useState("");
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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<void>;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
const [baseUrl, setBaseUrl] = useState(baseUrl0);
|
||||||
const [key, setKey] = useState("");
|
const [key, setKey] = useState("");
|
||||||
const [keyTouched, setKeyTouched] = useState(false);
|
const [keyTouched, setKeyTouched] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
// A key is required to enable chatting (the send gate needs a provider key);
|
||||||
const reload = () =>
|
// local servers accept any value. When one is already stored, leaving the
|
||||||
fetch("/api/custom-provider")
|
// field blank keeps it.
|
||||||
.then((r) => r.json())
|
const needsKey = !keyPresent;
|
||||||
.then((b: { baseUrl?: string; models?: CustomModel[]; keyPresent?: boolean }) =>
|
const canConnect = Boolean(baseUrl.trim()) && (!needsKey || Boolean(key.trim()));
|
||||||
setStatus({ baseUrl: b.baseUrl ?? "", models: b.models ?? [], keyPresent: Boolean(b.keyPresent) }),
|
|
||||||
)
|
|
||||||
.catch(() => setStatus({ baseUrl: "", models: [], keyPresent: false }));
|
|
||||||
useEffect(() => {
|
|
||||||
void reload();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
if (busy || !baseUrl.trim()) return;
|
if (busy || !baseUrl.trim()) return;
|
||||||
@@ -608,38 +712,9 @@ function CustomProviderRow() {
|
|||||||
setError(res ? (b?.error ?? `Connect failed (${res.status})`) : "Connect failed. Check your connection.");
|
setError(res ? (b?.error ?? `Connect failed (${res.status})`) : "Connect failed. Check your connection.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setExpanded(false);
|
await onSaved();
|
||||||
setKey("");
|
|
||||||
setKeyTouched(false);
|
|
||||||
await reload();
|
|
||||||
notifySettingsSaved("keys");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flip one model's image capability and persist it (capability update — no
|
|
||||||
// re-discovery, no key change). Uses the CONNECTED base URL (status.baseUrl),
|
|
||||||
// not the editor's possibly-edited field, so toggles always apply to the
|
|
||||||
// endpoint whose models are listed.
|
|
||||||
async function toggleImage(id: string) {
|
|
||||||
if (!status?.baseUrl || busy) return;
|
|
||||||
const models = status.models.map((m) => (m.id === id ? { ...m, image: !m.image } : m));
|
|
||||||
setBusy(true);
|
|
||||||
setError(null);
|
|
||||||
const res = await fetch("/api/custom-provider", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
body: JSON.stringify({ baseUrl: status.baseUrl, models }),
|
|
||||||
}).catch(() => null);
|
|
||||||
setBusy(false);
|
|
||||||
if (!res || !res.ok) {
|
|
||||||
const b = (await res?.json().catch(() => ({}))) as { error?: string } | undefined;
|
|
||||||
setError(res ? (b?.error ?? `Save failed (${res.status})`) : "Save failed. Check your connection.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await reload();
|
|
||||||
notifySettingsSaved("keys");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (expanded) {
|
|
||||||
return (
|
return (
|
||||||
<div data-testid="custom-endpoint-editor" className="flex flex-col gap-2 rounded-sm px-1 py-1.5">
|
<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">
|
<div className="flex items-center gap-2">
|
||||||
@@ -664,7 +739,7 @@ function CustomProviderRow() {
|
|||||||
setKey(e.target.value);
|
setKey(e.target.value);
|
||||||
setKeyTouched(true);
|
setKeyTouched(true);
|
||||||
}}
|
}}
|
||||||
placeholder={status?.keyPresent ? "Key set — paste to replace, empty to clear" : "API key (optional for local servers)"}
|
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"
|
aria-label="Custom endpoint API key"
|
||||||
data-testid="custom-key-input"
|
data-testid="custom-key-input"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
@@ -673,7 +748,7 @@ function CustomProviderRow() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void save()}
|
onClick={() => void save()}
|
||||||
disabled={busy || !baseUrl.trim()}
|
disabled={busy || !canConnect}
|
||||||
data-testid="custom-save"
|
data-testid="custom-save"
|
||||||
className="shrink-0 text-xs text-foreground underline-offset-2 hover:underline disabled:text-muted-foreground/40 disabled:no-underline"
|
className="shrink-0 text-xs text-foreground underline-offset-2 hover:underline disabled:text-muted-foreground/40 disabled:no-underline"
|
||||||
>
|
>
|
||||||
@@ -681,7 +756,7 @@ function CustomProviderRow() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setExpanded(false)}
|
onClick={onCancel}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
data-testid="custom-cancel"
|
data-testid="custom-cancel"
|
||||||
className="shrink-0 text-xs text-muted-foreground/70 underline-offset-2 hover:text-foreground hover:underline"
|
className="shrink-0 text-xs text-muted-foreground/70 underline-offset-2 hover:text-foreground hover:underline"
|
||||||
@@ -689,25 +764,10 @@ function CustomProviderRow() {
|
|||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{status && status.models.length > 0 && (
|
{needsKey && (
|
||||||
<div className="flex flex-col gap-1 border-t border-border/40 pt-2">
|
<p className="text-[11px] text-muted-foreground">
|
||||||
<span className="text-[11px] text-muted-foreground">
|
A key is required to enable chatting — local servers (Ollama, LM Studio) accept any value.
|
||||||
Models — check the ones that accept images (needed for the Vision slot)
|
</p>
|
||||||
</span>
|
|
||||||
{status.models.map((m) => (
|
|
||||||
<label key={m.id} className="flex items-center gap-2 text-xs">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={Boolean(m.image)}
|
|
||||||
disabled={busy}
|
|
||||||
onChange={() => void toggleImage(m.id)}
|
|
||||||
data-testid={`custom-image-${m.id.replace(/[^a-z0-9-]/gi, "_")}`}
|
|
||||||
/>
|
|
||||||
<span className="font-mono text-foreground">{m.id}</span>
|
|
||||||
<span className="text-muted-foreground">images</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{error && (
|
{error && (
|
||||||
<p role="alert" data-testid="custom-error" className="text-xs text-red-500">
|
<p role="alert" data-testid="custom-error" className="text-xs text-red-500">
|
||||||
@@ -717,29 +777,3 @@ function CustomProviderRow() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (
|
|
||||||
<div data-testid="custom-endpoint-row">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-testid="custom-endpoint-row-btn"
|
|
||||||
onClick={() => {
|
|
||||||
setBaseUrl(status?.baseUrl ?? "");
|
|
||||||
setExpanded(true);
|
|
||||||
}}
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export function ModelMenuList({
|
|||||||
effectiveProvider,
|
effectiveProvider,
|
||||||
onPick,
|
onPick,
|
||||||
footer,
|
footer,
|
||||||
|
prioritizeImage,
|
||||||
}: {
|
}: {
|
||||||
/** The full catalog — undefined while it loads. */
|
/** The full catalog — undefined while it loads. */
|
||||||
models: ModelCatalogEntry[] | undefined;
|
models: ModelCatalogEntry[] | undefined;
|
||||||
@@ -53,6 +54,10 @@ export function ModelMenuList({
|
|||||||
onPick: (entry: ModelCatalogEntry) => void;
|
onPick: (entry: ModelCatalogEntry) => void;
|
||||||
/** Quiet footer note under the list (context-specific). */
|
/** Quiet footer note under the list (context-specific). */
|
||||||
footer?: string;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -61,6 +66,7 @@ export function ModelMenuList({
|
|||||||
itemKey={(m) => `${m.provider}/${m.id}`}
|
itemKey={(m) => `${m.provider}/${m.id}`}
|
||||||
searchTerms={(m) => [m.provider, m.id, m.name]}
|
searchTerms={(m) => [m.provider, m.id, m.name]}
|
||||||
compare={(a, b) => a.id.localeCompare(b.id)}
|
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)}
|
isSelected={(m) => m.id === effectiveId && m.provider === (effectiveProvider ?? m.provider)}
|
||||||
onPick={onPick}
|
onPick={onPick}
|
||||||
searchTestid="model-search"
|
searchTestid="model-search"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { fuzzyScore } from "@/src/core";
|
|||||||
* Models and dictation languages supply only their catalog and row content.
|
* Models and dictation languages supply only their catalog and row content.
|
||||||
* The caller owns the shared MenuShell and dismissal behavior. */
|
* The caller owns the shared MenuShell and dismissal behavior. */
|
||||||
export function SearchMenuList<T>({
|
export function SearchMenuList<T>({
|
||||||
items, itemKey, searchTerms, compare, isSelected, onPick, renderItem,
|
items, itemKey, searchTerms, compare, prioritize, isSelected, onPick, renderItem,
|
||||||
searchTestid, listTestid, optionTestid, placeholder, ariaLabel, loading, empty,
|
searchTestid, listTestid, optionTestid, placeholder, ariaLabel, loading, empty,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}: {
|
}: {
|
||||||
@@ -15,6 +15,10 @@ export function SearchMenuList<T>({
|
|||||||
itemKey: (item: T) => string;
|
itemKey: (item: T) => string;
|
||||||
searchTerms: (item: T) => string[];
|
searchTerms: (item: T) => string[];
|
||||||
compare?: (a: T, b: T) => number;
|
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;
|
isSelected: (item: T) => boolean;
|
||||||
onPick: (item: T) => void;
|
onPick: (item: T) => void;
|
||||||
renderItem: (item: T) => ReactNode;
|
renderItem: (item: T) => ReactNode;
|
||||||
@@ -41,7 +45,12 @@ export function SearchMenuList<T>({
|
|||||||
const results = (items ?? [])
|
const results = (items ?? [])
|
||||||
.map((item) => ({ item, score: fuzzyScore(query, ...searchTerms(item)) }))
|
.map((item) => ({ item, score: fuzzyScore(query, ...searchTerms(item)) }))
|
||||||
.filter((result) => result.score !== null)
|
.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);
|
.map(({ item }) => item);
|
||||||
|
|
||||||
return <>
|
return <>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
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 { classifyKeys, type ProviderKeyInfo } from "@/components/settings/keys-section";
|
||||||
import { useModelCatalog } from "@/components/model-catalog";
|
import { useModelCatalog } from "@/components/model-catalog";
|
||||||
import type { ModelCatalogResponse } from "@/src/core";
|
import type { ModelCatalogResponse } from "@/src/core";
|
||||||
@@ -30,7 +30,13 @@ export function hasProviderKey(
|
|||||||
statuses: readonly AuthKeyStatus[],
|
statuses: readonly AuthKeyStatus[],
|
||||||
providers: readonly ProviderKeyInfo[] | undefined,
|
providers: readonly ProviderKeyInfo[] | undefined,
|
||||||
): boolean {
|
): 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. */
|
/** Completeness is saved identity, not current provider availability. */
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ const SLOT_META: Record<ModelSlotName, { label: string; roster: string }> = {
|
|||||||
const SLOT_ORDER: readonly ModelSlotName[] = ["strong", "cheap", "vision"];
|
const SLOT_ORDER: readonly ModelSlotName[] = ["strong", "cheap", "vision"];
|
||||||
|
|
||||||
/** The custom endpoint's synthetic picker entry — one more provider in the
|
/** 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. */
|
* /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 };
|
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). */
|
/** The MenuShell anchor — wired by the wrapper (its triggerRef). */
|
||||||
menuAnchor: React.RefObject<HTMLDivElement | null>;
|
menuAnchor: React.RefObject<HTMLDivElement | null>;
|
||||||
/** The chosen provider is the custom endpoint: a base URL field joins the
|
/** 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. */
|
* optional so the plain-key callers (and tests) stay unchanged. */
|
||||||
isCustom?: boolean;
|
isCustom?: boolean;
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
@@ -218,9 +218,9 @@ export function SetupKeysBody({
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onValue(e.target.value)}
|
onChange={(e) => onValue(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
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`}
|
aria-label={`${provider.label} key`}
|
||||||
data-testid="setup-key-input"
|
data-testid="setup-key-input"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
@@ -230,7 +230,7 @@ export function SetupKeysBody({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onSave}
|
onClick={onSave}
|
||||||
disabled={saving || (isCustom ? !baseUrl?.trim() : !value.trim())}
|
disabled={saving || !value.trim()}
|
||||||
data-testid="setup-key-save"
|
data-testid="setup-key-save"
|
||||||
className="shrink-0 rounded-sm border border-border bg-background px-3 py-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 disabled:cursor-not-allowed disabled:opacity-40"
|
className="shrink-0 rounded-sm border border-border bg-background px-3 py-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
>
|
>
|
||||||
@@ -405,7 +405,7 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) {
|
|||||||
async function saveKey() {
|
async function saveKey() {
|
||||||
if (!keyProvider || keySaving) return;
|
if (!keyProvider || keySaving) return;
|
||||||
const isCustom = keyProvider.id === CUSTOM_PROVIDER.id;
|
const isCustom = keyProvider.id === CUSTOM_PROVIDER.id;
|
||||||
if (!isCustom && !keyValue.trim()) return;
|
if (!keyValue.trim()) return;
|
||||||
setKeySaving(true);
|
setKeySaving(true);
|
||||||
setKeyError(null);
|
setKeyError(null);
|
||||||
// The custom endpoint connects through its own route (base URL + key +
|
// The custom endpoint connects through its own route (base URL + key +
|
||||||
@@ -580,6 +580,7 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) {
|
|||||||
models={slotCatalog}
|
models={slotCatalog}
|
||||||
effectiveId={entry?.id}
|
effectiveId={entry?.id}
|
||||||
effectiveProvider={entry?.provider}
|
effectiveProvider={entry?.provider}
|
||||||
|
prioritizeImage={slot === "vision"}
|
||||||
onPick={(m) => {
|
onPick={(m) => {
|
||||||
editedSlots.current.add(slot);
|
editedSlots.current.add(slot);
|
||||||
setPicked((prev) => ({ ...prev, [slot]: m }));
|
setPicked((prev) => ({ ...prev, [slot]: m }));
|
||||||
|
|||||||
@@ -172,10 +172,13 @@ describe("setup polish interactions", () => {
|
|||||||
assert.equal(closed, 0);
|
assert.equal(closed, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("the empty vision list gives the approved next action without an em dash", async () => {
|
it("the vision slot lists unknown-capability models (image-capable first, nothing hidden)", async () => {
|
||||||
await mount({ keyed: true });
|
await mount({ keyed: true });
|
||||||
await click(element('[data-testid="setup-trigger-vision"]'));
|
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 fixture stays selectable in the vision slot: the slot
|
||||||
|
// orders image-capable first but no longer filters, so a custom endpoint
|
||||||
|
// that reports no modality can still be picked for Vision.
|
||||||
|
assert.ok(element('[data-testid="setup-menu-vision"]').textContent?.includes("fixture-text"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("catalog failure uses the approved two-sentence error", async () => {
|
it("catalog failure uses the approved two-sentence error", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user