Files
AgentHub/client/src/pages/AiRouterConfig.tsx
T
nntrivi2001 00a2aedb09 feat(ai-router): test an endpoint before saving it, and re-point a live lane
Two gaps left by the AI Router feature: a profile could be saved without ever
being reachable, and a lane's router could only be chosen at creation.

**POST /api/ai-routers/test** answers the question saving cannot. It sends one
Anthropic-style message capped at a single output token to `<baseUrl>/v1/messages`
with a 15s timeout — the wire shape a claude lane's eight env keys point at, so
what is tested is what will actually run. A router that answers is a 200 here
whatever it replies: `{ ok, status, message }` carries the router's own words,
and `status` is null when nothing answered at all. Only bad input is a 4xx.

The stored key is only ever sent to the endpoint it was stored against. A blank
`apiKey` falls back to the key of the profile named by `name`, but only while
the submitted `baseUrl` still equals the saved one — so testing a retyped
endpoint needs a retyped key (`EBADKEY`). Without that rule this route would be
a way to make the server POST a saved API key to any URL a browser names.

Network failures report `error.cause`, not `error.message`: node's fetch reports
every transport failure as the same useless "fetch failed" and hides the part
the user can act on (ECONNREFUSED, ENOTFOUND, a TLS error) one level down.

**The lane card carries an AI Router select.** The Add-lane modal was the only
place a router was chosen, and a lane outlives its creation. Switching goes
through `POST /api/lanes/:id/router/apply` — never a lane PATCH — because the
DB column and the lane's own config on disk may only move together. A failed
apply puts the select back rather than leaving the card claiming a router the
lane did not get. Hidden when `native` is the only profile: a select with one
option is a control that cannot do anything.

Adds `Lane.router_profile` to the client type (the payload already carried it).
2026-09-16 11:26:00 +07:00

460 lines
17 KiB
TypeScript

/**
* @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ĩ <vinnt@smartgift.vn>
*/
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<AiRouterInput, "apiKey" | "models"> & {
apiKey: string;
models: Partial<AiRouterModels>;
};
const emptyForm = (): FormState => ({
label: "",
baseUrl: "",
apiKey: "",
model: "",
models: {},
});
export function AiRouterConfig() {
const { t } = useTranslation("settings");
const [items, setItems] = useState<AiRouter[]>([]);
const [error, setError] = useState<string | null>(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<string | null>(null);
const [editing, setEditing] = useState<AiRouter | null>(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<AiRouterModels> = {};
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 (
<div className="space-y-4">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="flex items-center gap-2 text-sm font-semibold text-fg-primary">
<RouterIcon className="h-4 w-4 text-fg-muted" />
{t("aiRouter.title")}
</h2>
<p className="mt-1 max-w-2xl text-xs text-fg-muted">{t("aiRouter.description")}</p>
</div>
<button
type="button"
onClick={startAdd}
className="inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-accent/40 bg-accent/15 px-3 py-1.5 text-xs font-medium text-accent transition-colors hover:bg-accent/25"
>
<Plus className="h-3.5 w-3.5" />
{t("aiRouter.add")}
</button>
</div>
{error && (
<p className="rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-xs text-status-danger">
{error}
</p>
)}
{laneWarning && (
<p className="rounded-md border border-status-warning/40 bg-status-warning/10 px-3 py-2 text-xs text-status-warning">
{laneWarning}
</p>
)}
<div className="overflow-hidden rounded-lg border border-border">
<table className="w-full text-left text-xs">
<thead className="bg-surface-2 text-[10px] uppercase tracking-wider text-fg-muted">
<tr>
<th className="px-3 py-2 font-medium">{t("aiRouter.colLabel")}</th>
<th className="px-3 py-2 font-medium">{t("aiRouter.colBaseUrl")}</th>
<th className="px-3 py-2 font-medium">{t("aiRouter.colModel")}</th>
<th className="px-3 py-2 font-medium">{t("aiRouter.colKey")}</th>
<th className="px-3 py-2" />
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.name} className="border-t border-border">
<td className="px-3 py-2">
<span className="flex items-center gap-1.5 font-medium text-fg-primary">
{item.builtin && <Lock className="h-3 w-3 text-fg-muted" />}
{item.label}
</span>
{item.builtin && (
<span className="mt-0.5 block text-[10px] text-fg-muted">
{t("aiRouter.builtinHint")}
</span>
)}
</td>
<td className="px-3 py-2 font-mono text-[11px] text-fg-secondary">
{item.baseUrl || "—"}
</td>
<td className="px-3 py-2 font-mono text-[11px] text-fg-secondary">{item.model}</td>
<td className="px-3 py-2 text-fg-secondary">
{item.builtin
? "—"
: item.hasKey
? t("aiRouter.keySet")
: t("aiRouter.keyMissing")}
</td>
<td className="px-3 py-2 text-right">
{!item.builtin && (
<span className="inline-flex gap-1">
<button
type="button"
aria-label={`${t("aiRouter.edit")} ${item.label}`}
onClick={() => startEdit(item)}
className="rounded p-1 text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
aria-label={`${t("aiRouter.remove")} ${item.label}`}
onClick={() => remove(item)}
className="rounded p-1 text-fg-muted transition-colors hover:bg-surface-3 hover:text-status-danger"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</span>
)}
</td>
</tr>
))}
{(loading || items.length <= 1) && (
<tr className="border-t border-border">
<td colSpan={5} className="px-3 py-4 text-center text-fg-muted">
{loading ? t("aiRouter.loading") : t("aiRouter.empty")}
</td>
</tr>
)}
</tbody>
</table>
</div>
{open && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
role="presentation"
>
<div
role="dialog"
aria-modal="true"
aria-label={editing ? t("aiRouter.edit") : t("aiRouter.add")}
className="max-h-[90vh] w-full max-w-md overflow-y-auto rounded-xl border border-border bg-surface-1 p-5 shadow-xl"
>
<h3 className="mb-3 text-sm font-semibold text-fg-primary">
{editing ? t("aiRouter.edit") : t("aiRouter.add")}
</h3>
<div className="space-y-3">
<Field
id="router-label"
label={t("aiRouter.fieldLabel")}
hint={t("aiRouter.fieldLabelHint")}
value={form.label}
onChange={(v) => setForm((f) => ({ ...f, label: v }))}
/>
<Field
id="router-base-url"
label={t("aiRouter.fieldBaseUrl")}
hint={t("aiRouter.fieldBaseUrlHint")}
value={form.baseUrl}
mono
onChange={(v) => setForm((f) => ({ ...f, baseUrl: v }))}
/>
<Field
id="router-api-key"
label={t("aiRouter.fieldApiKey")}
hint={
editing && editing.hasKey
? t("aiRouter.fieldApiKeyKeep")
: t("aiRouter.fieldApiKeyHint")
}
value={form.apiKey}
type="password"
mono
onChange={(v) => setForm((f) => ({ ...f, apiKey: v }))}
/>
<Field
id="router-model"
label={t("aiRouter.fieldModel")}
hint={t("aiRouter.fieldModelHint")}
value={form.model}
mono
onChange={(v) => setForm((f) => ({ ...f, model: v }))}
/>
<details className="rounded-lg border border-border bg-surface-2 p-2">
<summary className="cursor-pointer text-xs text-fg-secondary">
{t("aiRouter.perTier")}
</summary>
<p className="mt-1 text-[10px] text-fg-muted">{t("aiRouter.perTierHint")}</p>
<div className="mt-2 space-y-2">
{TIERS.map((tier) => (
<Field
key={tier}
id={`router-model-${tier}`}
label={t(`aiRouter.tier.${tier}`)}
value={form.models[tier] || ""}
mono
placeholder={form.model}
onChange={(v) =>
setForm((f) => ({ ...f, models: { ...f.models, [tier]: v } }))
}
/>
))}
</div>
</details>
</div>
{testResult && (
<p
data-testid="router-test-result"
className={`mt-3 rounded-md border px-3 py-2 text-xs ${
testResult.ok
? "border-status-success/40 bg-status-success/10 text-status-success"
: "border-status-danger/40 bg-status-danger/10 text-status-danger"
}`}
>
{testResult.text}
</p>
)}
<div className="mt-4 flex items-center justify-end gap-2">
<button
type="button"
data-testid="router-test"
onClick={test}
disabled={testing || busy || !form.baseUrl.trim() || !form.model.trim()}
className="mr-auto rounded-md border border-border-light px-3 py-1.5 text-xs text-fg-secondary transition-colors hover:text-fg-primary disabled:opacity-50"
>
{testing ? t("aiRouter.testing") : t("aiRouter.test")}
</button>
<button
type="button"
onClick={() => setOpen(false)}
className="rounded-md px-3 py-1.5 text-xs text-fg-secondary transition-colors hover:bg-surface-3"
>
{t("aiRouter.cancel")}
</button>
<button
type="button"
onClick={submit}
disabled={busy || !form.label.trim() || !form.baseUrl.trim() || !form.model.trim()}
className="rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-accent/90 disabled:opacity-50"
>
{t("aiRouter.save")}
</button>
</div>
</div>
</div>
)}
</div>
);
}
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 (
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor={id}>
{label}
</label>
<input
id={id}
type={type}
value={value}
placeholder={placeholder}
onChange={(e) => 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 && <p className="mt-1 text-[10px] text-fg-muted">{hint}</p>}
</div>
);
}