feat: v2.5.5 — AI Router renamed AI Proxy, reload onto new builds, update no longer spins

AI Proxy (was AI Router):
- server/lib/ai-proxies.js, proxy-apply.js, routes/ai-proxies.js mounted at
  /api/ai-proxies and the old /api/ai-routers; `router` still accepted on the
  start routes; profiles move to <CLAUDE_HOME>/agenthub-proxies/
- dashboard_runs.router renamed in place to proxy: an older build on the same
  DB fails on that column, so dev and prod sharing one DB update together
- a proxy's baseUrl stored as its root (a typed /v1 made /v1/v1/messages);
  Test probes Claude /v1/messages and Codex /v1/responses
- Settings → AI Proxy (/settings/ai-router redirects), ProxyPicker

Updates:
- every tab reloads onto the new UI build when its WebSocket reconnects to a
  server of another version (lib/newBuildReload.ts, once per version)
- Update now stops waiting on a plugin chain that failed or pulled nothing
  (the old server never goes down), and reloads as soon as a new version answers

Also:
- a pane whose command exits at once says so (EPANEEXITED) instead of
  "Command failed: tmux set-option … mouse on"
- the pane drag preview draws where the pane actually lands
This commit is contained in:
2026-10-06 22:48:09 +07:00
parent dd9b23106c
commit 5fe5d51b1b
82 changed files with 1792 additions and 1412 deletions
+1 -1
View File
@@ -259,7 +259,7 @@ graph TB
Detail[SessionDetail<br/>agent hierarchy + timeline]
Feed[ActivityFeed<br/>streaming event log]
Workflows[Workflows<br/>Analytics → Workflow sub-tab:<br/>lane pipelines + D3 analytics]
Settings[SettingsHub /settings/:section<br/>General · Appearance · Notifications<br/>Agents: AI tools, Claude Code, Codex, each catalog tool<br/>Routing: AI Router, Pricing · Git: credentials, worktree suggestions]
Settings[SettingsHub /settings/:section<br/>General · Appearance · Notifications<br/>Agents: AI tools, Claude Code, Codex, each catalog tool<br/>Routing: AI Proxy · Cost: Pricing · Git: credentials, worktree suggestions]
App --> Layout
Layout --> Dashboard & Sessions & Detail & Feed & Settings
+5
View File
@@ -71,6 +71,7 @@ import { useWebSocket } from "./hooks/useWebSocket";
import { useNotifications } from "./hooks/useNotifications";
import { eventBus } from "./lib/eventBus";
import { copyOnCtrlShiftC } from "./lib/keys";
import { reloadOnNewBuild } from "./lib/newBuildReload";
import type { WSMessage } from "./lib/types";
// The heavy pages (xterm, the diff viewer, the settings editors) load on first
@@ -132,6 +133,10 @@ export default function App() {
return () => window.removeEventListener("vite:preloadError", onPreloadError);
}, []);
// The server came back as a newer version (any tab, any way it was updated):
// load its UI build instead of leaving the user to press F5.
useEffect(() => reloadOnNewBuild(), []);
// Ctrl+Shift+C is copy here (Linux terminal habit), not the browser's inspector.
useEffect(() => {
window.addEventListener("keydown", copyOnCtrlShiftC, true);
+37 -17
View File
@@ -85,6 +85,7 @@ import { Download, X, Copy, Check, RefreshCw, Zap, ExternalLink } from "lucide-r
import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import { copyText } from "../lib/clipboard";
import { toast } from "../lib/toast";
import { useDialog } from "../hooks/useDialog";
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
@@ -264,22 +265,41 @@ export function UpdateNotifier() {
* stretch, so polling naively would get a 200 from the process we are
* waiting to replace and reload onto the same old bundle.
*/
const pollUntilBack = useCallback((requireDownFirst = false) => {
let sawDown = !requireDownFirst;
const attempt = () => {
api.updates
.status()
.then(() => {
if (sawDown) window.location.reload();
else setTimeout(attempt, 1500);
})
.catch(() => {
sawDown = true;
setTimeout(attempt, 1500);
});
};
setTimeout(attempt, 1500);
}, []);
const pollUntilBack = useCallback(
(requireDownFirst = false, fromVersion: string | null = null) => {
let sawDown = !requireDownFirst;
// Its own verdicts only: an older plugin-update.json is still on disk
// until this chain writes one (5 s slack for a clock a little behind).
const since = Date.now() - 5000;
const attempt = () => {
api.updates
.status()
.then((s) => {
const v = s.last_plugin_update;
const mine = !!v?.finished_at && Date.parse(v.finished_at) >= since;
// A failed step, or nothing to pull, leaves the OLD server up: it
// never goes down, so waiting for that spun "Restarting…" forever.
if (requireDownFirst && v && mine && (v.ok === false || v.step === "already_current")) {
syncFromPayload(s);
setRestarting(false);
if (v.ok) toast.info(t("pluginAlreadyCurrent"));
return;
}
// A new version answering is back too, even if a poll never
// landed in the short gap between the old server and the new one.
const moved = !!fromVersion && !!s.local_version && s.local_version !== fromVersion;
if (sawDown || moved) window.location.reload();
else setTimeout(attempt, 1500);
})
.catch(() => {
sawDown = true;
setTimeout(attempt, 1500);
});
};
setTimeout(attempt, 1500);
},
[syncFromPayload, t]
);
const applyNow = async () => {
if (applying || restarting) return;
@@ -292,7 +312,7 @@ export function UpdateNotifier() {
setRestarting(true);
// mode "plugin": the old server lingers while the chain installs, so
// only treat a reachable server as "back" after it has gone away once.
pollUntilBack(result.mode === "plugin");
pollUntilBack(result.mode === "plugin", status?.local_version ?? null);
return;
}
setError(
@@ -222,6 +222,40 @@ describe("UpdateNotifier — Update now", () => {
});
});
describe("UpdateNotifier — plugin chain verdict", () => {
it("stops Restarting when the chain fails and the old server stays up", async () => {
const PLUGIN: UpdateStatusPayload = {
...BASE_STATUS,
situation: "plugin_managed",
install_kind: "plugin",
plugin_marketplace: "sg",
plugin_name: "agenthub",
commits_behind: 0,
local_version: "1.5.0",
release_check: "ok",
latest_release: "v1.6.0",
};
statusMock.mockResolvedValue(PLUGIN);
applyMock.mockResolvedValue({ ...PLUGIN, applied: true, mode: "plugin" });
render(<UpdateNotifier />);
await userEvent.click(await screen.findByText(i18n.t("updates:updateNow")));
await screen.findAllByText(i18n.t("updates:pluginUpdating"));
// The old server answers again, now carrying this chain's failed verdict.
statusMock.mockResolvedValue({
...PLUGIN,
last_plugin_update: {
ok: false,
step: "plugin_update",
error: "boom",
finished_at: new Date().toISOString(),
},
});
expect(await screen.findByText(/plugin_update/, {}, { timeout: 4000 })).toBeInTheDocument();
expect(screen.queryByText(i18n.t("updates:pluginUpdating"))).not.toBeInTheDocument();
});
});
describe("UpdateNotifier — closing vs skipping", () => {
it("Escape only closes for now; Skip this version is the persistent dismiss", async () => {
const { unmount } = render(<UpdateNotifier />);
@@ -88,7 +88,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
list: vi.fn(async () => ({ tools: [], default: "claude" })),
iconUrl: () => "",
},
aiRouters: { list: vi.fn(async () => ({ routers: [] })) },
aiProxies: { list: vi.fn(async () => ({ items: [] })) },
},
};
});
+17 -17
View File
@@ -7,7 +7,7 @@
* `ConsoleTabs.tsx`). Inside
* the box, like Claude Desktop's: AI (the session's own tool, the workspace's
* by default) and one Model chip (`ModelMenu`: the tool's own list or the
* router's models, plus Effort); under it the quieter choices — AI Router
* proxy's models, plus Effort); under it the quieter choices — AI Proxy
* (Native or a profile), Permission (Claude's mode, Codex's sandbox/approval,
* a catalog tool's bypass) and Local / New worktree — the workspace's Recents — the sessions it
* owns and has not archived (`?lane=&archived=0`, with each one's state dot,
@@ -58,7 +58,7 @@ import { useAiTools } from "../../hooks/useAiTools";
import { useProjectTreePrefs } from "../../lib/projectTreePrefs";
import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "../run/RunSetup";
import { AiToolIcon } from "../AiToolIcon";
import { RouterPicker, routerArg, useAiRouters, useSessionRouter } from "../run/RouterPicker";
import { ProxyPicker, proxyArg, useAiProxies, useSessionProxy } from "../run/ProxyPicker";
import { Select } from "../Select";
import { MarkdownPreview } from "../MarkdownPreview";
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./WorkspaceRowMenu";
@@ -143,25 +143,25 @@ function HomeBody({
}
}, [draftKey, prompt]);
const [images, setImages] = useState<PromptImage[]>([]);
const [router, setRouter] = useSessionRouter();
const routers = useAiRouters();
// Claude and Codex, also before the catalog has loaded (as RouterPicker).
const routerCapable = entry?.routerCapable ?? (claude || codex);
const [proxy, setProxy] = useSessionProxy();
const proxies = useAiProxies();
// Claude and Codex, also before the catalog has loaded (as ProxyPicker).
const proxyCapable = entry?.proxyCapable ?? (claude || codex);
// A profile picked for a tool that can use one; its models replace the tool's own.
const profile = routerCapable
? (routers?.find((r) => r.name === router && !r.builtin) ?? null)
const profile = proxyCapable
? (proxies?.find((r) => r.name === proxy && !r.builtin) ?? null)
: null;
const routerModels: ModelChoice[] | undefined = profile
const proxyModels: ModelChoice[] | undefined = profile
? [
...new Set(
[profile.model, profile.models.opus, profile.models.sonnet, profile.models.haiku].filter(
Boolean
(m): m is string => !!m
)
),
].map((id) => ({ id, label: id }))
: undefined;
// The tool's default model (also when the catalog arrives after mount); on
// a router, the router's own default. A new tool or router drops the pick.
// a proxy, the proxy's own default. A new tool or proxy drops the pick.
const defaultModel = profile ? "" : (entry?.defaultModel ?? "");
const [model, setModel] = useState(defaultModel);
useEffect(() => setModel(defaultModel), [defaultModel, tool, profile?.name]);
@@ -319,7 +319,7 @@ function HomeBody({
// Only a different tool is named: the workspace's own keeps starting
// even after it was disabled in Settings.
...(tool !== lane.tool ? { tool } : {}),
...routerArg(routerCapable, router),
...proxyArg(proxyCapable, proxy),
...(hasModel && model ? { model } : {}),
...(claude ? { permissionMode: mode } : {}),
...(codex ? { sandbox, approval } : {}),
@@ -533,7 +533,7 @@ function HomeBody({
value={model}
onChange={setModel}
tool={tool}
choices={routerModels}
choices={proxyModels}
{...(hasEffort ? { effort, onEffortChange: setEffort } : {})}
/>
)}
@@ -556,12 +556,12 @@ function HomeBody({
data-testid="workspace-home-options"
className="mt-1.5 flex flex-wrap items-center gap-0.5 px-1"
>
<RouterPicker
<ProxyPicker
variant="ghost"
tool={tool}
routers={routers}
value={router}
onChange={setRouter}
proxies={proxies}
value={proxy}
onChange={setProxy}
/>
{claude && (
<Select<PermissionMode>
@@ -31,7 +31,7 @@ vi.mock("../../../lib/api", () => ({
profileInit: vi.fn(),
mcpSync: vi.fn(),
},
aiRouters: { list: vi.fn() },
aiProxies: { list: vi.fn() },
aiTools: { list: vi.fn(), iconUrl: (id: string) => `/api/ai-tools/${id}/icon` },
run: { start: vi.fn() },
},
@@ -50,7 +50,7 @@ const tool = (id: string, label: string, over: Partial<AiTool> = {}): AiTool =>
prompt: { mode: "argv" },
resume: false,
bypass: false,
routerCapable: false,
proxyCapable: false,
install: null,
update: null,
modelFlag: false,
@@ -62,8 +62,8 @@ const tool = (id: string, label: string, over: Partial<AiTool> = {}): AiTool =>
const TOOLS: AiToolsView = {
default: "claude",
tools: [
tool("claude", "Claude Code", { launcher: "claude", routerCapable: true, prompt: null }),
tool("codex", "Codex", { launcher: "codex", routerCapable: true, prompt: null }),
tool("claude", "Claude Code", { launcher: "claude", proxyCapable: true, prompt: null }),
tool("codex", "Codex", { launcher: "codex", proxyCapable: true, prompt: null }),
tool("gemini", "Gemini"),
tool("aider", "Aider", { installed: false, path: null }),
tool("grok", "Grok", { enabled: false }),
@@ -158,7 +158,7 @@ beforeEach(() => {
.mockReset()
.mockResolvedValue({ scaffolded: false, reason: "no detectable Node.js project" });
vi.mocked(api.lanes.mcpSync).mockReset().mockResolvedValue({ servers: [], profilesSeeded: [] });
vi.mocked(api.aiRouters.list).mockReset();
vi.mocked(api.aiProxies.list).mockReset();
});
describe("AddLaneModal — picking an existing branch (issue #34)", () => {
@@ -639,12 +639,12 @@ describe("AddLaneModal — create/update readme", () => {
});
});
describe("AddLaneModal — no router here", () => {
it("asks for no AI Router: a new session picks its own", async () => {
describe("AddLaneModal — no proxy here", () => {
it("asks for no AI Proxy: a new session picks its own", async () => {
renderModal({});
await screen.findByRole("button", { name: /Claude Code.*Default/ });
expect(screen.queryByRole("radio", { name: "AI Router" })).toBeNull();
expect(api.aiRouters.list).not.toHaveBeenCalled();
expect(screen.queryByRole("radio", { name: "AI Proxy" })).toBeNull();
expect(api.aiProxies.list).not.toHaveBeenCalled();
});
});
@@ -28,7 +28,7 @@ vi.mock("../../../lib/api", () => ({
locks: {
list: vi.fn(),
},
aiRouters: {
aiProxies: {
list: vi.fn(),
},
run: {
@@ -45,10 +45,10 @@ beforeEach(() => {
// stack, so the runtime strip and its button stay absent unless a test opts in.
vi.mocked(api.lanes.runtime).mockReset();
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
// Only the built-in profile by default: a user who never opened the AI Router
// Only the built-in profile by default: a user who never opened the AI Proxy
// 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.aiProxies.list).mockReset();
vi.mocked(api.aiProxies.list).mockResolvedValue({ items: [] });
});
vi.mocked(api.locks.list).mockReset();
vi.mocked(api.locks.list).mockResolvedValue({ locks: [] });
@@ -2,8 +2,8 @@
* @file WorkspaceHome.test.tsx
* @description The workspace home: a new session starts in the first free
* pane slot and opens it, on the AI tool picked for it (the workspace's by
* default), AI and Model in the box and AI Router / Permission / Worktree
* under it (the router's models on a router), all-busy is refused, Recents open a live pane or the
* default), AI and Model in the box and AI Proxy / Permission / Worktree
* under it (the proxy's models on a proxy), all-busy is refused, Recents open a live pane or the
* transcript (never a resume), instructions save, context files upload and
* remove, a folder is added with the repo's untracked worktrees suggested
* (minus hidden sources), an unsupported tool is said so, and memory opens
@@ -117,7 +117,7 @@ vi.mock("../../../lib/api", async (importOriginal) => {
})),
},
aiTools: { list: vi.fn(async () => ({ tools: [], default: "claude" })), iconUrl: () => "" },
aiRouters: { list: vi.fn(async () => ({ items: [] })) },
aiProxies: { list: vi.fn(async () => ({ items: [] })) },
},
};
});
@@ -241,7 +241,7 @@ describe("WorkspaceHome", () => {
await waitFor(() => expect(localStorage.getItem("agenthub.workspace.draft.1")).toBeNull());
});
it("keeps AI and Model (with effort) in the box, Router/Permission/Worktree under it", async () => {
it("keeps AI and Model (with effort) in the box, Proxy/Permission/Worktree under it", async () => {
renderHome();
await screen.findByTestId("workspace-recent-s-live");
const labels = (id: string) =>
@@ -252,7 +252,7 @@ describe("WorkspaceHome", () => {
].map((x) => x.getAttribute("aria-label"));
expect(labels("workspace-home-composer")).toEqual(["AI", "model"]);
expect(labels("workspace-home-options")).toEqual([
"AI Router",
"AI Proxy",
"Permission mode",
expect.stringContaining("own git worktree"),
]);
@@ -263,8 +263,8 @@ describe("WorkspaceHome", () => {
vi.mocked(api.aiTools.list).mockResolvedValue({
default: "claude",
tools: [
{ id: "claude", label: "Claude Code", launcher: "claude", routerCapable: true },
{ id: "codex", label: "Codex", launcher: "codex", routerCapable: true, bypass: true },
{ id: "claude", label: "Claude Code", launcher: "claude", proxyCapable: true },
{ id: "codex", label: "Codex", launcher: "codex", proxyCapable: true, bypass: true },
{ id: "cursor", label: "Cursor CLI", installed: false, enabled: true },
].map((x) => ({ installed: true, enabled: true, ...x })),
} as never);
@@ -289,9 +289,9 @@ describe("WorkspaceHome", () => {
__resetAiTools();
});
it("offers the router's models on a router, and sends the router", async () => {
localStorage.setItem("agenthub.session.router", "glm");
vi.mocked(api.aiRouters.list).mockResolvedValueOnce({
it("offers the proxy's models on a proxy, and sends the proxy", async () => {
localStorage.setItem("agenthub.session.proxy", "glm");
vi.mocked(api.aiProxies.list).mockResolvedValueOnce({
items: [
{ name: "native", label: "Native", builtin: true, models: {} },
{
@@ -316,7 +316,7 @@ describe("WorkspaceHome", () => {
expect(api.lanes.action).toHaveBeenCalledWith(
1,
"start",
expect.objectContaining({ router: "glm" })
expect.objectContaining({ proxy: "glm" })
);
});
+2 -8
View File
@@ -14,7 +14,7 @@
* toasts. The `+` menu opens a new tab in the free slot the server picks
* (`slot: "auto"`) — a plain shell (`POST /api/lanes/:id/start {shell: true}`)
* or an installed, enabled AI tool started bare on its default model, no
* prompt (a prompt, router or worktree is still the workspace home's prompt
* prompt (a prompt, proxy or worktree is still the workspace home's prompt
* box) — plus a link to Settings → Agents. Ten sessions is the server's limit
* (`MAX_PANE_SLOT`). The bar is the console's only header: Home (the
* workspace's home) at its left end, `actions` (the single pane's split menus)
@@ -51,13 +51,7 @@ import { AiToolIcon } from "../AiToolIcon";
import { freeSlot, startInFreeTab } from "../lanes/startSession";
import { PANE_DRAG_TYPE, RunIcon, startPaneDrag, type GroupsUpdate } from "./PaneGrid";
import { insertionIndex } from "../../lib/laneOrderStorage";
import {
type PaneGroups,
detachSlot,
groupOf,
leaves,
reorderGroups,
} from "../../lib/paneLayout";
import { type PaneGroups, detachSlot, groupOf, leaves, reorderGroups } from "../../lib/paneLayout";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
type AiToolsState = ReturnType<typeof useAiTools>;
+25 -17
View File
@@ -106,20 +106,27 @@ export function usePaneGroups(laneId: number | null): [PaneGroups, GroupsUpdate]
return [current.groups, update];
}
type Zone = Edge | "center";
export type Zone = Edge | "center";
const pct = (r: Rect) => ({
left: `${r.x * 100}%`,
top: `${r.y * 100}%`,
width: `${r.w * 100}%`,
height: `${r.h * 100}%`,
});
/** The half (or the whole) of a pane a drop zone would fill. */
function zoneRect(r: Rect, zone: Zone): Rect {
if (zone === "left") return { ...r, w: r.w / 2 };
if (zone === "right") return { ...r, x: r.x + r.w / 2, w: r.w / 2 };
if (zone === "top") return { ...r, h: r.h / 2 };
if (zone === "bottom") return { ...r, y: r.y + r.h / 2, h: r.h / 2 };
return r;
/** What dropping `src` on a pane's zone does: the center swaps, an edge
* splits. The drop applies it and the preview draws it, so they agree. */
export function applyDrop(l: PaneLayout, src: number, slot: number, zone: Zone): PaneLayout {
return zone === "center" ? swapInLayout(l, src, slot) : moveInLayout(l, src, slot, zone);
}
/** Where `src` would land, or null for a drop that changes nothing. A pane
* already shown leaves its place first, so its target grows before the
* split — the target's own half is not where it lands. */
export function dropPreview(l: PaneLayout, src: number, slot: number, zone: Zone): Rect | null {
if (src === slot) return null;
const next = applyDrop(l, src, slot, zone);
if (next === l) return null;
return layoutRects(next.root).panes.find((p) => p.slot === src) ?? null;
}
/** A small anchored menu: a trigger button and a list that closes on a pick,
@@ -323,7 +330,7 @@ export function PaneGrid({
}) {
const { t } = useTranslation("lanes");
const gridRef = useRef<HTMLDivElement>(null);
const [drop, setDrop] = useState<{ slot: number; zone: Zone } | null>(null);
const [drop, setDrop] = useState<{ src: number; slot: number; zone: Zone } | null>(null);
const { panes, dividers } = layoutRects(layout.root);
const shown = leaves(layout.root);
const rectOf = new Map(panes.map((p) => [p.slot, p]));
@@ -391,6 +398,8 @@ export function PaneGrid({
return { slot: p.slot, zone: dist < 0.25 ? edge : "center" };
};
const preview = drop && dropPreview(layout, drop.src, drop.slot, drop.zone);
const isPaneDrag = (e: React.DragEvent) =>
dragSlot !== null || !!e.dataTransfer?.types?.includes(PANE_DRAG_TYPE);
@@ -404,7 +413,10 @@ export function PaneGrid({
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
const z = zoneAt(e);
setDrop((d) => (d?.slot === z?.slot && d?.zone === z?.zone ? d : z));
const next = z && dragSlot !== null ? { src: dragSlot, ...z } : null;
setDrop((d) =>
d?.src === next?.src && d?.slot === next?.slot && d?.zone === next?.zone ? d : next
);
}}
onDragLeave={(e) => {
if (!gridRef.current?.contains(e.relatedTarget as Node | null)) setDrop(null);
@@ -417,11 +429,7 @@ export function PaneGrid({
dragSlot = null;
setDrop(null);
if (!z || !Number.isInteger(src) || src === z.slot) return;
onChange((l) =>
z.zone === "center"
? swapInLayout(l, src, z.slot)
: moveInLayout(l, src, z.slot, z.zone as Edge)
);
onChange((l) => applyDrop(l, src, z.slot, z.zone));
}}
>
{mounted.map((slot) => {
@@ -561,11 +569,11 @@ export function PaneGrid({
}
/>
))}
{drop && rectOf.has(drop.slot) && (
{preview && (
<div
data-testid="pane-drop-preview"
className="pointer-events-none absolute z-20 rounded-md border-2 border-accent/60 bg-accent/15 transition-all duration-100"
style={pct(zoneRect(rectOf.get(drop.slot) as Rect, drop.zone))}
style={pct(preview)}
/>
)}
</div>
+104
View File
@@ -0,0 +1,104 @@
/**
* @file ProxyPicker.tsx
* @description The AI Proxy a NEW session starts on — a session's choice, not
* its workspace's (`proxy` on `POST /api/lanes/:id/start`, `server/lib/proxy-apply.js`):
* Native or one of the saved profiles (`useAiProxies`, which the caller also
* reads to list the proxy's models). A tool that cannot use a proxy (only
* Claude and Codex can) shows Native, disabled. The last pick is remembered
* for the next session (`agenthub.session.proxy`; the pre-rename
* `agenthub.session.router` is still read when that is unset).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, type AiProxy } from "../../lib/api";
import { useAiTools } from "../../hooks/useAiTools";
import { Select } from "../Select";
const KEY = "agenthub.session.proxy";
const LEGACY_KEY = "agenthub.session.router";
/** The remembered pick, `"native"` by default. */
export function useSessionProxy(): [string, (v: string) => void] {
const [value, setValue] = useState(() => {
try {
return localStorage.getItem(KEY) || localStorage.getItem(LEGACY_KEY) || "native";
} catch {
return "native";
}
});
const set = (v: string) => {
setValue(v);
try {
localStorage.setItem(KEY, v);
} catch {
/* storage off — the pick still holds for this page */
}
};
return [value, set];
}
/** The saved profiles (Native included), null until they arrive. */
export function useAiProxies(): AiProxy[] | null {
const [proxies, setProxies] = useState<AiProxy[] | null>(null);
useEffect(() => {
let alive = true;
api.aiProxies
.list()
.then((r) => alive && setProxies(r.items))
.catch(() => alive && setProxies([]));
return () => {
alive = false;
};
}, []);
return proxies;
}
/** What to send as `proxy` for `tool`: nothing for native or a tool that has none. */
export function proxyArg(proxyCapable: boolean | undefined, proxy: string): { proxy?: string } {
return proxyCapable && proxy && proxy !== "native" ? { proxy } : {};
}
export function ProxyPicker({
tool,
proxies,
value,
onChange,
variant,
}: {
variant?: "field" | "ghost";
tool: string;
/** From `useAiProxies`. */
proxies: AiProxy[] | null;
value: string;
onChange: (v: string) => void;
}) {
const { t } = useTranslation(["lanes"]);
const aiTools = useAiTools();
const capable = aiTools.byId(tool)?.proxyCapable ?? (tool === "claude" || tool === "codex");
const native = t("proxy.native", { tool: aiTools.label(tool) });
// A remembered profile that has since been deleted falls back to native,
// or the start would be refused for a proxy the user cannot see.
useEffect(() => {
if (proxies && value !== "native" && !proxies.some((r) => r.name === value)) onChange("native");
}, [proxies, value, onChange]);
const options =
capable && proxies?.length
? proxies.map((r) => ({ value: r.name, label: r.builtin ? native : r.label }))
: [{ value: "native", label: native }];
return (
<div data-testid="proxy-picker">
<Select<string>
value={capable ? value : "native"}
onChange={onChange}
options={options}
disabled={!capable}
variant={variant}
label={t("proxy.label")}
/>
</div>
);
}
-102
View File
@@ -1,102 +0,0 @@
/**
* @file RouterPicker.tsx
* @description The AI Router a NEW session starts on — a session's choice, not
* its workspace's (`router` on `POST /api/lanes/:id/start`, `server/lib/router-apply.js`):
* Native or one of the saved profiles (`useAiRouters`, which the caller also
* reads to list the router's models). A tool that cannot use a router (only
* Claude and Codex can) shows Native, disabled. The last pick is remembered
* for the next session (`agenthub.session.router`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, type AiRouter } from "../../lib/api";
import { useAiTools } from "../../hooks/useAiTools";
import { Select } from "../Select";
const KEY = "agenthub.session.router";
/** The remembered pick, `"native"` by default. */
export function useSessionRouter(): [string, (v: string) => void] {
const [value, setValue] = useState(() => {
try {
return localStorage.getItem(KEY) || "native";
} catch {
return "native";
}
});
const set = (v: string) => {
setValue(v);
try {
localStorage.setItem(KEY, v);
} catch {
/* storage off — the pick still holds for this page */
}
};
return [value, set];
}
/** The saved profiles (Native included), null until they arrive. */
export function useAiRouters(): AiRouter[] | null {
const [routers, setRouters] = useState<AiRouter[] | null>(null);
useEffect(() => {
let alive = true;
api.aiRouters
.list()
.then((r) => alive && setRouters(r.items))
.catch(() => alive && setRouters([]));
return () => {
alive = false;
};
}, []);
return routers;
}
/** What to send as `router` for `tool`: nothing for native or a tool that has none. */
export function routerArg(routerCapable: boolean | undefined, router: string): { router?: string } {
return routerCapable && router && router !== "native" ? { router } : {};
}
export function RouterPicker({
tool,
routers,
value,
onChange,
variant,
}: {
variant?: "field" | "ghost";
tool: string;
/** From `useAiRouters`. */
routers: AiRouter[] | null;
value: string;
onChange: (v: string) => void;
}) {
const { t } = useTranslation(["lanes"]);
const aiTools = useAiTools();
const capable = aiTools.byId(tool)?.routerCapable ?? (tool === "claude" || tool === "codex");
const native = t("router.native", { tool: aiTools.label(tool) });
// A remembered profile that has since been deleted falls back to native,
// or the start would be refused for a router the user cannot see.
useEffect(() => {
if (routers && value !== "native" && !routers.some((r) => r.name === value)) onChange("native");
}, [routers, value, onChange]);
const options =
capable && routers?.length
? routers.map((r) => ({ value: r.name, label: r.builtin ? native : r.label }))
: [{ value: "native", label: native }];
return (
<div data-testid="router-picker">
<Select<string>
value={capable ? value : "native"}
onChange={onChange}
options={options}
disabled={!capable}
variant={variant}
label={t("router.label")}
/>
</div>
);
}
+4 -4
View File
@@ -10,7 +10,7 @@
* (`POST /api/run/drop-image`);
* - `CwdAutocomplete` — the folder field the Add-workspace modal uses;
* - `ModelPicker` — a tool's own model list (`GET /api/ai-tools/:id/models`)
* or a fixed one (an AI Router's models), plus Custom…;
* or a fixed one (an AI Proxy's models), plus Custom…;
* - `ModelMenu` — the same list as ONE composer chip, Claude Desktop's
* "Sonnet 5.5 · Medium": the main models with their hints, the rest under
* More models, Custom…, and the Effort slider (Faster → Smarter, a reset
@@ -671,7 +671,7 @@ const MODEL_CUSTOM = "__custom__";
/**
* The models `tool` can start on, the inherit entry (`""`) first: the tool's
* own list (Codex's models cache, Claude's aliases named after the newest
* version, a catalog tool's vendor rows), or `fixed` (a router's models);
* version, a catalog tool's vendor rows), or `fixed` (a proxy's models);
* Claude's static aliases only when the server cannot answer. `loaded` once
* the list is final.
*/
@@ -707,7 +707,7 @@ export function ModelPicker({
value: string;
onChange: (s: string) => void;
tool: LaneTool;
/** A fixed list instead of the tool's own — an AI Router's models. */
/** A fixed list instead of the tool's own — an AI Proxy's models. */
choices?: ModelChoice[];
variant?: "field" | "ghost";
}) {
@@ -781,7 +781,7 @@ export function ModelMenu({
value: string;
onChange: (s: string) => void;
tool: LaneTool;
/** A fixed list instead of the tool's own — an AI Router's models. */
/** A fixed list instead of the tool's own — an AI Proxy's models. */
choices?: ModelChoice[];
/** Omitted for a tool that takes no effort. */
effort?: EffortLevel;
@@ -31,7 +31,7 @@ vi.mock("../../../lib/api", () => ({
action: vi.fn(),
list: vi.fn(),
},
aiRouters: { list: vi.fn().mockResolvedValue({ items: [] }) },
aiProxies: { list: vi.fn().mockResolvedValue({ items: [] }) },
aiTools: {
list: vi.fn().mockResolvedValue({ default: "claude", tools: [] }),
models: vi.fn().mockResolvedValue({ flag: true, models: [], defaultModel: null }),
@@ -0,0 +1,36 @@
/**
* @file PaneGrid.test.ts
* @description The pane drop preview: it draws where the dragged pane will
* actually land (`dropPreview` over `applyDrop`), not half of the target's
* current box, and nothing for a drop that changes nothing.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it } from "vitest";
import { dropPreview } from "../PaneGrid";
import { pane, split, type PaneLayout } from "../../../lib/paneLayout";
const AB: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 0 };
describe("dropPreview", () => {
it("a shown pane leaves its place first: A onto B's top is the whole top half", () => {
expect(dropPreview(AB, 0, 1, "top")).toEqual({ slot: 0, x: 0, y: 0, w: 1, h: 0.5 });
});
it("a pane from another tab splits the target's current box", () => {
expect(dropPreview(AB, 2, 1, "bottom")).toEqual({ slot: 2, x: 0.5, y: 0.5, w: 0.5, h: 0.5 });
});
it("the center swaps into the target's box", () => {
expect(dropPreview(AB, 0, 1, "center")).toEqual({ slot: 0, x: 0.5, y: 0, w: 0.5, h: 1 });
});
it("nothing on its own pane or on a refused move", () => {
expect(dropPreview(AB, 0, 0, "left")).toBeNull();
const full: PaneLayout = {
root: split("row", split("col", pane(0), pane(1)), split("col", pane(2), pane(3))),
focus: 0,
};
expect(dropPreview(full, 5, 0, "left")).toBeNull();
});
});
@@ -1,16 +1,16 @@
/**
* @file RouterPicker.test.tsx
* @description The per-session AI Router picker: Native plus the saved
* profiles, Native alone and disabled on a tool that cannot use a router,
* @file ProxyPicker.test.tsx
* @description The per-session AI Proxy picker: Native plus the saved
* profiles, Native alone and disabled on a tool that cannot use a proxy,
* remembering the pick, and falling back to Native when the remembered
* profile is gone; plus `routerArg`, which never sends native.
* profile is gone; plus `proxyArg`, which never sends native.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { act, render, renderHook, screen, waitFor } from "@testing-library/react";
import type { AiRouter } from "../../../lib/api";
import type { AiProxy } from "../../../lib/api";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { RouterPicker, routerArg, useAiRouters, useSessionRouter } from "../RouterPicker";
import { ProxyPicker, proxyArg, useAiProxies, useSessionProxy } from "../ProxyPicker";
import { api } from "../../../lib/api";
vi.mock("../../../lib/api", async (importOriginal) => {
@@ -22,67 +22,72 @@ vi.mock("../../../lib/api", async (importOriginal) => {
list: vi.fn().mockResolvedValue({
default: "claude",
tools: [
{ id: "claude", label: "Claude Code", routerCapable: true },
{ id: "gemini", label: "Gemini", routerCapable: false },
{ id: "claude", label: "Claude Code", proxyCapable: true },
{ id: "gemini", label: "Gemini", proxyCapable: false },
].map((x) => ({ ...x, installed: true, enabled: true })),
}),
},
aiRouters: { list: vi.fn() },
aiProxies: { list: vi.fn() },
},
};
});
const native = { name: "native", label: "Native", builtin: true } as AiRouter;
const nine = { name: "9router", label: "9Router", builtin: false } as AiRouter;
const native = { name: "native", label: "Native", builtin: true } as AiProxy;
const nine = { name: "9router", label: "9Router", builtin: false } as AiProxy;
beforeEach(() => {
localStorage.clear();
vi.mocked(api.aiRouters.list)
vi.mocked(api.aiProxies.list)
.mockReset()
.mockResolvedValue({ items: [native, nine] } as never);
});
describe("RouterPicker", () => {
describe("ProxyPicker", () => {
it("lists the saved profiles for a tool that can use one", async () => {
render(
<RouterPicker tool="claude" routers={[native, nine]} value="9router" onChange={() => {}} />
<ProxyPicker tool="claude" proxies={[native, nine]} value="9router" onChange={() => {}} />
);
expect(await screen.findByText("9Router")).toBeInTheDocument();
});
it("shows Native, disabled, on a tool with no endpoint to point at a router", () => {
it("shows Native, disabled, on a tool with no endpoint to point at a proxy", () => {
render(
<RouterPicker tool="gemini" routers={[native, nine]} value="9router" onChange={() => {}} />
<ProxyPicker tool="gemini" proxies={[native, nine]} value="9router" onChange={() => {}} />
);
const picker = screen.getByTestId("router-picker");
const picker = screen.getByTestId("proxy-picker");
expect(picker.querySelector("button")).toBeDisabled();
expect(picker).not.toHaveTextContent("9Router");
});
it("falls back to Native when the remembered profile is gone", async () => {
const onChange = vi.fn();
render(<RouterPicker tool="claude" routers={[native]} value="deleted" onChange={onChange} />);
render(<ProxyPicker tool="claude" proxies={[native]} value="deleted" onChange={onChange} />);
await waitFor(() => expect(onChange).toHaveBeenCalledWith("native"));
});
it("useAiRouters reads the saved profiles once", async () => {
const { result } = renderHook(() => useAiRouters());
it("useAiProxies reads the saved profiles once", async () => {
const { result } = renderHook(() => useAiProxies());
await waitFor(() => expect(result.current).toEqual([native, nine]));
expect(api.aiRouters.list).toHaveBeenCalledTimes(1);
expect(api.aiProxies.list).toHaveBeenCalledTimes(1);
});
});
describe("useSessionRouter / routerArg", () => {
describe("useSessionProxy / proxyArg", () => {
it("remembers the last pick for the next session", () => {
const first = renderHook(() => useSessionRouter());
const first = renderHook(() => useSessionProxy());
expect(first.result.current[0]).toBe("native");
act(() => first.result.current[1]("9router"));
expect(renderHook(() => useSessionRouter()).result.current[0]).toBe("9router");
expect(renderHook(() => useSessionProxy()).result.current[0]).toBe("9router");
});
it("sends a router only for a capable tool and never native", () => {
expect(routerArg(true, "9router")).toEqual({ router: "9router" });
expect(routerArg(true, "native")).toEqual({});
expect(routerArg(false, "9router")).toEqual({});
it("still reads the pick remembered before the rename", () => {
localStorage.setItem("agenthub.session.router", "9router");
expect(renderHook(() => useSessionProxy()).result.current[0]).toBe("9router");
});
it("sends a proxy only for a capable tool and never native", () => {
expect(proxyArg(true, "9router")).toEqual({ proxy: "9router" });
expect(proxyArg(true, "native")).toEqual({});
expect(proxyArg(false, "9router")).toEqual({});
});
});
@@ -4,7 +4,7 @@
* `PromptEditor`'s `/` and `@` autocomplete (Enter picks while the list is
* open, `/` only where slash commands apply), Shift/Alt+Enter newlines, a
* pasted image shown as a thumbnail and sent as its saved path, and `ModelPicker` — a tool's own
* list from the server, a fixed list (an AI Router's models) when given one,
* list from the server, a fixed list (an AI Proxy's models) when given one,
* and Custom…, which is a sentinel, never a model id — and `ModelMenu`, the
* same list as one chip: aliases up front, pinned versions under More models,
* plus the effort slider.
@@ -211,7 +211,7 @@ describe("ModelPicker", () => {
expect(await screen.findByText("GPT-6-Sol")).toBeTruthy();
});
it("offers only a fixed list when given one (an AI Router's models)", async () => {
it("offers only a fixed list when given one (an AI Proxy's models)", async () => {
render(
<ModelPicker
value=""
@@ -4,7 +4,7 @@
* computed from — as shown on a tool's Settings tab (Overview for Claude Code
* and Codex, the Antigravity tab itself): Claude
* Code's shows `claude-…` rows, Codex's shows OpenAI rows (`gpt-…`, `o<n>…`,
* `codex-…`), Antigravity's Gemini rows (`gemini-…`). It is ONE table (a router model can serve either tool), so a
* `codex-…`), Antigravity's Gemini rows (`gemini-…`). It is ONE table (a proxy model can serve either tool), so a
* **Show all models** switch lists every row, and a row added outside the
* filter turns it on rather than vanishing. Reset Defaults reseeds Claude's
* rates, so only the Claude tab offers it; the server re-fetches any row the
@@ -49,7 +49,7 @@ const tool = (id: string, label: string, over: Partial<AiTool> = {}): AiTool =>
prompt: { mode: "argv" },
resume: false,
bypass: false,
routerCapable: false,
proxyCapable: false,
install: null,
update: null,
modelFlag: false,
+2 -2
View File
@@ -215,8 +215,8 @@
"proof.ticketReport": "Task report",
"renameLabel": "Rename this workspace",
"reorderHint": "Drag to reorder · Alt+←/→",
"router.native": "Native ({{tool}})",
"router.label": "AI Router",
"proxy.native": "Native ({{tool}})",
"proxy.label": "AI Proxy",
"runtime.boot": "▶ up",
"runtime.busy.down": "stopping…",
"runtime.busy.up": "booting…",
+13 -13
View File
@@ -577,13 +577,13 @@
"fieldLabelPlaceholder": "Dev box"
},
"cursorPathsNote": "Informational: Cursor sessions count here too — Cursor happens to use the same ~/.claude paths as Claude Code (locally and on synced remotes).",
"aiRouter": {
"title": "AI Router",
"aiProxy": {
"title": "AI Proxy",
"description": "For example 9Router, OpenRouter or a self-hosted gateway. Save one here, then pick it for a session in the workspace prompt box.",
"add": "Add profile",
"edit": "Edit",
"remove": "Remove",
"empty": "No router profile saved yet. Native runs against the vendor's own API.",
"empty": "No proxy profile saved yet. Native runs against the vendor's own API.",
"builtinHint": "Built in — this is the vendor's own API, and it cannot be edited or removed.",
"colLabel": "Profile",
"colBaseUrl": "Endpoint",
@@ -592,16 +592,16 @@
"keySet": "Saved",
"keyMissing": "None",
"fieldLabel": "Name",
"fieldLabelHint": "What you will see in the workspace form. Free text.",
"fieldLabelHint": "What you will see in the session's AI Proxy picker. Free text.",
"fieldBaseUrl": "Base URL",
"fieldBaseUrlHint": "The router's OpenAI/Anthropic-compatible endpoint, e.g. http://localhost:20128/v1",
"fieldBaseUrlHint": "The proxy's root URL, without /v1 — e.g. http://localhost:20128. Claude Code adds /v1/messages and Codex /v1/responses; a /v1 you type is dropped.",
"fieldApiKey": "API key",
"fieldApiKeyHint": "Stored on this machine at 0600 and never sent back to the browser.",
"fieldApiKeyKeep": "Leave blank to keep the saved key.",
"fieldModel": "Model",
"fieldModelHint": "Used for every tier unless you override one below. Many routers accept one symbolic name.",
"fieldModelHint": "Used for every tier unless you override one below. Many proxies accept one symbolic name.",
"fieldBehavesAs": "Behaves as (Claude Code)",
"fieldBehavesAsHint": "Optional. The Claude model id Claude Code should treat this router's model names as (effort defaults, context window), e.g. claude-opus-5-5. Fill it in when Claude Code says a model isn't described by its model catalog, and name the model the router really sends to. While set, it replaces your own modelPicker setting in these sessions.",
"fieldBehavesAsHint": "Optional. The Claude model id Claude Code should treat this proxy's model names as (effort defaults, context window), e.g. claude-opus-5-5. Fill it in when Claude Code says a model isn't described by its model catalog, and name the model the proxy really sends to. While set, it replaces your own modelPicker setting in these sessions.",
"perTier": "Per-tier overrides",
"perTierHint": "Leave a field blank to use the model above.",
"tier": {
@@ -613,12 +613,12 @@
},
"save": "Save",
"cancel": "Cancel",
"removeConfirm": "Remove the profile \"{{label}}\"? Every workspace using it is reset to the vendor's own API right away — on disk and in the dashboard.",
"removeConfirm": "Remove the profile \"{{label}}\"? Its session files go with it, and a session that ran on it resumes on the vendor's own API.",
"loading": "Loading profiles…",
"test": "Test",
"testing": "Testing…",
"testOk": "The endpoint answered — {{model}} is reachable with this key.",
"testFailed": "No answer ({{status}}): {{message}}"
"testOk": "answered — {{model}} is reachable with this key.",
"testFailed": "no answer ({{status}}): {{message}}"
},
"aiTools": {
"title": "AI Tools",
@@ -700,7 +700,7 @@
},
"codexHome": {
"title": "CODEX_HOME",
"description": "Root directory for Codex config and session journals. The dashboard reads it, writes AI Router layers into it, and starts every codex workspace with it.",
"description": "Root directory for Codex config and session journals. The dashboard reads it, writes AI Proxy layers into it, and starts every codex workspace with it.",
"restartNote": "A codex workspace already running keeps the home it started with, and shows as idle, until it is restarted.",
"placeholder": "/path/to/codex-home"
},
@@ -792,8 +792,8 @@
"tool": {
"description": "{{tool}}: path, usage, default model and config files."
},
"aiRouter": {
"title": "AI Router",
"aiProxy": {
"title": "AI Proxy",
"description": "Endpoints that stand in front of Claude Code and Codex."
},
"pricing": {
+1
View File
@@ -25,6 +25,7 @@
"releaseUnavailable": "Could not read the latest release from the repository ({{reason}}); fell back to comparing commits.",
"pluginAutoNote": "This dashboard is installed as a Claude Code plugin. \"Update now\" pulls the new plugin version, reinstalls the runtime, and restarts the server for you — it takes a few minutes. Newly added skills and slash commands only appear in an already-open Claude Code session after you run /reload-plugins there.",
"pluginUpdating": "Pulling the new plugin version and reinstalling the runtime — this takes a few minutes, the page reloads itself when the new server is up...",
"pluginAlreadyCurrent": "No newer plugin version was pulled, so the running dashboard was left as it is.",
"lastPluginUpdateFailed": "The last plugin update failed at step \"{{step}}\": {{error}} — run /agenthub-update in Claude Code to recover.",
"restartingShort": "Restarting…",
"installedTitle": "Version {{version}} is installed but not running",
+2 -2
View File
@@ -215,8 +215,8 @@
"proof.ticketReport": "Báo cáo nhiệm vụ",
"renameLabel": "Đổi tên workspace này",
"reorderHint": "Kéo để sắp xếp · Alt+←/→",
"router.native": "Native ({{tool}})",
"router.label": "AI Router",
"proxy.native": "Native ({{tool}})",
"proxy.label": "AI Proxy",
"runtime.boot": "▶ chạy",
"runtime.busy.down": "đang dừng…",
"runtime.busy.up": "đang khởi động…",
+13 -13
View File
@@ -577,13 +577,13 @@
"fieldLabelPlaceholder": "Máy dev"
},
"cursorPathsNote": "Chỉ mang tính thông tin: phiên Cursor cũng được tính — Cursor tình cờ dùng cùng đường dẫn ~/.claude với Claude Code (cục bộ và trên máy từ xa đã đồng bộ).",
"aiRouter": {
"title": "AI Router",
"aiProxy": {
"title": "AI Proxy",
"description": "Ví dụ 9Router, OpenRouter hoặc gateway tự dựng. Lưu ở đây rồi chọn cho từng session ở ô nhập prompt của workspace.",
"add": "Thêm profile",
"edit": "Sửa",
"remove": "Xoá",
"empty": "Chưa lưu profile router nào. Native là chạy thẳng vào API của hãng.",
"empty": "Chưa lưu profile proxy nào. Native là chạy thẳng vào API của hãng.",
"builtinHint": "Dựng sẵn — đây là API của chính hãng, không sửa và không xoá được.",
"colLabel": "Profile",
"colBaseUrl": "Endpoint",
@@ -592,16 +592,16 @@
"keySet": "Đã lưu",
"keyMissing": "Chưa có",
"fieldLabel": "Tên",
"fieldLabelHint": "Tên bạn sẽ thấy trong form tạo workspace. Gõ tự do.",
"fieldLabelHint": "Tên bạn sẽ thấy trong ô chọn AI Proxy của session. Gõ tự do.",
"fieldBaseUrl": "Base URL",
"fieldBaseUrlHint": "Endpoint tương thích OpenAI/Anthropic của router, ví dụ http://localhost:20128/v1",
"fieldBaseUrlHint": "URL gốc của proxy, không có /v1 — ví dụ http://localhost:20128. Claude Code tự thêm /v1/messages, Codex tự thêm /v1/responses; /v1 bạn gõ vào sẽ được bỏ đi.",
"fieldApiKey": "API key",
"fieldApiKeyHint": "Lưu trên máy này ở quyền 0600, không bao giờ gửi ngược về trình duyệt.",
"fieldApiKeyKeep": "Để trống nếu muốn giữ key đã lưu.",
"fieldModel": "Model",
"fieldModelHint": "Dùng cho mọi bậc trừ khi bạn ghi đè bên dưới. Nhiều router chỉ cần một tên tượng trưng.",
"fieldModelHint": "Dùng cho mọi bậc trừ khi bạn ghi đè bên dưới. Nhiều proxy chỉ cần một tên tượng trưng.",
"fieldBehavesAs": "Hoạt động như (Claude Code)",
"fieldBehavesAsHint": "Không bắt buộc. Model id của Claude mà Claude Code dùng để hiểu các tên model của router này (mặc định effort, context window), ví dụ claude-opus-5-5. Điền khi Claude Code báo một model không có trong model catalog của nó, và chọn đúng model router thật sự chuyển tới. Khi đã đặt, nó thay thiết lập modelPicker của bạn trong các phiên này.",
"fieldBehavesAsHint": "Không bắt buộc. Model id của Claude mà Claude Code dùng để hiểu các tên model của proxy này (mặc định effort, context window), ví dụ claude-opus-5-5. Điền khi Claude Code báo một model không có trong model catalog của nó, và chọn đúng model proxy thật sự chuyển tới. Khi đã đặt, nó thay thiết lập modelPicker của bạn trong các phiên này.",
"perTier": "Ghi đè theo từng bậc",
"perTierHint": "Để trống một ô là dùng model ở trên.",
"tier": {
@@ -613,12 +613,12 @@
},
"save": "Lưu",
"cancel": "Huỷ",
"removeConfirm": "Xoá profile \"{{label}}\"? Mọi workspace đ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.",
"removeConfirm": "Xoá profile \"{{label}}\"? Các file session của nó bị xoá theo, và một session từng chạy trên nó sẽ resume bằng API gốc của hãng.",
"loading": "Đang tải danh sách profile…",
"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}}"
"testOk": "đã trả lời — {{model}} dùng được với key này.",
"testFailed": "không trả lời ({{status}}): {{message}}"
},
"aiTools": {
"title": "AI Tools",
@@ -700,7 +700,7 @@
},
"codexHome": {
"title": "CODEX_HOME",
"description": "Thư mục gốc chứa config và nhật ký phiên của Codex. Dashboard đọc từ đây, ghi layer AI Router vào đây, và mở mọi workspace codex với thư mục này.",
"description": "Thư mục gốc chứa config và nhật ký phiên của Codex. Dashboard đọc từ đây, ghi layer AI Proxy vào đây, và mở mọi workspace codex với thư mục này.",
"restartNote": "Workspace codex đang chạy vẫn dùng thư mục cũ, và hiện là idle, cho tới khi được khởi động lại.",
"placeholder": "/đường-dẫn/tới/codex-home"
},
@@ -792,8 +792,8 @@
"tool": {
"description": "{{tool}}: đường dẫn, mức dùng, mô hình mặc định và file cấu hình."
},
"aiRouter": {
"title": "AI Router",
"aiProxy": {
"title": "AI Proxy",
"description": "Các endpoint đứng trước Claude Code và Codex."
},
"pricing": {
+1
View File
@@ -25,6 +25,7 @@
"releaseUnavailable": "Không đọc được bản phát hành mới nhất từ repo ({{reason}}); đã quay về so sánh theo commit.",
"pluginAutoNote": "Dashboard này cài dạng plugin của Claude Code. Bấm \"Cập nhật ngay\" sẽ tự tải bản plugin mới, cài lại runtime và khởi động lại server — mất vài phút. Riêng skill và slash command mới thì session Claude Code đang mở chỉ thấy sau khi bạn chạy /reload-plugins ở đó.",
"pluginUpdating": "Đang tải bản plugin mới và cài lại runtime — mất vài phút, trang sẽ tự reload khi server mới lên...",
"pluginAlreadyCurrent": "Không có phiên bản plugin mới nào được tải về, nên dashboard đang chạy được giữ nguyên.",
"lastPluginUpdateFailed": "Lần cập nhật plugin trước thất bại ở bước \"{{step}}\": {{error}} — chạy /agenthub-update trong Claude Code để khắc phục.",
"restartingShort": "Đang khởi động lại…",
"installedTitle": "Đã cài bản {{version}} nhưng chưa chạy",
@@ -0,0 +1,58 @@
/**
* @file Tests for newBuildReload: after a reconnect the tab reloads once when
* the server's version is not this bundle's, never on the first connect,
* never for the same version, and not twice for one version.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { eventBus } from "../eventBus";
import { reloadOnNewBuild } from "../newBuildReload";
const flush = () => new Promise((r) => setTimeout(r, 0));
function setup(serverVersion: string) {
const reload = vi.fn();
const store = new Map<string, string>();
const storage = {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
};
const off = reloadOnNewBuild({
bundle: "2.5.4",
health: () => Promise.resolve({ version: serverVersion }),
reload,
storage,
});
return { reload, off };
}
let off: (() => void) | undefined;
afterEach(() => off?.());
describe("reloadOnNewBuild", () => {
it("reloads once after a reconnect onto a new version", async () => {
const s = setup("2.5.5");
off = s.off;
eventBus.setConnected(true); // first connect: nothing to compare against yet
await flush();
expect(s.reload).not.toHaveBeenCalled();
eventBus.setConnected(false);
eventBus.setConnected(true);
await flush();
expect(s.reload).toHaveBeenCalledTimes(1);
eventBus.setConnected(false); // same version again: a failed UI build must not loop
eventBus.setConnected(true);
await flush();
expect(s.reload).toHaveBeenCalledTimes(1);
});
it("stays put when the server is this bundle's version", async () => {
const s = setup("2.5.4");
off = s.off;
eventBus.setConnected(false);
eventBus.setConnected(true);
await flush();
expect(s.reload).not.toHaveBeenCalled();
});
});
+53 -44
View File
@@ -2099,13 +2099,6 @@ export const api = {
}),
},
// ──────────────────────────────── AI Router API ─────────────────────────────
/** Named AI Router endpoints (9Router, OpenRouter, a self-hosted gateway) a
* lane can be pointed at instead of the vendor's own API. Managed in the
* **AI Router** tab of Settings; attached to a lane when the lane is created.
*
* No response here ever carries an API key — a saved profile reports only
* `hasKey`. */
/** AgentHub's own git credentials (issue #45), shared by every user of this
* install. No response ever carries a password — only `hasToken`. */
gitCredentials: {
@@ -2219,37 +2212,44 @@ export const api = {
return `${BASE}/ai-tools/${encodeURIComponent(id)}/icon${q}`;
},
},
aiRouters: {
/** GET /api/ai-routers - every profile, the built-in `native` one first. */
list: () => request<{ items: AiRouter[] }>("/ai-routers"),
/** POST /api/ai-routers - save a new profile. */
create: (body: AiRouterInput) =>
request<AiRouter>("/ai-routers", { method: "POST", body: JSON.stringify(body) }),
// ──────────────────────────────── AI Proxy API ──────────────────────────────
/** Named AI Proxy endpoints (9Router, OpenRouter, a self-hosted gateway) a
* new session can run on instead of the vendor's own API. Managed in the
* **AI Proxy** page of Settings; chosen per session (`proxy` on a start).
*
* No response here ever carries an API key — a saved profile reports only
* `hasKey`. */
aiProxies: {
/** GET /api/ai-proxies - every profile, the built-in `native` one first. */
list: () => request<{ items: AiProxy[] }>("/ai-proxies"),
/** POST /api/ai-proxies - save a new profile. */
create: (body: AiProxyInput) =>
request<AiProxy>("/ai-proxies", { method: "POST", body: JSON.stringify(body) }),
/**
* PATCH /api/ai-routers/:name - change a saved profile. Omitting `apiKey`
* PATCH /api/ai-proxies/:name - change a saved profile. Omitting `apiKey`
* keeps the stored one, which is what the edit form always does: it cannot
* display a key, so it has none to send back.
*/
update: (name: string, body: Partial<AiRouterInput>) =>
request<AiRouter>(`/ai-routers/${encodeURIComponent(name)}`, {
update: (name: string, body: Partial<AiProxyInput>) =>
request<AiProxy>(`/ai-proxies/${encodeURIComponent(name)}`, {
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.
* POST /api/ai-proxies/test - ask the endpoint itself whether it works
* for Claude Code and for Codex, before a session finds out it does not.
*/
test: (body: AiRouterTestInput) =>
request<AiRouterTestResult>("/ai-routers/test", {
test: (body: AiProxyTestInput) =>
request<AiProxyTestResult>("/ai-proxies/test", {
method: "POST",
body: JSON.stringify(body),
}),
/**
* DELETE /api/ai-routers/:name - also removes the session files that
* DELETE /api/ai-proxies/:name - also removes the session files that
* copy its key.
*/
remove: (name: string) =>
request<{ removed: string }>(`/ai-routers/${encodeURIComponent(name)}`, {
request<{ removed: string }>(`/ai-proxies/${encodeURIComponent(name)}`, {
method: "DELETE",
}),
},
@@ -3307,10 +3307,10 @@ export interface CcProfileUsage {
* user + project scope in one response. */
export type CcScope = "user" | "project" | "all";
/** The per-tier model names an AI Router profile carries. Each maps to one env
* var Claude Code reads; a router that names its models once fills them all
/** The per-tier model names an AI Proxy profile carries. Each maps to one env
* var Claude Code reads; a proxy that names its models once fills them all
* from the profile's `model`. */
export interface AiRouterModels {
export interface AiProxyModels {
opus: string;
sonnet: string;
haiku: string;
@@ -3318,7 +3318,6 @@ export interface AiRouterModels {
subagent: string;
}
/** One saved AI Router profile, as the API returns it — **never** with a key. */
/** A saved git login, as the server reports it: never the password. */
export interface GitCredential {
protocol: "http" | "https";
@@ -3362,8 +3361,8 @@ export interface AiTool {
resume: boolean;
/** Offers the per-run "Bypass permissions" switch. */
bypass: boolean;
/** Can use an AI Router profile (Claude, Codex); others are native only. */
routerCapable: boolean;
/** Can use an AI Proxy profile (Claude, Codex); others are native only. */
proxyCapable: boolean;
/** What Install runs; null when the catalog names no install. */
install: { kind: "npm"; package: string } | { kind: "script"; url: string } | null;
/** What Update runs; `self` is the tool's own updater with these args. */
@@ -3421,37 +3420,47 @@ export interface AiToolJob {
log: string;
}
export interface AiRouter {
/** Identifier, derived from the label. Stable: a lane stores this. */
/** One saved AI Proxy profile, as the API returns it — **never** with a key. */
export interface AiProxy {
/** Identifier, derived from the label. Stable: a session's run row stores it. */
name: string;
label: string;
/** True only for the read-only `native` profile (the vendor's own API). */
builtin: boolean;
/** Empty string for `native`, an http(s) URL otherwise. */
/** Empty string for `native`, else the proxy's http(s) ROOT, without `/v1`. */
baseUrl: string;
/** Whether a key is stored. The key itself never leaves the server. */
hasKey: boolean;
/** "" for `native`, which has no model of its own. */
model: string;
models: AiRouterModels;
/** Claude model id the router's own model names behave as; "" when unset. */
/** Empty for `native`. */
models: Partial<AiProxyModels>;
/** Claude model id the proxy's own model names behave as; "" when unset. */
behavesAs?: string;
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 {
/** One probe of POST /api/ai-proxies/test. A proxy that answered with an
* error is still a successful HTTP call: `ok` is the PROXY'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 AiProxyProbe {
ok: boolean;
status: number | null;
message: string;
}
/** Body for POST /api/ai-routers/test. `name` lets the server reuse the stored
/** Outcome of POST /api/ai-proxies/test: Claude Code's `/v1/messages` and
* Codex's `/v1/responses`, probed side by side. */
export interface AiProxyTestResult {
claude: AiProxyProbe;
codex: AiProxyProbe;
}
/** Body for POST /api/ai-proxies/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 {
export interface AiProxyTestInput {
baseUrl: string;
model: string;
apiKey?: string;
@@ -3459,14 +3468,14 @@ export interface AiRouterTestInput {
}
/** Body accepted when creating or updating a profile. */
export interface AiRouterInput {
export interface AiProxyInput {
label: string;
baseUrl: string;
/** Omit on update to keep the stored key. */
apiKey?: string;
model: string;
/** Any tier left out is filled from `model`. */
models?: Partial<AiRouterModels>;
models?: Partial<AiProxyModels>;
/** A Claude model id; "" clears it. Omit on update to keep the stored one. */
behavesAs?: string;
}
@@ -4124,8 +4133,8 @@ export interface RunStartArgs {
resumeLast?: boolean;
/** Turn off the tool's permission prompts for THIS run (its catalog bypass args). */
bypass?: boolean;
/** AI Router profile THIS session runs on; omitted = native, or a resume's own. */
router?: string;
/** AI Proxy profile THIS session runs on; omitted = native, or a resume's own. */
proxy?: string;
}
/** A lane's tmux-backed terminal run — one per lane, id is the tmux session
+46
View File
@@ -0,0 +1,46 @@
/**
* @file newBuildReload.ts
* @description Reloads the tab onto the new UI build after the server was
* updated. Every update — the dashboard's own Update button in any tab,
* `/agenthub-update`, a restart onto a newer plugin — drops the WebSocket; when
* it comes back, `GET /api/health` names the server's version, and a version
* other than this bundle's `__APP_VERSION__` means the page is stale. At most
* one reload per version (sessionStorage), so a server whose UI build failed
* cannot make the tab reload on every reconnect.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { eventBus } from "./eventBus";
const KEY = "agenthub.reloadedFor";
export function reloadOnNewBuild({
bundle = __APP_VERSION__,
health = () => fetch("/api/health").then((r) => r.json() as Promise<{ version?: string }>),
reload = () => window.location.reload(),
storage = window.sessionStorage,
}: {
bundle?: string;
health?: () => Promise<{ version?: string }>;
reload?: () => void;
storage?: Pick<Storage, "getItem" | "setItem">;
} = {}): () => void {
let wasDown = false;
return eventBus.onConnection((up) => {
if (!up) {
wasDown = true;
return;
}
if (!wasDown) return;
wasDown = false;
health()
.then(({ version }) => {
if (!version || version === bundle || storage.getItem(KEY) === version) return;
storage.setItem(KEY, version);
reload();
})
.catch(() => {
/* the next reconnect asks again */
});
});
}
+1 -1
View File
@@ -259,7 +259,7 @@ export const SETTINGS_INDEX: {
{ section: "agents/codex", titleKey: "codexConfig:tabs.plugins", to: "?tab=plugins" },
{ section: "agents/antigravity", titleKey: "antigravityHooks.title", keywords: ["agy", "hooks"] },
{ section: "agents/*", titleKey: "toolTab.defaultModel", keywords: ["model", "config"] },
{ section: "ai-router", titleKey: "aiRouter.title", keywords: ["proxy", "endpoint", "key"] },
{ section: "ai-proxy", titleKey: "aiProxy.title", keywords: ["router", "endpoint", "key"] },
{ section: "pricing", titleKey: "pricing.title", keywords: ["cost", "price", "tokens"] },
{ section: "git-credentials", titleKey: "gitCreds.title", keywords: ["login", "token"] },
{ section: "worktrees", titleKey: "worktrees.showUntracked", keywords: ["hide", "suggestions"] },
+3 -4
View File
@@ -2652,10 +2652,9 @@ 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. */
/** Legacy: the per-workspace AI Proxy (then "AI Router") profile. A proxy
* is a session's choice now and the server empties this at every start, so
* it is always null. */
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). */
@@ -1,16 +1,16 @@
/**
* @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.
* @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 router (the workspace home's prompt box, a pane's
* run form), not here. An edit here reaches the sessions already on a profile
* 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
* router" rather than a special case.
* 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.
@@ -21,28 +21,35 @@
* A right-click on a profile row opens its Edit / Remove (plus Copy endpoint)
* as a menu; the buttons stay.
*
* **Test** asks the endpoint itself before the profile is saved onto a lane.
* **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 lane's session otherwise, where the error surfaces
* as a broken CLI rather than a bad URL.
* 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ĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Plus, Pencil, Trash2, Lock } from "lucide-react";
import { api, type AiRouter, type AiRouterInput, type AiRouterModels } from "../lib/api";
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";
const TIERS: (keyof AiRouterModels)[] = ["opus", "sonnet", "haiku", "smallFast", "subagent"];
const TIERS: (keyof AiProxyModels)[] = ["opus", "sonnet", "haiku", "smallFast", "subagent"];
type FormState = Omit<AiRouterInput, "apiKey" | "models" | "behavesAs"> & {
type FormState = Omit<AiProxyInput, "apiKey" | "models" | "behavesAs"> & {
apiKey: string;
behavesAs: string;
models: Partial<AiRouterModels>;
models: Partial<AiProxyModels>;
};
const emptyForm = (): FormState => ({
@@ -54,11 +61,11 @@ const emptyForm = (): FormState => ({
behavesAs: "",
});
export function AiRouterConfig() {
export function AiProxyConfig() {
const { t } = useTranslation("settings");
const [items, setItems] = useState<AiRouter[]>([]);
const [items, setItems] = useState<AiProxy[]>([]);
const [error, setError] = useState<string | null>(null);
const [editing, setEditing] = useState<AiRouter | null>(null);
const [editing, setEditing] = useState<AiProxy | null>(null);
const [form, setForm] = useState(emptyForm());
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
@@ -66,14 +73,14 @@ export function AiRouterConfig() {
const [formError, setFormError] = useState<string | null>(null);
const dialogRef = useRef<HTMLDivElement>(null);
useDialog(open, dialogRef, () => setOpen(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);
// 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<AiRouter>();
const ctx = useContextMenu<AiProxy>();
// A menu item: close the menu, then act on the row it was opened on.
const pick = (fn: (item: AiRouter) => unknown) => () => {
const pick = (fn: (item: AiProxy) => unknown) => () => {
const item = ctx.menu?.item;
ctx.close();
if (item) fn(item);
@@ -91,7 +98,7 @@ export function AiRouterConfig() {
// vanish. Keeping the message untranslated is the cheaper half of the trade.
const load = useCallback(async () => {
try {
setItems((await api.aiRouters.list()).items);
setItems((await api.aiProxies.list()).items);
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
@@ -114,7 +121,7 @@ export function AiRouterConfig() {
setOpen(true);
};
const startEdit = (item: AiRouter) => {
const startEdit = (item: AiProxy) => {
setEditing(item);
// No apiKey: the server never sent one. Blank means "keep what is stored".
setForm({
@@ -129,7 +136,6 @@ export function AiRouterConfig() {
setOpen(true);
};
/** Report any lane the server could not re-point, or clear a stale report. */
/**
* 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
@@ -139,23 +145,23 @@ export function AiRouterConfig() {
setTesting(true);
setTestResult(null);
try {
const result = await api.aiRouters.test({
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 } : {}),
});
setTestResult({
ok: result.ok,
text: result.ok
? t("aiRouter.testOk", { model: result.message })
: t("aiRouter.testFailed", {
status: result.status ?? "—",
message: result.message,
}),
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) });
setTestResult([{ ok: false, text: e instanceof Error ? e.message : String(e) }]);
} finally {
setTesting(false);
}
@@ -167,13 +173,13 @@ export function AiRouterConfig() {
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> = {};
// enough for a proxy that wants the same value everywhere.
const models: Partial<AiProxyModels> = {};
for (const tier of TIERS) {
const value = (form.models[tier] || "").trim();
if (value && value !== form.model.trim()) models[tier] = value;
}
const body: AiRouterInput = {
const body: AiProxyInput = {
label: form.label.trim(),
baseUrl: form.baseUrl.trim(),
model: form.model.trim(),
@@ -182,8 +188,8 @@ export function AiRouterConfig() {
};
if (form.apiKey.trim()) body.apiKey = form.apiKey.trim();
if (editing) await api.aiRouters.update(editing.name, body);
else await api.aiRouters.create(body);
if (editing) await api.aiProxies.update(editing.name, body);
else await api.aiProxies.create(body);
setOpen(false);
await load();
@@ -194,17 +200,17 @@ export function AiRouterConfig() {
}
};
const remove = async (item: AiRouter) => {
const remove = async (item: AiProxy) => {
if (
!(await confirmDialog({
title: t("aiRouter.removeConfirm", { label: item.label }),
confirmLabel: t("aiRouter.remove"),
title: t("aiProxy.removeConfirm", { label: item.label }),
confirmLabel: t("aiProxy.remove"),
}))
)
return;
setError(null);
try {
await api.aiRouters.remove(item.name);
await api.aiProxies.remove(item.name);
await load();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
@@ -215,14 +221,14 @@ export function AiRouterConfig() {
<div className="space-y-4">
<div className="flex items-start justify-between gap-4">
{/* The page header above already names the section. */}
<p className="max-w-2xl text-xs text-fg-muted">{t("aiRouter.description")}</p>
<p className="max-w-2xl text-xs text-fg-muted">{t("aiProxy.description")}</p>
<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")}
{t("aiProxy.add")}
</button>
</div>
@@ -236,10 +242,10 @@ export function AiRouterConfig() {
<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 font-medium">{t("aiProxy.colLabel")}</th>
<th className="px-3 py-2 font-medium">{t("aiProxy.colBaseUrl")}</th>
<th className="px-3 py-2 font-medium">{t("aiProxy.colModel")}</th>
<th className="px-3 py-2 font-medium">{t("aiProxy.colKey")}</th>
<th className="px-3 py-2" />
</tr>
</thead>
@@ -257,27 +263,25 @@ export function AiRouterConfig() {
</span>
{item.builtin && (
<span className="mt-0.5 block text-[10px] text-fg-muted">
{t("aiRouter.builtinHint")}
{t("aiProxy.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 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")}
{item.builtin ? "—" : item.hasKey ? t("aiProxy.keySet") : t("aiProxy.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}`}
aria-label={`${t("aiProxy.edit")} ${item.label}`}
onClick={() => startEdit(item)}
className="rounded p-1 text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary"
>
@@ -285,7 +289,7 @@ export function AiRouterConfig() {
</button>
<button
type="button"
aria-label={`${t("aiRouter.remove")} ${item.label}`}
aria-label={`${t("aiProxy.remove")} ${item.label}`}
onClick={() => remove(item)}
className="rounded p-1 text-fg-muted transition-colors hover:bg-surface-3 hover:text-status-danger"
>
@@ -299,7 +303,7 @@ export function AiRouterConfig() {
{(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")}
{loading ? t("aiProxy.loading") : t("aiProxy.empty")}
</td>
</tr>
)}
@@ -307,8 +311,8 @@ export function AiRouterConfig() {
</table>
</div>
{ctx.menu && (
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="ai-router-menu">
<MenuItem onSelect={pick(startEdit)}>{t("aiRouter.edit")}</MenuItem>
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="ai-proxy-menu">
<MenuItem onSelect={pick(startEdit)}>{t("aiProxy.edit")}</MenuItem>
{ctx.menu.item.baseUrl && (
<MenuItem onSelect={pick((item) => void copyText(item.baseUrl))}>
{t("rowMenu.copyUrl")}
@@ -316,7 +320,7 @@ export function AiRouterConfig() {
)}
<MenuSeparator />
<MenuItem danger onSelect={pick((item) => void remove(item))}>
{t("aiRouter.remove")}
{t("aiProxy.remove")}
</MenuItem>
</ContextMenu>
)}
@@ -330,36 +334,36 @@ export function AiRouterConfig() {
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="router-form-title"
aria-labelledby="proxy-form-title"
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 id="router-form-title" className="mb-3 text-sm font-semibold text-fg-primary">
{editing ? t("aiRouter.edit") : t("aiRouter.add")}
<h3 id="proxy-form-title" className="mb-3 text-sm font-semibold text-fg-primary">
{editing ? t("aiProxy.edit") : t("aiProxy.add")}
</h3>
<div className="space-y-3">
<Field
id="router-label"
label={t("aiRouter.fieldLabel")}
hint={t("aiRouter.fieldLabelHint")}
id="proxy-label"
label={t("aiProxy.fieldLabel")}
hint={t("aiProxy.fieldLabelHint")}
value={form.label}
onChange={(v) => setForm((f) => ({ ...f, label: v }))}
/>
<Field
id="router-base-url"
label={t("aiRouter.fieldBaseUrl")}
hint={t("aiRouter.fieldBaseUrlHint")}
id="proxy-base-url"
label={t("aiProxy.fieldBaseUrl")}
hint={t("aiProxy.fieldBaseUrlHint")}
value={form.baseUrl}
mono
onChange={(v) => setForm((f) => ({ ...f, baseUrl: v }))}
/>
<Field
id="router-api-key"
label={t("aiRouter.fieldApiKey")}
id="proxy-api-key"
label={t("aiProxy.fieldApiKey")}
hint={
editing && editing.hasKey
? t("aiRouter.fieldApiKeyKeep")
: t("aiRouter.fieldApiKeyHint")
? t("aiProxy.fieldApiKeyKeep")
: t("aiProxy.fieldApiKeyHint")
}
value={form.apiKey}
type="password"
@@ -367,17 +371,17 @@ export function AiRouterConfig() {
onChange={(v) => setForm((f) => ({ ...f, apiKey: v }))}
/>
<Field
id="router-model"
label={t("aiRouter.fieldModel")}
hint={t("aiRouter.fieldModelHint")}
id="proxy-model"
label={t("aiProxy.fieldModel")}
hint={t("aiProxy.fieldModelHint")}
value={form.model}
mono
onChange={(v) => setForm((f) => ({ ...f, model: v }))}
/>
<Field
id="router-behaves-as"
label={t("aiRouter.fieldBehavesAs")}
hint={t("aiRouter.fieldBehavesAsHint")}
id="proxy-behaves-as"
label={t("aiProxy.fieldBehavesAs")}
hint={t("aiProxy.fieldBehavesAsHint")}
value={form.behavesAs}
mono
placeholder="claude-opus-5-5"
@@ -386,15 +390,15 @@ export function AiRouterConfig() {
<details className="rounded-lg border border-border bg-surface-2 p-2">
<summary className="cursor-pointer text-xs text-fg-secondary">
{t("aiRouter.perTier")}
{t("aiProxy.perTier")}
</summary>
<p className="mt-1 text-[10px] text-fg-muted">{t("aiRouter.perTierHint")}</p>
<p className="mt-1 text-[10px] text-fg-muted">{t("aiProxy.perTierHint")}</p>
<div className="mt-2 space-y-2">
{TIERS.map((tier) => (
<Field
key={tier}
id={`router-model-${tier}`}
label={t(`aiRouter.tier.${tier}`)}
id={`proxy-model-${tier}`}
label={t(`aiProxy.tier.${tier}`)}
value={form.models[tier] || ""}
mono
placeholder={form.model}
@@ -416,35 +420,36 @@ export function AiRouterConfig() {
</p>
)}
{testResult && (
{testResult?.map((r) => (
<p
data-testid="router-test-result"
key={r.text}
data-testid="proxy-test-result"
className={`mt-3 rounded-md border px-3 py-2 text-xs ${
testResult.ok
r.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}
{r.text}
</p>
)}
))}
<div className="mt-4 flex items-center justify-end gap-2">
<button
type="button"
data-testid="router-test"
data-testid="proxy-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")}
{testing ? t("aiProxy.testing") : t("aiProxy.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")}
{t("aiProxy.cancel")}
</button>
<button
type="button"
@@ -452,7 +457,7 @@ export function AiRouterConfig() {
disabled={busy || !form.label.trim() || !form.baseUrl.trim() || !form.model.trim()}
className="btn-primary px-3 py-1.5 text-xs"
>
{t("aiRouter.save")}
{t("aiProxy.save")}
</button>
</div>
</div>
+11 -6
View File
@@ -46,7 +46,7 @@ import { Settings } from "./Settings";
import { CcConfig } from "./CcConfig";
import { CodexConfig } from "./CodexConfig";
import { AiToolConfig } from "./AiToolConfig";
import { AiRouterConfig } from "./AiRouterConfig";
import { AiProxyConfig } from "./AiProxyConfig";
import { GitCredentialsConfig } from "./GitCredentialsConfig";
import { AppearanceSettings } from "../components/settings/AppearanceSettings";
import { SnapshotSettings } from "../components/settings/SnapshotSettings";
@@ -82,7 +82,7 @@ const LEGACY_TOOL: Record<string, string> = {
dashboard: "appearance",
"claude-code": "agents/claude-code",
codex: "agents/codex",
"ai-router": "ai-router",
"ai-router": "ai-proxy",
"git-credentials": "git-credentials",
};
@@ -117,7 +117,12 @@ function toolNavIcon(id: string, label: string) {
return Icon;
}
const MOVED: Record<string, string> = { general: "appearance", pet: "appearance#pet" };
const MOVED: Record<string, string> = {
general: "appearance",
pet: "appearance#pet",
// The AI Router → AI Proxy rename.
"ai-router": "ai-proxy",
};
/** An unknown section: say so, then show Appearance. */
function UnknownSection({ name }: { name: string }) {
@@ -290,11 +295,11 @@ export function SettingsHub() {
render: () => <AiToolConfig key={x.id} toolId={x.id} />,
})),
{
id: "ai-router",
id: "ai-proxy",
group: "routing",
Icon: Router,
...s("aiRouter"),
render: () => <AiRouterConfig />,
...s("aiProxy"),
render: () => <AiProxyConfig />,
},
{
id: "pricing",
@@ -0,0 +1,288 @@
/**
* @file Tests for the AI Proxy settings page — listing profiles, creating one,
* and the two rules that keep a key safe: the form never shows one, and an
* edit that leaves the field blank must not send an empty key that would wipe
* the stored one.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AiProxyConfig } from "../AiProxyConfig";
import { api } from "../../lib/api";
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../lib/confirm";
vi.mock("../../lib/api", () => ({
api: {
aiProxies: {
list: vi.fn(),
create: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
test: vi.fn(),
},
},
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) =>
opts && typeof opts.label === "string" ? `${key}:${opts.label}` : key,
}),
}));
const native = {
name: "native",
label: "Native (Claude / Codex)",
builtin: true,
baseUrl: "",
hasKey: false,
model: "",
models: {},
};
const nineRouter = {
...native,
name: "9router",
label: "9Router",
builtin: false,
baseUrl: "http://localhost:20128/v1",
hasKey: true,
model: "Highest",
models: {
opus: "Highest",
sonnet: "Highest",
haiku: "Highest",
smallFast: "Highest",
subagent: "Highest",
},
};
beforeEach(() => {
vi.mocked(api.aiProxies.list)
.mockReset()
.mockResolvedValue({ items: [native] });
vi.mocked(api.aiProxies.create).mockReset().mockResolvedValue(nineRouter);
vi.mocked(api.aiProxies.update).mockReset().mockResolvedValue(nineRouter);
vi.mocked(api.aiProxies.remove).mockReset().mockResolvedValue({ removed: "9router" });
vi.mocked(api.aiProxies.test)
.mockReset()
.mockResolvedValue({
claude: { ok: true, status: 200, message: "Highest" },
codex: { ok: true, status: 200, message: "Highest" },
});
});
describe("AiProxyConfig", () => {
it("lists the built-in native profile with no edit or delete control", async () => {
render(<AiProxyConfig />);
expect(await screen.findByText("Native (Claude / Codex)")).toBeInTheDocument();
expect(screen.queryByLabelText(/aiProxy\.edit Native/)).toBeNull();
expect(screen.queryByLabelText(/aiProxy\.remove Native/)).toBeNull();
});
it("saves a new profile with the typed endpoint, key and model", async () => {
render(<AiProxyConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiProxy\.add/ }));
await user.type(screen.getByLabelText("aiProxy.fieldLabel"), "9Router");
await user.type(screen.getByLabelText("aiProxy.fieldBaseUrl"), "http://localhost:20128/v1");
await user.type(screen.getByLabelText("aiProxy.fieldApiKey"), "sk-typed");
await user.type(screen.getByLabelText("aiProxy.fieldModel"), "Highest");
await user.click(screen.getByRole("button", { name: "aiProxy.save" }));
await waitFor(() =>
expect(api.aiProxies.create).toHaveBeenCalledWith({
label: "9Router",
baseUrl: "http://localhost:20128/v1",
model: "Highest",
// Every tier equals the headline model, so none is sent: the server
// fills them, which is what makes one field enough.
models: {},
behavesAs: "",
apiKey: "sk-typed",
})
);
});
it("sends the typed behavesAs, which maps the proxy's model names in Claude Code (#56)", async () => {
render(<AiProxyConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiProxy\.add/ }));
await user.type(screen.getByLabelText("aiProxy.fieldLabel"), "9Router");
await user.type(screen.getByLabelText("aiProxy.fieldBaseUrl"), "http://localhost:20128/v1");
await user.type(screen.getByLabelText("aiProxy.fieldModel"), "agent");
await user.type(screen.getByLabelText("aiProxy.fieldBehavesAs"), "claude-opus-5-5");
await user.click(screen.getByRole("button", { name: "aiProxy.save" }));
await waitFor(() =>
expect(api.aiProxies.create).toHaveBeenCalledWith(
expect.objectContaining({ model: "agent", behavesAs: "claude-opus-5-5" })
)
);
});
it("sends only the tiers that differ from the headline model", async () => {
render(<AiProxyConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiProxy\.add/ }));
await user.type(screen.getByLabelText("aiProxy.fieldLabel"), "9Router");
await user.type(screen.getByLabelText("aiProxy.fieldBaseUrl"), "http://localhost:20128/v1");
await user.type(screen.getByLabelText("aiProxy.fieldModel"), "Highest");
await user.type(screen.getByLabelText("aiProxy.tier.opus"), "PremiumCombo");
await user.click(screen.getByRole("button", { name: "aiProxy.save" }));
await waitFor(() =>
expect(api.aiProxies.create).toHaveBeenCalledWith(
expect.objectContaining({ models: { opus: "PremiumCombo" } })
)
);
});
it("never puts a stored key in the edit form, and omits it when left blank", async () => {
// The API cannot return a key, so an edit that sends `apiKey: ""` would
// silently erase the one on disk.
vi.mocked(api.aiProxies.list).mockResolvedValue({ items: [native, nineRouter] });
render(<AiProxyConfig />);
const user = userEvent.setup();
await user.click(await screen.findByLabelText("aiProxy.edit 9Router"));
expect(screen.getByLabelText("aiProxy.fieldApiKey")).toHaveValue("");
await user.clear(screen.getByLabelText("aiProxy.fieldLabel"));
await user.type(screen.getByLabelText("aiProxy.fieldLabel"), "9Router nhà");
await user.click(screen.getByRole("button", { name: "aiProxy.save" }));
await waitFor(() => expect(api.aiProxies.update).toHaveBeenCalled());
const call = vi.mocked(api.aiProxies.update).mock.calls[0];
expect(call?.[1]).not.toHaveProperty("apiKey");
});
it("surfaces a server error instead of closing the form", async () => {
vi.mocked(api.aiProxies.create).mockRejectedValue(
new Error("baseUrl must be http:// or https://")
);
render(<AiProxyConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiProxy\.add/ }));
await user.type(screen.getByLabelText("aiProxy.fieldLabel"), "Bad");
await user.type(screen.getByLabelText("aiProxy.fieldBaseUrl"), "file:///etc/passwd");
await user.type(screen.getByLabelText("aiProxy.fieldModel"), "x");
await user.click(screen.getByRole("button", { name: "aiProxy.save" }));
// Inside the dialog, not behind its scrim.
const dialog = screen.getByRole("dialog");
expect(await within(dialog).findByText(/must be http/)).toBeInTheDocument();
});
it("closes the form on Escape", async () => {
render(<AiProxyConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiProxy\.add/ }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
await user.keyboard("{Escape}");
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
});
});
describe("AiProxyConfig — testing an endpoint before saving it", () => {
it("sends the typed endpoint, key and model, and reports success", async () => {
render(<AiProxyConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiProxy\.add/ }));
await user.type(screen.getByLabelText("aiProxy.fieldBaseUrl"), "http://localhost:20128/v1");
await user.type(screen.getByLabelText("aiProxy.fieldApiKey"), "sk-typed");
await user.type(screen.getByLabelText("aiProxy.fieldModel"), "Highest");
await user.click(screen.getByTestId("proxy-test"));
await waitFor(() =>
expect(api.aiProxies.test).toHaveBeenCalledWith({
baseUrl: "http://localhost:20128/v1",
model: "Highest",
apiKey: "sk-typed",
})
);
const lines = await screen.findAllByTestId("proxy-test-result");
expect(lines[0]).toHaveTextContent("Claude Code: aiProxy.testOk");
expect(lines[1]).toHaveTextContent("Codex: aiProxy.testOk");
});
it("names the profile being edited so the server can reuse its stored key", async () => {
vi.mocked(api.aiProxies.list).mockResolvedValue({ items: [native, nineRouter] });
render(<AiProxyConfig />);
const user = userEvent.setup();
await user.click(await screen.findByLabelText(/aiProxy\.edit 9Router/));
await user.click(screen.getByTestId("proxy-test"));
await waitFor(() =>
expect(api.aiProxies.test).toHaveBeenCalledWith({
baseUrl: "http://localhost:20128/v1",
model: "Highest",
name: "9router",
})
);
});
it("shows the proxy's own refusal per CLI rather than claiming it works", async () => {
vi.mocked(api.aiProxies.test).mockResolvedValue({
claude: { ok: false, status: 401, message: "invalid api key" },
codex: { ok: true, status: 200, message: "Highest" },
});
render(<AiProxyConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiProxy\.add/ }));
await user.type(screen.getByLabelText("aiProxy.fieldBaseUrl"), "http://localhost:20128/v1");
await user.type(screen.getByLabelText("aiProxy.fieldModel"), "Highest");
await user.click(screen.getByTestId("proxy-test"));
const [claude, codex] = await screen.findAllByTestId("proxy-test-result");
expect(claude).toHaveTextContent("Claude Code: aiProxy.testFailed");
expect(codex).toHaveTextContent("Codex: aiProxy.testOk");
});
it("drops a stale verdict as soon as a field changes", async () => {
render(<AiProxyConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiProxy\.add/ }));
await user.type(screen.getByLabelText("aiProxy.fieldBaseUrl"), "http://localhost:20128/v1");
await user.type(screen.getByLabelText("aiProxy.fieldModel"), "Highest");
await user.click(screen.getByTestId("proxy-test"));
expect(await screen.findAllByTestId("proxy-test-result")).toHaveLength(2);
await user.type(screen.getByLabelText("aiProxy.fieldBaseUrl"), "2");
await waitFor(() => expect(screen.queryAllByTestId("proxy-test-result")).toHaveLength(0));
});
it("opens a profile's Edit and Remove from a right-click; the built-in row has no menu", async () => {
vi.mocked(api.aiProxies.list).mockResolvedValue({ items: [native, nineRouter] });
let pending: PendingConfirm | null = null;
const off = subscribeConfirm((p) => (pending = p));
const user = userEvent.setup();
render(<AiProxyConfig />);
const row = (await screen.findByText("9Router")).closest("tr")!;
await user.pointer({ keys: "[MouseRight]", target: screen.getByText(native.label) });
expect(screen.queryByRole("menu")).toBeNull();
await user.pointer({ keys: "[MouseRight]", target: row });
await user.click(screen.getByRole("menuitem", { name: "aiProxy.remove" }));
await waitFor(() => expect(pending?.title).toBe("aiProxy.removeConfirm:9Router"));
settleConfirm(false);
await waitFor(() => expect(pending).toBeNull());
expect(api.aiProxies.remove).not.toHaveBeenCalled();
await user.pointer({ keys: "[MouseRight]", target: row });
await user.click(screen.getByRole("menuitem", { name: "aiProxy.edit" }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
off();
});
});
@@ -1,290 +0,0 @@
/**
* @file Tests for the AI Router settings tab — listing profiles, creating one,
* and the two rules that keep a key safe: the form never shows one, and an
* edit that leaves the field blank must not send an empty key that would wipe
* the stored one.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AiRouterConfig } from "../AiRouterConfig";
import { api } from "../../lib/api";
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../lib/confirm";
vi.mock("../../lib/api", () => ({
api: {
aiRouters: {
list: vi.fn(),
create: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
test: vi.fn(),
},
},
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) =>
opts && typeof opts.label === "string" ? `${key}:${opts.label}` : key,
}),
}));
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,
baseUrl: "http://localhost:20128/v1",
hasKey: true,
model: "Highest",
models: {
opus: "Highest",
sonnet: "Highest",
haiku: "Highest",
smallFast: "Highest",
subagent: "Highest",
},
};
beforeEach(() => {
vi.mocked(api.aiRouters.list)
.mockReset()
.mockResolvedValue({ items: [native] });
vi.mocked(api.aiRouters.create).mockReset().mockResolvedValue(nineRouter);
vi.mocked(api.aiRouters.update).mockReset().mockResolvedValue(nineRouter);
vi.mocked(api.aiRouters.remove).mockReset().mockResolvedValue({ removed: "9router" });
vi.mocked(api.aiRouters.test)
.mockReset()
.mockResolvedValue({ ok: true, status: 200, message: "Highest" });
});
describe("AiRouterConfig", () => {
it("lists the built-in native profile with no edit or delete control", async () => {
render(<AiRouterConfig />);
expect(await screen.findByText("Native (Claude / Codex)")).toBeInTheDocument();
expect(screen.queryByLabelText(/aiRouter\.edit Native/)).toBeNull();
expect(screen.queryByLabelText(/aiRouter\.remove Native/)).toBeNull();
});
it("saves a new profile with the typed endpoint, key and model", async () => {
render(<AiRouterConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
await user.type(screen.getByLabelText("aiRouter.fieldLabel"), "9Router");
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "http://localhost:20128/v1");
await user.type(screen.getByLabelText("aiRouter.fieldApiKey"), "sk-typed");
await user.type(screen.getByLabelText("aiRouter.fieldModel"), "Highest");
await user.click(screen.getByRole("button", { name: "aiRouter.save" }));
await waitFor(() =>
expect(api.aiRouters.create).toHaveBeenCalledWith({
label: "9Router",
baseUrl: "http://localhost:20128/v1",
model: "Highest",
// Every tier equals the headline model, so none is sent: the server
// fills them, which is what makes one field enough.
models: {},
behavesAs: "",
apiKey: "sk-typed",
})
);
});
it("sends the typed behavesAs, which maps the router's model names in Claude Code (#56)", async () => {
render(<AiRouterConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
await user.type(screen.getByLabelText("aiRouter.fieldLabel"), "9Router");
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "http://localhost:20128/v1");
await user.type(screen.getByLabelText("aiRouter.fieldModel"), "agent");
await user.type(screen.getByLabelText("aiRouter.fieldBehavesAs"), "claude-opus-5-5");
await user.click(screen.getByRole("button", { name: "aiRouter.save" }));
await waitFor(() =>
expect(api.aiRouters.create).toHaveBeenCalledWith(
expect.objectContaining({ model: "agent", behavesAs: "claude-opus-5-5" })
)
);
});
it("sends only the tiers that differ from the headline model", async () => {
render(<AiRouterConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
await user.type(screen.getByLabelText("aiRouter.fieldLabel"), "9Router");
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "http://localhost:20128/v1");
await user.type(screen.getByLabelText("aiRouter.fieldModel"), "Highest");
await user.type(screen.getByLabelText("aiRouter.tier.opus"), "PremiumCombo");
await user.click(screen.getByRole("button", { name: "aiRouter.save" }));
await waitFor(() =>
expect(api.aiRouters.create).toHaveBeenCalledWith(
expect.objectContaining({ models: { opus: "PremiumCombo" } })
)
);
});
it("never puts a stored key in the edit form, and omits it when left blank", async () => {
// The API cannot return a key, so an edit that sends `apiKey: ""` would
// silently erase the one on disk.
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
render(<AiRouterConfig />);
const user = userEvent.setup();
await user.click(await screen.findByLabelText("aiRouter.edit 9Router"));
expect(screen.getByLabelText("aiRouter.fieldApiKey")).toHaveValue("");
await user.clear(screen.getByLabelText("aiRouter.fieldLabel"));
await user.type(screen.getByLabelText("aiRouter.fieldLabel"), "9Router nhà");
await user.click(screen.getByRole("button", { name: "aiRouter.save" }));
await waitFor(() => expect(api.aiRouters.update).toHaveBeenCalled());
const call = vi.mocked(api.aiRouters.update).mock.calls[0];
expect(call?.[1]).not.toHaveProperty("apiKey");
});
it("surfaces a server error instead of closing the form", async () => {
vi.mocked(api.aiRouters.create).mockRejectedValue(
new Error("baseUrl must be http:// or https://")
);
render(<AiRouterConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
await user.type(screen.getByLabelText("aiRouter.fieldLabel"), "Bad");
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "file:///etc/passwd");
await user.type(screen.getByLabelText("aiRouter.fieldModel"), "x");
await user.click(screen.getByRole("button", { name: "aiRouter.save" }));
// Inside the dialog, not behind its scrim.
const dialog = screen.getByRole("dialog");
expect(await within(dialog).findByText(/must be http/)).toBeInTheDocument();
});
it("closes the form on Escape", async () => {
render(<AiRouterConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
await user.keyboard("{Escape}");
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
});
});
describe("AiRouterConfig — testing an endpoint before saving it", () => {
it("sends the typed endpoint, key and model, and reports success", async () => {
render(<AiRouterConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "http://localhost:20128/v1");
await user.type(screen.getByLabelText("aiRouter.fieldApiKey"), "sk-typed");
await user.type(screen.getByLabelText("aiRouter.fieldModel"), "Highest");
await user.click(screen.getByTestId("router-test"));
await waitFor(() =>
expect(api.aiRouters.test).toHaveBeenCalledWith({
baseUrl: "http://localhost:20128/v1",
model: "Highest",
apiKey: "sk-typed",
})
);
expect(await screen.findByTestId("router-test-result")).toHaveTextContent("aiRouter.testOk");
});
it("names the profile being edited so the server can reuse its stored key", async () => {
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
render(<AiRouterConfig />);
const user = userEvent.setup();
await user.click(await screen.findByLabelText(/aiRouter\.edit 9Router/));
await user.click(screen.getByTestId("router-test"));
await waitFor(() =>
expect(api.aiRouters.test).toHaveBeenCalledWith({
baseUrl: "http://localhost:20128/v1",
model: "Highest",
name: "9router",
})
);
});
it("shows the router's own refusal rather than claiming it works", async () => {
vi.mocked(api.aiRouters.test).mockResolvedValue({
ok: false,
status: 401,
message: "invalid api key",
});
render(<AiRouterConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "http://localhost:20128/v1");
await user.type(screen.getByLabelText("aiRouter.fieldModel"), "Highest");
await user.click(screen.getByTestId("router-test"));
expect(await screen.findByTestId("router-test-result")).toHaveTextContent(
"aiRouter.testFailed"
);
});
it("drops a stale verdict as soon as a field changes", async () => {
render(<AiRouterConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "http://localhost:20128/v1");
await user.type(screen.getByLabelText("aiRouter.fieldModel"), "Highest");
await user.click(screen.getByTestId("router-test"));
expect(await screen.findByTestId("router-test-result")).toBeInTheDocument();
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "2");
await waitFor(() => expect(screen.queryByTestId("router-test-result")).toBeNull());
});
it("opens a profile's Edit and Remove from a right-click; the built-in row has no menu", async () => {
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
let pending: PendingConfirm | null = null;
const off = subscribeConfirm((p) => (pending = p));
const user = userEvent.setup();
render(<AiRouterConfig />);
const row = (await screen.findByText("9Router")).closest("tr")!;
await user.pointer({ keys: "[MouseRight]", target: screen.getByText(native.label) });
expect(screen.queryByRole("menu")).toBeNull();
await user.pointer({ keys: "[MouseRight]", target: row });
await user.click(screen.getByRole("menuitem", { name: "aiRouter.remove" }));
await waitFor(() => expect(pending?.title).toBe("aiRouter.removeConfirm:9Router"));
settleConfirm(false);
await waitFor(() => expect(pending).toBeNull());
expect(api.aiRouters.remove).not.toHaveBeenCalled();
await user.pointer({ keys: "[MouseRight]", target: row });
await user.click(screen.getByRole("menuitem", { name: "aiRouter.edit" }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
off();
});
});
@@ -50,7 +50,7 @@ const GEMINI: AiTool = {
prompt: { mode: "flag" },
resume: false,
bypass: true,
routerCapable: false,
proxyCapable: false,
install: { kind: "npm", package: "@google/gemini-cli" },
update: { kind: "npm" },
modelFlag: true,
@@ -30,7 +30,7 @@ vi.mock("../CodexConfig", () => ({ CodexConfig: () => <div data-testid="codex-co
vi.mock("../AiToolConfig", () => ({
AiToolConfig: ({ toolId }: { toolId: string }) => <div data-testid={`tool-${toolId}`} />,
}));
vi.mock("../AiRouterConfig", () => ({ AiRouterConfig: () => null }));
vi.mock("../AiProxyConfig", () => ({ AiProxyConfig: () => null }));
vi.mock("../GitCredentialsConfig", () => ({ GitCredentialsConfig: () => null }));
vi.mock("../../components/settings/AiToolsSettings", () => ({
AiToolsSettings: () => <div data-testid="ai-tools" />,
@@ -87,7 +87,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
features: { list: r({ features: [] }), show: r({ feature: null }) },
proof: { list: r({ features: [] }), imageUrl: () => "" },
},
aiRouters: { list: r({ items: [] }) },
aiProxies: { list: r({ items: [] }) },
sessions: { list: r({ sessions: [], total: 0, limit: 50, offset: 0 }) },
},
};
@@ -184,11 +184,11 @@ vi.mock("../../lib/api", async (importOriginal) => {
}),
},
},
// The lane card's router picker. Only the built-in profile, which is the
// The lane card's proxy picker. Only the built-in profile, which is the
// shape that renders no picker — nothing here asserts on it.
aiRouters: {
aiProxies: {
list: vi.fn().mockImplementation(async () => {
recordCall("GET", "/api/ai-routers");
recordCall("GET", "/api/ai-proxies");
return { items: [] };
}),
},
@@ -7233,7 +7233,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<p
class="text-[10px] text-status-warning/90 mt-1"
>
UI build v2.5.4 (rebuild client to match)
UI build v2.5.5 (rebuild client to match)
</p>
</div>
<div
@@ -258,7 +258,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
return {
...actual,
api: {
aiRouters: { list: r({ items: [] }) },
aiProxies: { list: r({ items: [] }) },
stats: { get: r(stats), facets: r({ cwds: [] }) },
sessions: {
list: r({ sessions: [], total: 0, limit: 50, offset: 0 }),