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:
@@ -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>
|
||||
@@ -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 }),
|
||||
|
||||
Reference in New Issue
Block a user