Merge PR #62 contribution for donor-led integration
This commit is contained in:
@@ -600,9 +600,6 @@ export function AgentSection({
|
||||
const chosen = coord ? catalog?.find((m) => m.id === coord.id && m.provider === coord.provider) : undefined;
|
||||
const levels = chosen ? chosen.supportedLevels : THINKING_LEVELS;
|
||||
const thisMenuOpen = openMenu?.target === slot ? openMenu.kind : null;
|
||||
// Vision slots pick from image-capable models only.
|
||||
const slotCatalog =
|
||||
slot === "vision" ? catalog?.filter((m) => m.input.includes("image")) : catalog;
|
||||
return (
|
||||
<SettingsRow
|
||||
key={slot}
|
||||
@@ -647,9 +644,10 @@ export function AgentSection({
|
||||
menuClass="flex w-[22rem] flex-col rounded-xl border border-border bg-card p-2 text-xs shadow-xl"
|
||||
>
|
||||
<ModelMenuList
|
||||
models={slotCatalog ?? undefined}
|
||||
models={catalog ?? undefined}
|
||||
effectiveId={modelId}
|
||||
effectiveProvider={provider}
|
||||
vision={slot === "vision"}
|
||||
onPick={(entry) => {
|
||||
// Auto-populate (chunk 2, owner: "populate everywhere"): the
|
||||
// fresh pick writes a level — the stored one rides as-is
|
||||
|
||||
@@ -24,7 +24,15 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { AUTH_KEYS, type AuthKeyStatus } from "@/src/core";
|
||||
import {
|
||||
AUTH_KEYS,
|
||||
CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW,
|
||||
CUSTOM_MODEL_DEFAULT_MAX_TOKENS,
|
||||
CUSTOM_PROVIDER,
|
||||
type AuthKeyStatus,
|
||||
type CustomModel,
|
||||
type CustomModelOverride,
|
||||
} from "@/src/core";
|
||||
import { SettingsGroup } from "./controls.tsx";
|
||||
import { SubscriptionConnections } from "./subscription-connections.tsx";
|
||||
import { ClaudeCodeInstallationStatus } from "./claude-code-status.tsx";
|
||||
@@ -46,12 +54,29 @@ interface KeysResponse {
|
||||
providers?: ProviderKeyInfo[];
|
||||
}
|
||||
|
||||
/** The custom endpoint's non-secret status (GET /api/custom-provider). */
|
||||
interface CustomStatus {
|
||||
baseUrl: string;
|
||||
models: CustomModel[];
|
||||
/** User pins by model id (context / max output / thinking). */
|
||||
overrides: Record<string, CustomModelOverride>;
|
||||
manualModels: string[];
|
||||
keyPresent: boolean;
|
||||
}
|
||||
|
||||
/** The curated tools group, pinned by id (the catalog owns the copy;
|
||||
* these ids are the contract). Derived from the catalog (search wave,
|
||||
* 2026-09-20: the Google CSE pair retired, Tavily + Brave joined) — the
|
||||
* section can never drift from core/auth-keys.ts again. */
|
||||
const TOOL_IDS = AUTH_KEYS.map((k) => k.id) as readonly string[];
|
||||
|
||||
/** The custom endpoint's synthetic Add-provider entry (offered only while unconfigured). */
|
||||
export const CUSTOM_PROVIDER_INFO: ProviderKeyInfo = {
|
||||
id: CUSTOM_PROVIDER.id,
|
||||
envVar: CUSTOM_PROVIDER.envVar,
|
||||
label: CUSTOM_PROVIDER.label,
|
||||
};
|
||||
|
||||
/** The classification driving the table groups. Pure — exported for
|
||||
* tests. OpenRouter is un-pinned (wave-5 owner ruling): no synthesis,
|
||||
* no special row — it rides the same provider classification. */
|
||||
@@ -84,6 +109,10 @@ export function classifyKeys(
|
||||
const unknownRows: AuthKeyStatus[] = [];
|
||||
for (const s of statuses) {
|
||||
if (isTool(s.id)) continue;
|
||||
// The custom endpoint's key has its OWN home (the Custom endpoint
|
||||
// section), so it never shows here — not as a provider row, not as an
|
||||
// "other stored variable".
|
||||
if (s.id === CUSTOM_PROVIDER.envVar) continue;
|
||||
if (providerEnv.has(s.id) || (!s.info && providers === undefined)) providerRows.push(s);
|
||||
else if (!s.info) unknownRows.push(s);
|
||||
// (pre-interlude catalogs: a curated-but-unclassified status still
|
||||
@@ -300,7 +329,7 @@ function AddProviderRow({
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full items-center justify-between gap-3 rounded-sm px-1 py-1.5 text-left font-ui text-xs text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||
>
|
||||
<span>+ Add provider key</span>
|
||||
<span>+ Add a provider</span>
|
||||
{addable.length > 0 && <span className="font-mono text-[11px]">{addable.length}</span>}
|
||||
</button>
|
||||
{open && (
|
||||
@@ -343,13 +372,16 @@ export function KeysSection({
|
||||
/** 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) return;
|
||||
if (openRow === null && adding === null && !addingCustom) return;
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Escape") return;
|
||||
// A language dropdown above this editor in the dismissal stack owns
|
||||
@@ -359,20 +391,37 @@ export function KeysSection({
|
||||
e.stopPropagation();
|
||||
setOpenRow(null);
|
||||
setAdding(null);
|
||||
setAddingCustom(false);
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown, true);
|
||||
return () => document.removeEventListener("keydown", onKeyDown, true);
|
||||
}, [openRow, adding]);
|
||||
}, [openRow, adding, addingCustom]);
|
||||
|
||||
const reload = () =>
|
||||
fetch("/api/keys")
|
||||
.then((r) => r.json())
|
||||
.then((body: KeysResponse) => {
|
||||
setStatuses(body.keys ?? []);
|
||||
if (body.providers) setProviders(body.providers);
|
||||
})
|
||||
.catch(() => setStatuses([]))
|
||||
.finally(() => setLoading(false));
|
||||
const reload = async () => {
|
||||
try {
|
||||
const body = (await fetch("/api/keys").then((r) => r.json())) as KeysResponse;
|
||||
setStatuses(body.keys ?? []);
|
||||
if (body.providers) setProviders(body.providers);
|
||||
} catch {
|
||||
setStatuses([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
// Best-effort and independent: the custom endpoint's status must never
|
||||
// blank the provider-key list above if its fetch fails.
|
||||
try {
|
||||
const cp = (await fetch("/api/custom-provider").then((r) => r.json())) as Partial<CustomStatus>;
|
||||
setCustom({
|
||||
baseUrl: cp.baseUrl ?? "",
|
||||
models: cp.models ?? [],
|
||||
overrides: cp.overrides ?? {},
|
||||
manualModels: cp.manualModels ?? [],
|
||||
keyPresent: Boolean(cp.keyPresent),
|
||||
});
|
||||
} catch {
|
||||
/* custom status unavailable — leave it unset */
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
@@ -409,6 +458,15 @@ export function KeysSection({
|
||||
notifySettingsSaved("keys");
|
||||
}
|
||||
|
||||
/** Forget the custom endpoint (config + key server-side), same rhythm as
|
||||
* deleteKey: fire, re-read, broadcast so the catalog narrows everywhere. */
|
||||
async function disconnectCustom(): Promise<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>;
|
||||
}
|
||||
@@ -445,12 +503,45 @@ export function KeysSection({
|
||||
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 ?? {}}
|
||||
manualModels0={custom?.manualModels ?? []}
|
||||
onSaved={async () => {
|
||||
setAddingCustom(false);
|
||||
await reload();
|
||||
notifySettingsSaved("keys");
|
||||
}}
|
||||
onPinsSaved={async () => {
|
||||
await reload();
|
||||
notifySettingsSaved("keys");
|
||||
}}
|
||||
onDelete={disconnectCustom}
|
||||
onCancel={() => setAddingCustom(false)}
|
||||
/>
|
||||
)}
|
||||
{providers !== undefined && (
|
||||
<AddProviderRow
|
||||
addable={addable}
|
||||
addable={custom?.baseUrl ? addable : [...addable, CUSTOM_PROVIDER_INFO]}
|
||||
onPick={(p) => {
|
||||
setAdding(p);
|
||||
setAddError(null);
|
||||
if (p.id === CUSTOM_PROVIDER.id) {
|
||||
setAdding(null);
|
||||
setAddingCustom(true);
|
||||
} else {
|
||||
setAddingCustom(false);
|
||||
setAdding(p);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -566,3 +657,320 @@ function AddProviderEditor({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** The custom endpoint's status row (shown once configured); clicking re-opens the editor. */
|
||||
function CustomEndpointRow({
|
||||
status,
|
||||
expanded,
|
||||
onToggle,
|
||||
}: {
|
||||
status: CustomStatus;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const count = status.models.length;
|
||||
const line = `${status.baseUrl} · ${count} model${count === 1 ? "" : "s"}${status.keyPresent ? "" : " · keyless"}`;
|
||||
return (
|
||||
<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,
|
||||
manualModels0,
|
||||
onSaved,
|
||||
onPinsSaved,
|
||||
onCancel,
|
||||
onDelete,
|
||||
}: {
|
||||
baseUrl0: string;
|
||||
keyPresent: boolean;
|
||||
/** The discovered models — one limits row each. */
|
||||
models: readonly CustomModel[];
|
||||
/** Their stored user pins. */
|
||||
overrides: Record<string, CustomModelOverride>;
|
||||
manualModels0: readonly string[];
|
||||
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 [manualModels, setManualModels] = useState(manualModels0.join(", "));
|
||||
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, manualModels, ...(keyTouched ? { key } : {}) }),
|
||||
}).catch(() => null);
|
||||
setBusy(false);
|
||||
if (!res || !res.ok) {
|
||||
const b = (await res?.json().catch(() => ({}))) as { error?: string } | undefined;
|
||||
setError(res ? (b?.error ?? `Connect failed (${res.status})`) : "Connect failed. Check your connection.");
|
||||
return;
|
||||
}
|
||||
await onSaved();
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
<input
|
||||
type="text"
|
||||
value={manualModels}
|
||||
onChange={(e) => setManualModels(e.target.value)}
|
||||
placeholder="Model IDs, comma-separated — optional, needed when the endpoint has no /models list"
|
||||
aria-label="Custom endpoint model IDs"
|
||||
data-testid="custom-models-input"
|
||||
disabled={busy}
|
||||
className="h-7 min-w-0 rounded-sm border border-border bg-background px-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50"
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ModelCatalogEntry } from "@/src/core";
|
||||
import { CLAUDE_CODE_PROVIDER, fuzzyScore, subscriptionLabel } from "@/src/core";
|
||||
import { CLAUDE_CODE_PROVIDER, CUSTOM_PROVIDER, fuzzyScore, subscriptionLabel } from "@/src/core";
|
||||
import { formatTokens } from "@/components/usage-format";
|
||||
import { SearchMenuList } from "./search-menu-list";
|
||||
|
||||
@@ -40,6 +40,7 @@ export function ModelMenuList({
|
||||
effectiveProvider,
|
||||
onPick,
|
||||
footer,
|
||||
vision,
|
||||
}: {
|
||||
/** The full catalog — undefined while it loads. */
|
||||
models: ModelCatalogEntry[] | undefined;
|
||||
@@ -54,14 +55,25 @@ export function ModelMenuList({
|
||||
onPick: (entry: ModelCatalogEntry) => void;
|
||||
/** Quiet footer note under the list (context-specific). */
|
||||
footer?: string;
|
||||
/** Vision slot: only image-capable models are selectable — plus every
|
||||
* custom-endpoint model, whose capability the endpoint may not report.
|
||||
* Image-capable ones float to the top. */
|
||||
vision?: boolean;
|
||||
}) {
|
||||
// Vision slot: built-ins must report image input; custom-endpoint models
|
||||
// stay selectable (the endpoint may not report a modality). Image-capable
|
||||
// ones float to the top of the list.
|
||||
const visionItems = vision
|
||||
? models?.filter((m) => m.input.includes("image") || m.provider === CUSTOM_PROVIDER.id)
|
||||
: models;
|
||||
return (
|
||||
<>
|
||||
<SearchMenuList
|
||||
items={models}
|
||||
items={visionItems}
|
||||
itemKey={(m) => `${m.provider}/${m.id}`}
|
||||
searchTerms={(m) => [m.provider, m.id, m.name]}
|
||||
compare={(a, b) => a.id.localeCompare(b.id)}
|
||||
prioritize={vision ? (m) => m.input.includes("image") : undefined}
|
||||
isSelected={(m) => m.id === effectiveId && m.provider === (effectiveProvider ?? m.provider)}
|
||||
onPick={onPick}
|
||||
searchTestid="model-search"
|
||||
|
||||
@@ -7,7 +7,7 @@ import { fuzzyScore } from "@/src/core";
|
||||
* Models and dictation languages supply only their catalog and row content.
|
||||
* The caller owns the shared MenuShell and dismissal behavior. */
|
||||
export function SearchMenuList<T>({
|
||||
items, itemKey, searchTerms, compare, isSelected, onPick, renderItem,
|
||||
items, itemKey, searchTerms, compare, prioritize, isSelected, onPick, renderItem,
|
||||
searchTestid, listTestid, optionTestid, placeholder, ariaLabel, loading, empty,
|
||||
disabled = false,
|
||||
}: {
|
||||
@@ -15,6 +15,10 @@ export function SearchMenuList<T>({
|
||||
itemKey: (item: T) => string;
|
||||
searchTerms: (item: T) => string[];
|
||||
compare?: (a: T, b: T) => number;
|
||||
/** Optional grouping key: items it answers true for sort ahead of the
|
||||
* rest, fuzzy rank still deciding within each group (a stable partition,
|
||||
* never a filter — nothing is hidden). */
|
||||
prioritize?: (item: T) => boolean;
|
||||
isSelected: (item: T) => boolean;
|
||||
onPick: (item: T) => void;
|
||||
renderItem: (item: T) => ReactNode;
|
||||
@@ -41,7 +45,12 @@ export function SearchMenuList<T>({
|
||||
const results = (items ?? [])
|
||||
.map((item) => ({ item, score: fuzzyScore(query, ...searchTerms(item)) }))
|
||||
.filter((result) => result.score !== null)
|
||||
.sort((a, b) => b.score! - a.score! || (compare?.(a.item, b.item) ?? 0))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(prioritize ? Number(prioritize(b.item)) - Number(prioritize(a.item)) : 0) ||
|
||||
b.score! - a.score! ||
|
||||
(compare?.(a.item, b.item) ?? 0),
|
||||
)
|
||||
.map(({ item }) => item);
|
||||
|
||||
return <>
|
||||
|
||||
@@ -16,10 +16,9 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { MODEL_SLOTS, type AuthKeyStatus } from "@/src/core";
|
||||
import { CUSTOM_PROVIDER, MODEL_SLOTS, type AuthKeyStatus, type ModelCatalogResponse } from "@/src/core";
|
||||
import { classifyKeys, type ProviderKeyInfo } from "@/components/settings/keys-section";
|
||||
import { useModelCatalog } from "@/components/model-catalog";
|
||||
import type { ModelCatalogResponse } from "@/src/core";
|
||||
|
||||
/** Pure: does a MODEL-PROVIDER key exist among the statuses? Reuses
|
||||
* classifyKeys (the keys section's own classification — no drift):
|
||||
@@ -30,7 +29,13 @@ export function hasProviderKey(
|
||||
statuses: readonly AuthKeyStatus[],
|
||||
providers: readonly ProviderKeyInfo[] | undefined,
|
||||
): boolean {
|
||||
return classifyKeys(statuses, providers).providerRows.some((s) => s.present);
|
||||
// A stored custom-endpoint key counts too: classifyKeys drops it from
|
||||
// providerRows (it has its own dedicated row), but a connected custom
|
||||
// endpoint IS a way to run inference, so it clears the zero-keys gate.
|
||||
return (
|
||||
classifyKeys(statuses, providers).providerRows.some((s) => s.present) ||
|
||||
statuses.some((s) => s.id === CUSTOM_PROVIDER.envVar && s.present)
|
||||
);
|
||||
}
|
||||
|
||||
/** Completeness is saved identity, not current provider availability. */
|
||||
|
||||
+73
-34
@@ -40,7 +40,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useBackdropDismiss } from "@/components/backdrop-dismiss";
|
||||
import type React from "react";
|
||||
import { type ModelCatalogEntry, type ModelSlotName, type ModelsSettings } from "@/src/core";
|
||||
import { type ModelCatalogEntry, type ModelSlotName, type ModelsSettings, CUSTOM_PROVIDER } from "@/src/core";
|
||||
import { prettyModel } from "@/components/usage-format";
|
||||
import { ModelMenuList } from "@/components/settings/model-menu";
|
||||
import { MenuShell } from "@/components/menu-shell";
|
||||
@@ -48,7 +48,7 @@ import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-sig
|
||||
import { useModelCatalog } from "@/components/model-catalog";
|
||||
import { savedSlotsConfigured } from "@/components/setup-cta";
|
||||
import { Hint } from "@/components/ui/hint";
|
||||
import { type ProviderKeyInfo } from "@/components/settings/keys-section";
|
||||
import { type ProviderKeyInfo, CUSTOM_PROVIDER_INFO } from "@/components/settings/keys-section";
|
||||
import { ProviderMenuList } from "@/components/settings/model-menu";
|
||||
import { ChatGPTBackgroundAccess } from "@/components/settings/chatgpt-connection";
|
||||
import { NativeSubscriptionBackgroundAccess, SubscriptionConnections } from "@/components/settings/subscription-connections";
|
||||
@@ -134,6 +134,9 @@ export function SetupKeysBody({
|
||||
onValue,
|
||||
onSave,
|
||||
menuAnchor,
|
||||
isCustom,
|
||||
baseUrl,
|
||||
onBaseUrl,
|
||||
}: {
|
||||
/** The full provider roster (GET /api/keys providers) — undefined
|
||||
* while it loads; the picker renders an honest empty state then. */
|
||||
@@ -151,6 +154,11 @@ export function SetupKeysBody({
|
||||
onSave: () => void;
|
||||
/** The MenuShell anchor — wired by the wrapper (its triggerRef). */
|
||||
menuAnchor: React.RefObject<HTMLDivElement | null>;
|
||||
/** Chosen provider is the custom endpoint: a base URL field joins the key
|
||||
* field. All three optional so plain-key callers (and tests) stay unchanged. */
|
||||
isCustom?: boolean;
|
||||
baseUrl?: string;
|
||||
onBaseUrl?: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 px-6 py-5" data-testid="setup-stage-keys">
|
||||
@@ -185,30 +193,44 @@ export function SetupKeysBody({
|
||||
)}
|
||||
</div>
|
||||
{provider && (
|
||||
<div className="flex max-w-96 items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && value.trim()) onSave();
|
||||
}}
|
||||
placeholder="Paste key…"
|
||||
aria-label={`${provider.label} key`}
|
||||
data-testid="setup-key-input"
|
||||
disabled={saving}
|
||||
autoFocus
|
||||
className="h-7 min-w-0 flex-1 rounded-sm border border-border bg-background px-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
disabled={saving || !value.trim()}
|
||||
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"
|
||||
>
|
||||
{saving ? "Saving…" : "Save key"}
|
||||
</button>
|
||||
<div className="flex max-w-96 flex-col gap-2">
|
||||
{isCustom && (
|
||||
<input
|
||||
type="text"
|
||||
value={baseUrl ?? ""}
|
||||
onChange={(e) => onBaseUrl?.(e.target.value)}
|
||||
placeholder="Base URL, e.g. http://localhost:11434/v1"
|
||||
aria-label="Custom endpoint base URL"
|
||||
data-testid="setup-custom-baseurl"
|
||||
disabled={saving}
|
||||
className="h-7 w-full rounded-sm border border-border bg-background px-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50"
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && value.trim()) onSave();
|
||||
}}
|
||||
placeholder={isCustom ? "API key — required (any value works for local servers)" : "Paste key…"}
|
||||
aria-label={`${provider.label} key`}
|
||||
data-testid="setup-key-input"
|
||||
disabled={saving}
|
||||
autoFocus
|
||||
className="h-7 min-w-0 flex-1 rounded-sm border border-border bg-background px-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
disabled={saving || !value.trim()}
|
||||
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"
|
||||
>
|
||||
{saving ? "Saving…" : isCustom ? "Connect" : "Save key"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
@@ -264,6 +286,7 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) {
|
||||
const [keyProviders, setKeyProviders] = useState<ProviderKeyInfo[] | undefined>(undefined);
|
||||
const [keyProvider, setKeyProvider] = useState<ProviderKeyInfo | null>(null);
|
||||
const [keyValue, setKeyValue] = useState("");
|
||||
const [customBaseUrl, setCustomBaseUrl] = useState("");
|
||||
const [keySaving, setKeySaving] = useState(false);
|
||||
const [keyError, setKeyError] = useState<string | null>(null);
|
||||
const [keyMenuOpen, setKeyMenuOpen] = useState(false);
|
||||
@@ -374,14 +397,23 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) {
|
||||
* close recovery or advance to the missing model slots. Shape-gate
|
||||
* 400s keep their user-facing line inline. */
|
||||
async function saveKey() {
|
||||
if (!keyProvider || !keyValue.trim() || keySaving) return;
|
||||
if (!keyProvider || keySaving) return;
|
||||
const isCustom = keyProvider.id === CUSTOM_PROVIDER.id;
|
||||
if (!keyValue.trim()) return;
|
||||
setKeySaving(true);
|
||||
setKeyError(null);
|
||||
const res = await fetch(`/api/keys/${encodeURIComponent(keyProvider.envVar)}`, {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ value: keyValue }),
|
||||
}).catch(() => null);
|
||||
// Custom endpoint connects through its own route; every other provider is a plain key PUT.
|
||||
const res = isCustom
|
||||
? await fetch("/api/custom-provider", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ baseUrl: customBaseUrl, key: keyValue }),
|
||||
}).catch(() => null)
|
||||
: await fetch(`/api/keys/${encodeURIComponent(keyProvider.envVar)}`, {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ value: keyValue }),
|
||||
}).catch(() => null);
|
||||
setKeySaving(false);
|
||||
if (!res || !res.ok) {
|
||||
const body = (await res?.json().catch(() => ({}))) as { error?: string } | undefined;
|
||||
@@ -390,6 +422,7 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) {
|
||||
}
|
||||
setKeyProvider(null);
|
||||
setKeyValue("");
|
||||
setCustomBaseUrl("");
|
||||
notifySettingsSaved("keys");
|
||||
}
|
||||
|
||||
@@ -420,13 +453,16 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) {
|
||||
}
|
||||
|
||||
const keysBody = <SetupKeysBody
|
||||
providers={keyProviders}
|
||||
providers={keyProviders ? [...keyProviders, CUSTOM_PROVIDER_INFO] : undefined}
|
||||
provider={keyProvider}
|
||||
value={keyValue}
|
||||
saving={keySaving}
|
||||
error={keyError}
|
||||
menuOpen={keyMenuOpen}
|
||||
menuAnchor={keyTriggerRef}
|
||||
isCustom={keyProvider?.id === CUSTOM_PROVIDER.id}
|
||||
baseUrl={customBaseUrl}
|
||||
onBaseUrl={setCustomBaseUrl}
|
||||
onToggleMenu={() => setKeyMenuOpen((v) => !v)}
|
||||
onPick={(p) => { setKeyProvider(p); setKeyMenuOpen(false); setKeyError(null); }}
|
||||
onValue={setKeyValue}
|
||||
@@ -483,7 +519,9 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) {
|
||||
// Unavailable metadata cannot erase or clamp a saved preference.
|
||||
const levelsForEntry = metadata?.supportedLevels ?? (entry ? Object.keys(THINKING_LABEL) : []);
|
||||
const available = catalog?.models ?? (failed ? [] : undefined);
|
||||
const slotCatalog = slot === "vision" ? available?.filter((m) => m.input.includes("image")) : available;
|
||||
const slotCatalog = slot === "vision"
|
||||
? available?.filter((m) => m.input.includes("image") || m.provider === CUSTOM_PROVIDER.id)
|
||||
: available;
|
||||
return (
|
||||
<div key={slot} data-testid={`setup-slot-${slot}`} className="flex flex-col gap-1.5">
|
||||
<span className="flex items-center gap-1.5">
|
||||
@@ -537,6 +575,7 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) {
|
||||
models={slotCatalog}
|
||||
effectiveId={entry?.id}
|
||||
effectiveProvider={entry?.provider}
|
||||
vision={slot === "vision"}
|
||||
onPick={(m) => {
|
||||
editedSlots.current.add(slot);
|
||||
setPicked((prev) => ({ ...prev, [slot]: m }));
|
||||
|
||||
Reference in New Issue
Block a user