diff --git a/CLAUDE.md b/CLAUDE.md index d6b93b47..255f4f94 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Claude Code Working Guide -_Last synced: v1.6.0 (2026-09-14) + AI Router profiles (`server/lib/ai-routers.js`, `server/lib/router-apply.js`, the Settings tab `/settings?tool=ai-router`, and `lanes.router_profile`) + the bootstrap's superseded-build guard and plugin-cache pruner (`supersededBy`/`pruneRetiredPluginRoots` in `scripts/plugin-bootstrap.js`, `unitRoot` in `scripts/systemd-service.js`) + v1.5.6 (2026-09-11) + the systemd handover rule below (`scripts/systemd-service.js`'s `writeUserService`/`restartUserService` split and `plugin-bootstrap.js`'s deferred `pendingSystemdRestart`) + eb63569 (2026-09-10) + release-based update detection (`server/lib/update-check.js` reads the forge releases API — a checkout via its `origin` remote, a plugin-cache install via the marketplace manifest on disk, since an archive-sourced cache dir has no `.git`; a `plugin_managed` install self-updates via the detached `plugin-update` chain) + the install-wide **Update all** batch and session reload (`server/lib/cc-update-all.js`, `server/lib/cc-reload.js`)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. +_Last synced: v1.6.0 (2026-09-14) + AI Router profiles (`server/lib/ai-routers.js`, `server/lib/router-apply.js`, the Settings tab `/settings?tool=ai-router`, its **Test** button / `POST /api/ai-routers/test`, the per-lane router select on `LaneCard`, and `lanes.router_profile`) + the bootstrap's superseded-build guard and plugin-cache pruner (`supersededBy`/`pruneRetiredPluginRoots` in `scripts/plugin-bootstrap.js`, `unitRoot` in `scripts/systemd-service.js`) + v1.5.6 (2026-09-11) + the systemd handover rule below (`scripts/systemd-service.js`'s `writeUserService`/`restartUserService` split and `plugin-bootstrap.js`'s deferred `pendingSystemdRestart`) + eb63569 (2026-09-10) + release-based update detection (`server/lib/update-check.js` reads the forge releases API — a checkout via its `origin` remote, a plugin-cache install via the marketplace manifest on disk, since an archive-sourced cache dir has no `.git`; a `plugin_managed` install self-updates via the detached `plugin-update` chain) + the install-wide **Update all** batch and session reload (`server/lib/cc-update-all.js`, `server/lib/cc-reload.js`)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. ## Project mission - Maintain a reliable local-first dashboard for Claude Code session monitoring. @@ -36,7 +36,7 @@ A **lane** is a durable unit of parallel agent work — one working directory, m - Liveness: a silent **watcher** (stage matching `/watch|poll/`) is dead after `LANE_DEAD_SEC` seconds (default 300); a silent **idle** lane is at rest, not dead. - **Never `rm -rf` a lane.** Destructive lane operations (`reset`, `remove`, `purge`) go through `server/lib/worktree.js`/`server/lib/lanes.js`, never a raw filesystem delete — that's what keeps the three-check destroy guard and the DB bookkeeping in lockstep. - **Never build a git command as a shell string.** Lane git operations use `execFile("git", [...args])` with an explicit argument array (see `server/lib/worktree.js`'s `git()` helper), never a concatenated/interpolated string passed to a shell. -- **A lane's AI Router profile is a lane fact too, and it never travels alone.** `lanes.router_profile` (nullable; null means the same as `native`) is written ONLY by `setRouterProfile`, called beside the disk write in `POST /api/lanes/:id/router/apply` — never through `PATCHABLE`. The column and the lane's own config must move together, or the row claims a router the lane's `settings.local.json` has never heard of. What is written comes from the lane row, never the request: a **claude** lane gets eight `env` keys merged into `/.claude/settings.local.json` (`.local` because the value carries an API key; every other key in that file is preserved and a non-JSON file is refused, not rewritten), a **codex** lane gets `$CODEX_HOME/.config.toml` — the layer `codex -p ` loads — because Codex has NO project-level config, and its base `config.toml` is never touched. Key material never leaves the server: profiles live at `/agenthub-routers/` (0700/0600), `GET /api/ai-routers` reports only `hasKey`, and there is deliberately no route that returns one profile in full. **Codex's key reaches the pane through tmux's `-e` argv and is never typed into it** — `tmux.sendCommand` takes over an idle pane with `respawn-pane` for exactly this reason; a `KEY='sk-…' codex …` line typed with `send-keys` would be rendered to every attached browser, kept in the scrollback and appended to the user's shell history. Do not reintroduce a typed command line there. **And attaching COPIES a profile**, so the store stops being the single source of truth the moment a lane exists: `PATCH`/`DELETE /api/ai-routers/:name` must push through to the lanes naming it (`reapplyToLanes`/`detachFromLanes`), or a rotated key silently never reaches the lane still using the revoked one. See `server/lib/ai-routers.js`, `server/lib/router-apply.js`. +- **A lane's AI Router profile is a lane fact too, and it never travels alone.** `lanes.router_profile` (nullable; null means the same as `native`) is written ONLY by `setRouterProfile`, called beside the disk write in `POST /api/lanes/:id/router/apply` — never through `PATCHABLE`. The column and the lane's own config must move together, or the row claims a router the lane's `settings.local.json` has never heard of. What is written comes from the lane row, never the request: a **claude** lane gets eight `env` keys merged into `/.claude/settings.local.json` (`.local` because the value carries an API key; every other key in that file is preserved and a non-JSON file is refused, not rewritten), a **codex** lane gets `$CODEX_HOME/.config.toml` — the layer `codex -p ` loads — because Codex has NO project-level config, and its base `config.toml` is never touched. Key material never leaves the server: profiles live at `/agenthub-routers/` (0700/0600), `GET /api/ai-routers` reports only `hasKey`, and there is deliberately no route that returns one profile in full. **`POST /api/ai-routers/test` sends a stored key ONLY to the endpoint it was stored against** — a blank `apiKey` falls back to the profile named by `name`, but only while the submitted `baseUrl` still equals the saved one, else `EBADKEY`. Relax that and the route becomes a way to make the server POST a saved key to any URL a browser names. **Re-pointing a lane is allowed and is not a PATCH:** the lane card's AI Router select calls `POST /api/lanes/:id/router/apply` (column + disk together, same rule as above), never `PATCH /api/lanes/:id`. **Codex's key reaches the pane through tmux's `-e` argv and is never typed into it** — `tmux.sendCommand` takes over an idle pane with `respawn-pane` for exactly this reason; a `KEY='sk-…' codex …` line typed with `send-keys` would be rendered to every attached browser, kept in the scrollback and appended to the user's shell history. Do not reintroduce a typed command line there. **And attaching COPIES a profile**, so the store stops being the single source of truth the moment a lane exists: `PATCH`/`DELETE /api/ai-routers/:name` must push through to the lanes naming it (`reapplyToLanes`/`detachFromLanes`), or a rotated key silently never reaches the lane still using the revoked one. See `server/lib/ai-routers.js`, `server/lib/router-apply.js`. - **A lane's `tool` is a lane fact, immutable, and never read from a request.** `lanes.tool` (`claude` | `codex`, default `claude`) is set by `createLane` only — it is deliberately absent from `PATCHABLE`, so no PATCH can flip the CLI under a live pane. Every spawn path (`POST /api/lanes/:id/start`, `POST /api/run`) takes the tool from the lane row and ignores whatever the body says; the run panel's Codex-only fields (`sandbox`, `approval`, `resumeLast`) are validated against the fixed allowlists in `server/lib/pty-run.js` before they reach an argv, and the bypass flags (`--dangerously-bypass-approvals-and-sandbox`, `--dangerously-bypass-hook-trust`) are never emitted. `GET /api/run/binary?tool=` resolves the probed binary name from that same allowlist — the query string reaches `which`. - **Adopted lanes are not destroyable.** A lane with `kind === "adopted"` may never have its worktree reset or removed; "removing" one only drops the dashboard's own record of it. This is enforced in code (`assertDestroyable`'s first check) — do not add a path that bypasses it. - **A `cwd` can back at most one lane per tool, not one lane overall.** Uniqueness is the composite `idx_lanes_cwd_tool` (`cwd`, `tool`), not `cwd` alone — Repo/Adopt mode (`POST /api/lanes/ensure`) lets the same directory carry a `claude` lane and a `codex` lane side by side, shown as two separate cards. `resolveLaneByCwd(cwd, tool)` takes an optional `tool` filter for exactly this; hook ingestion (`touchLaneFromHook`, `resolveLaneForWork`) always pins it to `"claude"` because Codex has no hook mechanism in this codebase, so a hook event must never touch a `codex` lane sharing the path. Worktree mode is unaffected — it already allocates a unique directory per lane. diff --git a/client/src/components/lanes/LaneCard.tsx b/client/src/components/lanes/LaneCard.tsx index 8d796b79..c64c7d67 100644 --- a/client/src/components/lanes/LaneCard.tsx +++ b/client/src/components/lanes/LaneCard.tsx @@ -7,6 +7,8 @@ * this view exists to catch. Stage, kind, and the "auto: " chip are * NOT repeated here — this card only ever renders inside the Workspace * lane-detail section, whose header above it already shows them. The repo + * card also carries the lane's AI Router picker — the Add-lane modal is the + * only other place one is chosen, and a lane outlives its creation. The repo * tooling dropdown (`LaneToolsMenu`, exported from this file) and the * remove-lane button live in Workspace's page header / pipeline-picker row * instead of here, so they stay reachable and un-clipped regardless of this @@ -20,6 +22,7 @@ import { DestructiveLaneModal } from "./DestructiveLaneModal"; import { api } from "../../lib/api"; import { eventBus } from "../../lib/eventBus"; import { keepIfSame } from "../../lib/keepIfSame"; +import type { AiRouter } from "../../lib/api"; import type { Lane, LaneGitFacts, LaneRuntime, NamedLock } from "../../lib/types"; /** How often a mounted card re-reads its working-copy facts. Slow on purpose: @@ -168,6 +171,92 @@ function useLaneIntegrations( return state as Record<(typeof INTEGRATION_NAMES)[number], boolean> | null; } +/** + * Which AI Router this lane runs against, and the one place it can be changed + * after the lane exists — the Add-lane modal is the only other one, and a lane + * outlives the moment it was created. + * + * Applying writes the lane's own config on disk as well as the DB column, so it + * goes through `POST /lanes/:id/router/apply` and never a PATCH. A failure puts + * the select back: the row must not claim a router the lane did not get. + * + * Rendered only when there is something to choose. A user who has never opened + * the AI Router tab has exactly the built-in `native` profile, and a select + * with one option is a control that cannot do anything. + */ +function LaneRouterRow({ lane }: { lane: Lane }) { + const { t } = useTranslation(["lanes"]); + const [routers, setRouters] = useState(null); + const [current, setCurrent] = useState(lane.router_profile || "native"); + const [busy, setBusy] = useState(false); + const [note, setNote] = useState<{ ok: boolean; text: string } | null>(null); + + // The polled lane list is the source of truth; a switch elsewhere (the lane + // was reset to native by a profile delete, say) has to land here too. + useEffect(() => { + setCurrent(lane.router_profile || "native"); + setNote(null); + }, [lane.id, lane.router_profile]); + + useEffect(() => { + let alive = true; + api.aiRouters + .list() + .then((r) => { + if (alive) setRouters(r.items); + }) + .catch(() => { + // Silent, same contract as the git/runtime pollers above: a card that + // cannot list routers simply shows no router row. + if (alive) setRouters([]); + }); + return () => { + alive = false; + }; + }, []); + + if (!routers || routers.length <= 1) return null; + + const change = async (name: string) => { + const previous = current; + setCurrent(name); + setBusy(true); + setNote(null); + try { + await api.lanes.routerApply(lane.id, name); + setNote({ ok: true, text: t("router.applied") }); + } catch (err) { + setCurrent(previous); + setNote({ ok: false, text: err instanceof Error ? err.message : String(err) }); + } finally { + setBusy(false); + } + }; + + return ( +
+ + {note && ( +

{note.text}

+ )} +
+ ); +} + export const LIVENESS_DOT: Record = { active: "bg-status-success", idle: "bg-surface-4", @@ -502,6 +591,8 @@ export default function LaneCard({ + + {childWorktrees && childWorktrees.length > 0 && (
({ agentsInstall: vi.fn(), mcpSync: vi.fn(), syncBaseCheck: vi.fn(), + routerApply: vi.fn(), }, locks: { list: vi.fn(), }, + aiRouters: { + list: vi.fn(), + }, }, })); @@ -43,6 +47,10 @@ beforeEach(() => { vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false }); vi.mocked(api.lanes.integration).mockReset(); vi.mocked(api.lanes.integration).mockResolvedValue({ enabled: false }); + // Only the built-in profile by default: a user who never opened the AI Router + // tab has nothing to switch between, so the row stays out of every other test. + vi.mocked(api.aiRouters.list).mockReset(); + vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [] }); }); vi.mocked(api.locks.list).mockReset(); vi.mocked(api.locks.list).mockResolvedValue({ locks: [] }); @@ -440,3 +448,63 @@ describe("LaneToolsMenu", () => { expect(container).toBeEmptyDOMElement(); }); }); + +describe("LaneCard — the AI Router picker", () => { + const native = { + name: "native", + label: "Native (Claude / Codex)", + builtin: true, + baseUrl: "", + hasKey: false, + model: "claude-sonnet-5", + models: { + opus: "claude-opus-5", + sonnet: "claude-sonnet-5", + haiku: "claude-haiku-4-5-20251001", + smallFast: "claude-haiku-4-5-20251001", + subagent: "claude-haiku-4-5-20251001", + }, + }; + const nineRouter = { ...native, name: "9router", label: "9Router", builtin: false }; + + it("stays out of the card when native is the only profile", async () => { + vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native] }); + render(); + await waitFor(() => expect(api.aiRouters.list).toHaveBeenCalled()); + expect(screen.queryByTestId("lane-router-1")).toBeNull(); + }); + + it("shows the lane's current profile selected", async () => { + vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] }); + render(); + expect(await screen.findByDisplayValue("9Router")).toBeInTheDocument(); + }); + + it("applies a switch through the router route, not a lane PATCH", async () => { + vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] }); + vi.mocked(api.lanes.routerApply).mockResolvedValue({ + tool: "claude", + file: "/work/demo/.claude/settings.local.json", + profile: "9router", + }); + render(); + + await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [ + "9router", + ]); + await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(1, "9router")); + expect(await screen.findByDisplayValue("9Router")).toBeInTheDocument(); + }); + + it("puts the selection back when the apply fails", async () => { + vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] }); + vi.mocked(api.lanes.routerApply).mockRejectedValue(new Error("EBADJSON")); + render(); + + await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [ + "9router", + ]); + await waitFor(() => expect(screen.getByText("EBADJSON")).toBeInTheDocument()); + expect(screen.getByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/run/__tests__/LaneConsolePane.test.tsx b/client/src/components/run/__tests__/LaneConsolePane.test.tsx index ac890763..75e7f9b1 100644 --- a/client/src/components/run/__tests__/LaneConsolePane.test.tsx +++ b/client/src/components/run/__tests__/LaneConsolePane.test.tsx @@ -37,6 +37,7 @@ vi.mock("../../../lib/api", () => ({ const LANE: Lane = { id: 1, + router_profile: null, title: "demo", cwd: "/workspace/a", branch: null, diff --git a/client/src/i18n/locales/en/lanes.json b/client/src/i18n/locales/en/lanes.json index 166df2cd..0fd274d0 100644 --- a/client/src/i18n/locales/en/lanes.json +++ b/client/src/i18n/locales/en/lanes.json @@ -142,5 +142,7 @@ "addLaneRouterHint": "Native talks to the vendor's own API. An AI Router profile is written into this lane's own config when it is created.", "addLaneRouterEmptyHint": "No router profile saved yet — add one under Settings › AI Router.", "addLaneSetupRouter": "Model endpoint", - "addLaneRouterLoading": "Loading router profiles…" + "addLaneRouterLoading": "Loading router profiles…", + "router.label": "AI Router", + "router.applied": "Applied. A session already running in this lane keeps the old one until it restarts." } diff --git a/client/src/i18n/locales/en/settings.json b/client/src/i18n/locales/en/settings.json index 8fbbb06e..af3e15a0 100644 --- a/client/src/i18n/locales/en/settings.json +++ b/client/src/i18n/locales/en/settings.json @@ -484,6 +484,10 @@ "cancel": "Cancel", "removeConfirm": "Remove the profile \"{{label}}\"? Every lane using it is reset to the vendor's own API right away — on disk and in the dashboard.", "loading": "Loading profiles…", - "lanesFailed": "Saved, but {{count}} lane could not be updated and still carries the old endpoint: {{lanes}}" + "lanesFailed": "Saved, but {{count}} lane could not be updated and still carries the old endpoint: {{lanes}}", + "test": "Test", + "testing": "Testing…", + "testOk": "The endpoint answered — {{model}} is reachable with this key.", + "testFailed": "No answer ({{status}}): {{message}}" } } diff --git a/client/src/i18n/locales/vi/lanes.json b/client/src/i18n/locales/vi/lanes.json index e6e9fa54..afe28874 100644 --- a/client/src/i18n/locales/vi/lanes.json +++ b/client/src/i18n/locales/vi/lanes.json @@ -142,5 +142,7 @@ "addLaneRouterHint": "Native là gọi thẳng API của hãng. Chọn profile AI Router thì cấu hình được ghi vào config riêng của lane này lúc tạo.", "addLaneRouterEmptyHint": "Chưa lưu profile router nào — thêm ở Settings › AI Router.", "addLaneSetupRouter": "Endpoint model", - "addLaneRouterLoading": "Đang tải danh sách router…" + "addLaneRouterLoading": "Đang tải danh sách router…", + "router.label": "AI Router", + "router.applied": "Đã áp dụng. Session đang chạy trong lane này vẫn dùng router cũ cho tới khi khởi động lại." } diff --git a/client/src/i18n/locales/vi/settings.json b/client/src/i18n/locales/vi/settings.json index 09b17b11..4baa3870 100644 --- a/client/src/i18n/locales/vi/settings.json +++ b/client/src/i18n/locales/vi/settings.json @@ -484,6 +484,10 @@ "cancel": "Huỷ", "removeConfirm": "Xoá profile \"{{label}}\"? Mọi lane đang dùng nó sẽ được đưa về API gốc của hãng ngay lập tức — cả trên đĩa lẫn trong dashboard.", "loading": "Đang tải danh sách profile…", - "lanesFailed": "Đã lưu, nhưng {{count}} lane không cập nhật được và vẫn đang giữ endpoint cũ: {{lanes}}" + "lanesFailed": "Đã lưu, nhưng {{count}} lane không cập nhật được và vẫn đang giữ endpoint cũ: {{lanes}}", + "test": "Kiểm tra", + "testing": "Đang kiểm tra…", + "testOk": "Endpoint đã trả lời — {{model}} dùng được với key này.", + "testFailed": "Không trả lời ({{status}}): {{message}}" } } diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index b4291d7a..19740d32 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -1891,6 +1891,15 @@ export const api = { method: "PATCH", body: JSON.stringify(body), }), + /** + * POST /api/ai-routers/test - ask the endpoint itself whether it works, + * before a profile that cannot reach anything is saved onto a lane. + */ + test: (body: AiRouterTestInput) => + request("/ai-routers/test", { + method: "POST", + body: JSON.stringify(body), + }), /** * DELETE /api/ai-routers/:name - also resets every lane using it back to * native, on disk as well as in the DB. @@ -2680,6 +2689,26 @@ export interface AiRouter { savedAt?: string; } +/** Outcome of POST /api/ai-routers/test. A router that answered with an error + * is still a successful HTTP call: `ok` is the ROUTER's verdict, `status` is + * its HTTP code (null when nothing answered at all), and `message` is its own + * words — truncated server-side, never the key. */ +export interface AiRouterTestResult { + ok: boolean; + status: number | null; + message: string; +} + +/** Body for POST /api/ai-routers/test. `name` lets the server reuse the stored + * key for a profile being edited — but only while `baseUrl` still matches the + * saved one, so a typed-in endpoint always needs a typed-in key. */ +export interface AiRouterTestInput { + baseUrl: string; + model: string; + apiKey?: string; + name?: string; +} + /** How an edit or a delete landed on the lanes already using that profile. * Attaching copies a profile, so the store alone is not the whole truth: a * lane listed in `failed` still carries the OLD key and endpoint. */ diff --git a/client/src/lib/types.ts b/client/src/lib/types.ts index 156233d6..f8e651f9 100644 --- a/client/src/lib/types.ts +++ b/client/src/lib/types.ts @@ -2420,6 +2420,11 @@ export interface Lane { kind: "managed" | "adopted"; /** The CLI this lane's runs spawn. Immutable once the lane exists. */ tool: LaneTool; + /** Name of the AI Router profile this lane runs against, or null for the + * vendor's own API. Unlike `tool` this IS changeable after creation, but + * only through `POST /api/lanes/:id/router/apply` — the column and the + * lane's own config on disk must move together. */ + router_profile: string | null; /** For a managed worktree lane, the repo it was provisioned from. Null for * an adopted lane (it IS a source repo, not a worktree of one). */ source_repo: string | null; diff --git a/client/src/pages/AiRouterConfig.tsx b/client/src/pages/AiRouterConfig.tsx index 85f2c82d..de4d9b18 100644 --- a/client/src/pages/AiRouterConfig.tsx +++ b/client/src/pages/AiRouterConfig.tsx @@ -18,6 +18,11 @@ * 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"; @@ -51,6 +56,11 @@ export function AiRouterConfig() { 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. @@ -77,6 +87,9 @@ export function AiRouterConfig() { void load(); }, [load]); + // Any edit to the form invalidates the last verdict — see `testResult`. + useEffect(() => setTestResult(null), [form]); + const startAdd = () => { setEditing(null); setForm(emptyForm()); @@ -105,6 +118,37 @@ export function AiRouterConfig() { ); }; + /** + * 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); @@ -329,7 +373,29 @@ export function AiRouterConfig() {
-
+ {testResult && ( +

+ {testResult.text} +

+ )} + +
+