/** * @file AiProxyConfig.tsx * @description The **AI Proxy** page of Settings — CRUD for the named proxy * endpoints (9Router, OpenRouter, a self-hosted gateway) a session can run on * instead of the vendor's own API. * * A NEW SESSION chooses its proxy (the workspace home's prompt box), not here. An edit here reaches the sessions already on a profile * (the server rewrites the file each reads), and a delete removes that file. * * The built-in `native` row is rendered read-only: it is the vendor's own API, * and it exists so that "go back to native" is the same operation as "use a * proxy" rather than a special case. It has no model of its own (the * session's model picker picks), so its Model cell reads "—". * * An API key is write-only here by design — the server never returns one, so * the form shows whether a key is saved, never the key. * * The form is a real dialog (focus trap, Escape) and shows a failed save * inside itself, not behind its scrim; a remove asks through `confirmDialog`. * * A right-click on a profile row opens its Edit / Remove (plus Copy endpoint) * as a menu; the buttons stay. * * In server mode a profile is SHARED (an admin's, usable by everyone) or * PERSONAL (the signed-in user's own, badged "Personal"). A member edits only * their own and every profile they add is personal; an admin chooses when * adding; an analyst only reads. * * **Test** asks the endpoint itself, once as Claude Code would * (`/v1/messages`) and once as Codex would (`/v1/responses`), one line each. * "Saved" and "works" are different claims, and the gap between them is only * ever discovered inside a session otherwise, where the error surfaces as a * broken CLI rather than a bad URL. * * @author Nguyễn Ngọc Trí Vĩ */ import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Plus, Pencil, Trash2, Lock } from "lucide-react"; import { api, type AiProxy, type AiProxyInput, type AiProxyModels, type AiProxyProbe, } from "../lib/api"; import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu"; import { confirmDialog } from "../lib/confirm"; import { copyText } from "../lib/clipboard"; import { useDialog } from "../hooks/useDialog"; import { useAuth } from "../components/AuthGate"; const TIERS: (keyof AiProxyModels)[] = ["opus", "sonnet", "haiku", "smallFast", "subagent"]; type FormState = Omit & { apiKey: string; behavesAs: string; models: Partial; scope: "shared" | "personal"; }; const emptyForm = (): FormState => ({ label: "", baseUrl: "", apiKey: "", model: "", models: {}, behavesAs: "", scope: "shared", }); export function AiProxyConfig() { const { t } = useTranslation("settings"); // Server mode's signed-in user; null in local mode, where the host owns // every (shared) profile. const { state: auth } = useAuth(); const user = auth.mode === "server" ? auth.user : null; const isAdmin = !user || user.role === "admin"; const canWrite = (item: AiProxy) => !item.builtin && (item.scope === "personal" || isAdmin); const [items, setItems] = useState([]); const [error, setError] = useState(null); const [editing, setEditing] = useState(null); const [form, setForm] = useState(emptyForm()); const [open, setOpen] = useState(false); const [busy, setBusy] = useState(false); // A failed save, shown INSIDE the dialog (the page-level `error` sits behind it). const [formError, setFormError] = useState(null); const dialogRef = useRef(null); useDialog(open, dialogRef, () => setOpen(false)); // The proxy's own verdict on the form as it currently stands, one line per // CLI. Cleared on every field change: a green "works" left sitting above an // edited base URL is a claim about a configuration that no longer exists. const [testResult, setTestResult] = useState<{ ok: boolean; text: string }[] | null>(null); const [testing, setTesting] = useState(false); const ctx = useContextMenu(); // A menu item: close the menu, then act on the row it was opened on. const pick = (fn: (item: AiProxy) => unknown) => () => { const item = ctx.menu?.item; ctx.close(); if (item) fn(item); }; // Distinct from "no profiles": an empty table rendered before the first fetch // resolves tells the user their profiles are gone. CodexConfig draws the same // distinction for the same reason. const [loading, setLoading] = useState(true); // No dependencies, deliberately. `t` is NOT one: react-i18next returns a // stable `t`, but nothing in this component's contract guarantees that, and // the moment it changes identity per render this callback does too — which // re-fires the effect below, which calls load(), which renders again. That // loop also clears `error` on every pass, so a failed save would flash and // vanish. Keeping the message untranslated is the cheaper half of the trade. const load = useCallback(async () => { try { setItems((await api.aiProxies.list()).items); setError(null); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setLoading(false); } }, []); useEffect(() => { void load(); }, [load]); // Any edit to the form invalidates the last verdict — see `testResult`. useEffect(() => setTestResult(null), [form]); const startAdd = () => { setEditing(null); setForm(emptyForm()); setFormError(null); setOpen(true); }; const startEdit = (item: AiProxy) => { setEditing(item); // No apiKey: the server never sent one. Blank means "keep what is stored". setForm({ label: item.label, baseUrl: item.baseUrl, apiKey: "", model: item.model, models: { ...item.models }, behavesAs: item.behavesAs ?? "", scope: item.scope ?? "shared", }); setFormError(null); setOpen(true); }; /** * Ask the endpoint itself. `name` is sent when editing so a profile whose key * the form cannot show is still testable — the server only honours it while * the base URL is unchanged, so a retyped endpoint needs a retyped key. */ const test = async () => { setTesting(true); setTestResult(null); try { const result = await api.aiProxies.test({ baseUrl: form.baseUrl.trim(), model: form.model.trim(), ...(form.apiKey.trim() ? { apiKey: form.apiKey.trim() } : {}), ...(editing ? { name: editing.name } : {}), }); const line = (cli: string, probe: AiProxyProbe) => ({ ok: probe.ok, text: `${cli}: ${ probe.ok ? t("aiProxy.testOk", { model: probe.message }) : t("aiProxy.testFailed", { status: probe.status ?? "—", message: probe.message }) }`, }); setTestResult([line("Claude Code", result.claude), line("Codex", result.codex)]); } catch (e) { setTestResult([{ ok: false, text: e instanceof Error ? e.message : String(e) }]); } finally { setTesting(false); } }; const submit = async () => { setBusy(true); setFormError(null); try { // Send only the tiers that differ from the headline model, so the server // keeps filling the rest from it — that is what makes one symbolic name // enough for a proxy that wants the same value everywhere. const models: Partial = {}; for (const tier of TIERS) { const value = (form.models[tier] || "").trim(); if (value && value !== form.model.trim()) models[tier] = value; } const body: AiProxyInput = { label: form.label.trim(), baseUrl: form.baseUrl.trim(), model: form.model.trim(), models, behavesAs: form.behavesAs.trim(), }; if (form.apiKey.trim()) body.apiKey = form.apiKey.trim(); if (user && isAdmin && !editing) body.scope = form.scope; if (editing) await api.aiProxies.update(editing.name, body); else await api.aiProxies.create(body); setOpen(false); await load(); } catch (e) { setFormError(e instanceof Error ? e.message : String(e)); } finally { setBusy(false); } }; const remove = async (item: AiProxy) => { if ( !(await confirmDialog({ title: t("aiProxy.removeConfirm", { label: item.label }), confirmLabel: t("aiProxy.remove"), })) ) return; setError(null); try { await api.aiProxies.remove(item.name); await load(); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } }; return (
{/* The page header above already names the section. */}

{t("aiProxy.description")}

{error && (

{error}

)}
{items.map((item) => ( ctx.open(e, item) : undefined} className="border-t border-border" > ))} {(loading || items.length <= 1) && ( )}
{t("aiProxy.colLabel")} {t("aiProxy.colBaseUrl")} {t("aiProxy.colModel")} {t("aiProxy.colKey")}
{item.builtin && } {item.label} {item.scope === "personal" && ( {t("aiProxy.personal")} )} {item.builtin && ( {t("aiProxy.builtinHint")} )} {item.baseUrl || "—"} {item.model || "—"} {item.builtin ? "—" : item.hasKey ? t("aiProxy.keySet") : t("aiProxy.keyMissing")} {canWrite(item) && ( )}
{loading ? t("aiProxy.loading") : t("aiProxy.empty")}
{ctx.menu && ( {t("aiProxy.edit")} {ctx.menu.item.baseUrl && ( void copyText(item.baseUrl))}> {t("rowMenu.copyUrl")} )} void remove(item))}> {t("aiProxy.remove")} )} {open && (

{editing ? t("aiProxy.edit") : t("aiProxy.add")}

setForm((f) => ({ ...f, label: v }))} /> {user && !editing && (
{isAdmin ? ( ) : (

{t("aiProxy.scopePersonal")}

)}

{t("aiProxy.fieldScopeHint")}

)} setForm((f) => ({ ...f, baseUrl: v }))} /> setForm((f) => ({ ...f, apiKey: v }))} /> setForm((f) => ({ ...f, model: v }))} /> setForm((f) => ({ ...f, behavesAs: v }))} />
{t("aiProxy.perTier")}

{t("aiProxy.perTierHint")}

{TIERS.map((tier) => ( setForm((f) => ({ ...f, models: { ...f.models, [tier]: v } })) } /> ))}
{formError && (

{formError}

)} {testResult?.map((r) => (

{r.text}

))}
)}
); } function Field({ id, label, hint, value, onChange, mono, type = "text", placeholder, }: { id: string; label: string; hint?: string; value: string; onChange: (value: string) => void; mono?: boolean; type?: string; placeholder?: string; }) { return (
onChange(e.target.value)} className={`w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none ${ mono ? "font-mono" : "" }`} /> {hint &&

{hint}

}
); }