/** * @file AiRouterConfig.tsx * @description The **AI Router** tab of Settings — CRUD for the named router * endpoints (9Router, OpenRouter, a self-hosted gateway) a lane can be pointed * at instead of the vendor's own API. * * A lane CHOOSES its router in the Add-lane modal, not here. But choosing one * copies it into that lane's own config, so editing or deleting a profile here * does reach every lane already using it — the server re-applies it, or resets * those lanes to native, and reports any lane it could not touch. That report * is surfaced: a rotated key that silently missed two lanes would leave the * user believing a revoked key is out of use when it is not. * * 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 * router" rather than a special case. * * 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. * * **Test** asks the endpoint itself before the profile is saved onto a lane. * "Saved" and "works" are different claims, and the gap between them is only * ever discovered inside a lane's 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, useState } from "react"; import { useTranslation } from "react-i18next"; import { Plus, Pencil, Trash2, Router as RouterIcon, Lock } from "lucide-react"; import { api, type AiRouter, type AiRouterInput, type AiRouterModels } from "../lib/api"; const TIERS: (keyof AiRouterModels)[] = ["opus", "sonnet", "haiku", "smallFast", "subagent"]; type FormState = Omit & { apiKey: string; models: Partial; }; const emptyForm = (): FormState => ({ label: "", baseUrl: "", apiKey: "", model: "", models: {}, }); export function AiRouterConfig() { const { t } = useTranslation("settings"); const [items, setItems] = useState([]); const [error, setError] = useState(null); // Not an error — the profile itself saved. It is the lanes that did not all // follow, which the user must be told about rather than left to assume. const [laneWarning, setLaneWarning] = useState(null); const [editing, setEditing] = useState(null); const [form, setForm] = useState(emptyForm()); const [open, setOpen] = useState(false); const [busy, setBusy] = useState(false); // The router's own verdict on the form as it currently stands. 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); // 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.aiRouters.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()); setOpen(true); }; const startEdit = (item: AiRouter) => { 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 }, }); setOpen(true); }; /** Report any lane the server could not re-point, or clear a stale report. */ const reportLanes = (lanes?: { updated: number; failed: string[] }) => { setLaneWarning( lanes && lanes.failed.length ? t("aiRouter.lanesFailed", { count: lanes.failed.length, lanes: lanes.failed.join(", ") }) : null ); }; /** * 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.aiRouters.test({ baseUrl: form.baseUrl.trim(), model: form.model.trim(), ...(form.apiKey.trim() ? { apiKey: form.apiKey.trim() } : {}), ...(editing ? { name: editing.name } : {}), }); setTestResult({ ok: result.ok, text: result.ok ? t("aiRouter.testOk", { model: result.message }) : t("aiRouter.testFailed", { status: result.status ?? "—", message: result.message, }), }); } catch (e) { setTestResult({ ok: false, text: e instanceof Error ? e.message : String(e) }); } finally { setTesting(false); } }; const submit = async () => { setBusy(true); setError(null); setLaneWarning(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 router 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: AiRouterInput = { label: form.label.trim(), baseUrl: form.baseUrl.trim(), model: form.model.trim(), models, }; if (form.apiKey.trim()) body.apiKey = form.apiKey.trim(); if (editing) reportLanes((await api.aiRouters.update(editing.name, body)).lanes); else await api.aiRouters.create(body); setOpen(false); await load(); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setBusy(false); } }; const remove = async (item: AiRouter) => { if (!window.confirm(t("aiRouter.removeConfirm", { label: item.label }))) return; setError(null); setLaneWarning(null); try { reportLanes((await api.aiRouters.remove(item.name)).lanes); await load(); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } }; return (

{t("aiRouter.title")}

{t("aiRouter.description")}

{error && (

{error}

)} {laneWarning && (

{laneWarning}

)}
{items.map((item) => ( ))} {(loading || items.length <= 1) && ( )}
{t("aiRouter.colLabel")} {t("aiRouter.colBaseUrl")} {t("aiRouter.colModel")} {t("aiRouter.colKey")}
{item.builtin && } {item.label} {item.builtin && ( {t("aiRouter.builtinHint")} )} {item.baseUrl || "—"} {item.model} {item.builtin ? "—" : item.hasKey ? t("aiRouter.keySet") : t("aiRouter.keyMissing")} {!item.builtin && ( )}
{loading ? t("aiRouter.loading") : t("aiRouter.empty")}
{open && (

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

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

{t("aiRouter.perTierHint")}

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

{testResult.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-blue-500 focus:outline-none ${ mono ? "font-mono" : "" }`} /> {hint &&

{hint}

}
); }