00a2aedb09
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).
460 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|