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:
+2
-2
@@ -2296,8 +2296,8 @@ The same shape is used by `GET /status`, `POST /check`, and the `update_status`
|
|||||||
| Ahead of the newest release | `{update_available:false, dev_build:true, commits_ahead_of_release:N}` | Sidebar reads `Dev build · N ahead`; modal suppressed (a dev build is not an available update). |
|
| Ahead of the newest release | `{update_available:false, dev_build:true, commits_ahead_of_release:N}` | Sidebar reads `Dev build · N ahead`; modal suppressed (a dev build is not an available update). |
|
||||||
| Shallow clone (incl. the plugin cache) | `{shallow_clone:true, commits_behind_release:null, commits_ahead_of_release:null, dev_build:false}` | No commit badge is rendered at all — the numbers would be graft artefacts. Version compare still decides `update_available`. |
|
| Shallow clone (incl. the plugin cache) | `{shallow_clone:true, commits_behind_release:null, commits_ahead_of_release:null, dev_build:false}` | No commit badge is rendered at all — the numbers would be graft artefacts. Version compare still decides `update_available`. |
|
||||||
| Newer release, ordinary checkout | `{update_available:true, situation:"release_available", latest_release:"vX.Y.Z", manual_command:"...git fetch origin --tags && git merge --ff-only vX.Y.Z..."}` | Modal opens titled with the release version, links its notes, and shows **Update now**. |
|
| Newer release, ordinary checkout | `{update_available:true, situation:"release_available", latest_release:"vX.Y.Z", manual_command:"...git fetch origin --tags && git merge --ff-only vX.Y.Z..."}` | Modal opens titled with the release version, links its notes, and shows **Update now**. |
|
||||||
| Newer release, plugin-cache install | `{update_available:true, situation:"plugin_managed", install_kind:"plugin", plugin_marketplace:"sg", plugin_name:"agenthub", manual_command:"claude plugin marketplace update sg && ..."}` | Modal opens WITH **Update now**; `POST /apply` answers `200 {applied:true, mode:"plugin"}` and the detached chain pulls the new version and re-bootstraps. The modal notes that an already-open Claude Code session still needs `/reload-plugins` for new skills/commands. |
|
| Newer release, plugin-cache install | `{update_available:true, situation:"plugin_managed", install_kind:"plugin", plugin_marketplace:"sg", plugin_name:"agenthub", manual_command:"claude plugin marketplace update sg && ..."}` | Modal opens WITH **Update now**; `POST /apply` answers `200 {applied:true, mode:"plugin"}` and the detached chain pulls the new version and re-bootstraps. The modal notes that an already-open Claude Code session still needs `/reload-plugins` for new skills/commands. It then polls `status` and reloads once the old server went down and answered again, or as soon as a different `local_version` answers; a verdict of this chain (`finished_at` after the click) that is `ok:false` or `already_current` ends the wait and shows it, since the old server then never goes down. Independently, every tab reloads itself onto the new UI build when its WebSocket reconnects to a server whose `/api/health` `version` is not the bundle's (`client/src/lib/newBuildReload.ts`, once per version). |
|
||||||
| Plugin chain ran but nothing was published | `{last_plugin_update:{ok:true, step:"already_current"}}` | The old server keeps running, untouched; no restart, no error banner. |
|
| Plugin chain ran but nothing was published | `{last_plugin_update:{ok:true, step:"already_current"}}` | The old server keeps running, untouched; no restart, no error banner — a tab waiting on **Update now** stops waiting and says nothing new was pulled. |
|
||||||
| Plugin chain mid-handover | `{last_plugin_update:{ok:true, step:"bootstrap", note:"the new plugin version is installed; the server is being handed over"}}` | Written BEFORE the handover on purpose: that step retires the process tree the helper hangs off, and a helper killed there would otherwise leave `last_plugin_update: null` for an update that succeeded. Overwritten by `done` when the helper survives. |
|
| Plugin chain mid-handover | `{last_plugin_update:{ok:true, step:"bootstrap", note:"the new plugin version is installed; the server is being handed over"}}` | Written BEFORE the handover on purpose: that step retires the process tree the helper hangs off, and a helper killed there would otherwise leave `last_plugin_update: null` for an update that succeeded. Overwritten by `done` when the helper survives. |
|
||||||
| Plugin chain failed (no `claude` on PATH, `npm ci` broke, bootstrap died) | `{last_plugin_update:{ok:false, step:"resolve_claude"\|"plugin_update"\|"bootstrap", error:"..."}}` | Danger banner in the modal naming the failed step. A `bootstrap` failure means the old server was already stopped, so the verdict says to run `/agenthub-update` to recover — and it is read back by whichever server comes up next, because it lives outside the cache. |
|
| Plugin chain failed (no `claude` on PATH, `npm ci` broke, bootstrap died) | `{last_plugin_update:{ok:false, step:"resolve_claude"\|"plugin_update"\|"bootstrap", error:"..."}}` | Danger banner in the modal naming the failed step. A `bootstrap` failure means the old server was already stopped, so the verdict says to run `/agenthub-update` to recover — and it is read back by whichever server comes up next, because it lives outside the cache. |
|
||||||
| Plugin `/apply` with nothing newer published | 409 `{reason:"plugin_managed", release_check:"ok"}` | No chain is spawned. Message names the newest release and points at `/agenthub-update` to force a refresh. |
|
| Plugin `/apply` with nothing newer published | 409 `{reason:"plugin_managed", release_check:"ok"}` | No chain is spawned. Message names the newest release and points at `/agenthub-update` to force a refresh. |
|
||||||
|
|||||||
+11
-11
File diff suppressed because one or more lines are too long
+1
-1
@@ -259,7 +259,7 @@ graph TB
|
|||||||
Detail[SessionDetail<br/>agent hierarchy + timeline]
|
Detail[SessionDetail<br/>agent hierarchy + timeline]
|
||||||
Feed[ActivityFeed<br/>streaming event log]
|
Feed[ActivityFeed<br/>streaming event log]
|
||||||
Workflows[Workflows<br/>Analytics → Workflow sub-tab:<br/>lane pipelines + D3 analytics]
|
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
|
App --> Layout
|
||||||
Layout --> Dashboard & Sessions & Detail & Feed & Settings
|
Layout --> Dashboard & Sessions & Detail & Feed & Settings
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ import { useWebSocket } from "./hooks/useWebSocket";
|
|||||||
import { useNotifications } from "./hooks/useNotifications";
|
import { useNotifications } from "./hooks/useNotifications";
|
||||||
import { eventBus } from "./lib/eventBus";
|
import { eventBus } from "./lib/eventBus";
|
||||||
import { copyOnCtrlShiftC } from "./lib/keys";
|
import { copyOnCtrlShiftC } from "./lib/keys";
|
||||||
|
import { reloadOnNewBuild } from "./lib/newBuildReload";
|
||||||
import type { WSMessage } from "./lib/types";
|
import type { WSMessage } from "./lib/types";
|
||||||
|
|
||||||
// The heavy pages (xterm, the diff viewer, the settings editors) load on first
|
// 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);
|
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.
|
// Ctrl+Shift+C is copy here (Linux terminal habit), not the browser's inspector.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.addEventListener("keydown", copyOnCtrlShiftC, true);
|
window.addEventListener("keydown", copyOnCtrlShiftC, true);
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ import { Download, X, Copy, Check, RefreshCw, Zap, ExternalLink } from "lucide-r
|
|||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import { eventBus } from "../lib/eventBus";
|
import { eventBus } from "../lib/eventBus";
|
||||||
import { copyText } from "../lib/clipboard";
|
import { copyText } from "../lib/clipboard";
|
||||||
|
import { toast } from "../lib/toast";
|
||||||
import { useDialog } from "../hooks/useDialog";
|
import { useDialog } from "../hooks/useDialog";
|
||||||
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
|
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
|
* stretch, so polling naively would get a 200 from the process we are
|
||||||
* waiting to replace and reload onto the same old bundle.
|
* waiting to replace and reload onto the same old bundle.
|
||||||
*/
|
*/
|
||||||
const pollUntilBack = useCallback((requireDownFirst = false) => {
|
const pollUntilBack = useCallback(
|
||||||
let sawDown = !requireDownFirst;
|
(requireDownFirst = false, fromVersion: string | null = null) => {
|
||||||
const attempt = () => {
|
let sawDown = !requireDownFirst;
|
||||||
api.updates
|
// Its own verdicts only: an older plugin-update.json is still on disk
|
||||||
.status()
|
// until this chain writes one (5 s slack for a clock a little behind).
|
||||||
.then(() => {
|
const since = Date.now() - 5000;
|
||||||
if (sawDown) window.location.reload();
|
const attempt = () => {
|
||||||
else setTimeout(attempt, 1500);
|
api.updates
|
||||||
})
|
.status()
|
||||||
.catch(() => {
|
.then((s) => {
|
||||||
sawDown = true;
|
const v = s.last_plugin_update;
|
||||||
setTimeout(attempt, 1500);
|
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.
|
||||||
setTimeout(attempt, 1500);
|
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 () => {
|
const applyNow = async () => {
|
||||||
if (applying || restarting) return;
|
if (applying || restarting) return;
|
||||||
@@ -292,7 +312,7 @@ export function UpdateNotifier() {
|
|||||||
setRestarting(true);
|
setRestarting(true);
|
||||||
// mode "plugin": the old server lingers while the chain installs, so
|
// mode "plugin": the old server lingers while the chain installs, so
|
||||||
// only treat a reachable server as "back" after it has gone away once.
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
setError(
|
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", () => {
|
describe("UpdateNotifier — closing vs skipping", () => {
|
||||||
it("Escape only closes for now; Skip this version is the persistent dismiss", async () => {
|
it("Escape only closes for now; Skip this version is the persistent dismiss", async () => {
|
||||||
const { unmount } = render(<UpdateNotifier />);
|
const { unmount } = render(<UpdateNotifier />);
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
|||||||
list: vi.fn(async () => ({ tools: [], default: "claude" })),
|
list: vi.fn(async () => ({ tools: [], default: "claude" })),
|
||||||
iconUrl: () => "",
|
iconUrl: () => "",
|
||||||
},
|
},
|
||||||
aiRouters: { list: vi.fn(async () => ({ routers: [] })) },
|
aiProxies: { list: vi.fn(async () => ({ items: [] })) },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
* `ConsoleTabs.tsx`). Inside
|
* `ConsoleTabs.tsx`). Inside
|
||||||
* the box, like Claude Desktop's: AI (the session's own tool, the workspace's
|
* 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
|
* 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,
|
* (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
|
* 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,
|
* 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 { useProjectTreePrefs } from "../../lib/projectTreePrefs";
|
||||||
import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "../run/RunSetup";
|
import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "../run/RunSetup";
|
||||||
import { AiToolIcon } from "../AiToolIcon";
|
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 { Select } from "../Select";
|
||||||
import { MarkdownPreview } from "../MarkdownPreview";
|
import { MarkdownPreview } from "../MarkdownPreview";
|
||||||
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./WorkspaceRowMenu";
|
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./WorkspaceRowMenu";
|
||||||
@@ -143,25 +143,25 @@ function HomeBody({
|
|||||||
}
|
}
|
||||||
}, [draftKey, prompt]);
|
}, [draftKey, prompt]);
|
||||||
const [images, setImages] = useState<PromptImage[]>([]);
|
const [images, setImages] = useState<PromptImage[]>([]);
|
||||||
const [router, setRouter] = useSessionRouter();
|
const [proxy, setProxy] = useSessionProxy();
|
||||||
const routers = useAiRouters();
|
const proxies = useAiProxies();
|
||||||
// Claude and Codex, also before the catalog has loaded (as RouterPicker).
|
// Claude and Codex, also before the catalog has loaded (as ProxyPicker).
|
||||||
const routerCapable = entry?.routerCapable ?? (claude || codex);
|
const proxyCapable = entry?.proxyCapable ?? (claude || codex);
|
||||||
// A profile picked for a tool that can use one; its models replace the tool's own.
|
// A profile picked for a tool that can use one; its models replace the tool's own.
|
||||||
const profile = routerCapable
|
const profile = proxyCapable
|
||||||
? (routers?.find((r) => r.name === router && !r.builtin) ?? null)
|
? (proxies?.find((r) => r.name === proxy && !r.builtin) ?? null)
|
||||||
: null;
|
: null;
|
||||||
const routerModels: ModelChoice[] | undefined = profile
|
const proxyModels: ModelChoice[] | undefined = profile
|
||||||
? [
|
? [
|
||||||
...new Set(
|
...new Set(
|
||||||
[profile.model, profile.models.opus, profile.models.sonnet, profile.models.haiku].filter(
|
[profile.model, profile.models.opus, profile.models.sonnet, profile.models.haiku].filter(
|
||||||
Boolean
|
(m): m is string => !!m
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
].map((id) => ({ id, label: id }))
|
].map((id) => ({ id, label: id }))
|
||||||
: undefined;
|
: undefined;
|
||||||
// The tool's default model (also when the catalog arrives after mount); on
|
// 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 defaultModel = profile ? "" : (entry?.defaultModel ?? "");
|
||||||
const [model, setModel] = useState(defaultModel);
|
const [model, setModel] = useState(defaultModel);
|
||||||
useEffect(() => setModel(defaultModel), [defaultModel, tool, profile?.name]);
|
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
|
// Only a different tool is named: the workspace's own keeps starting
|
||||||
// even after it was disabled in Settings.
|
// even after it was disabled in Settings.
|
||||||
...(tool !== lane.tool ? { tool } : {}),
|
...(tool !== lane.tool ? { tool } : {}),
|
||||||
...routerArg(routerCapable, router),
|
...proxyArg(proxyCapable, proxy),
|
||||||
...(hasModel && model ? { model } : {}),
|
...(hasModel && model ? { model } : {}),
|
||||||
...(claude ? { permissionMode: mode } : {}),
|
...(claude ? { permissionMode: mode } : {}),
|
||||||
...(codex ? { sandbox, approval } : {}),
|
...(codex ? { sandbox, approval } : {}),
|
||||||
@@ -533,7 +533,7 @@ function HomeBody({
|
|||||||
value={model}
|
value={model}
|
||||||
onChange={setModel}
|
onChange={setModel}
|
||||||
tool={tool}
|
tool={tool}
|
||||||
choices={routerModels}
|
choices={proxyModels}
|
||||||
{...(hasEffort ? { effort, onEffortChange: setEffort } : {})}
|
{...(hasEffort ? { effort, onEffortChange: setEffort } : {})}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -556,12 +556,12 @@ function HomeBody({
|
|||||||
data-testid="workspace-home-options"
|
data-testid="workspace-home-options"
|
||||||
className="mt-1.5 flex flex-wrap items-center gap-0.5 px-1"
|
className="mt-1.5 flex flex-wrap items-center gap-0.5 px-1"
|
||||||
>
|
>
|
||||||
<RouterPicker
|
<ProxyPicker
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
tool={tool}
|
tool={tool}
|
||||||
routers={routers}
|
proxies={proxies}
|
||||||
value={router}
|
value={proxy}
|
||||||
onChange={setRouter}
|
onChange={setProxy}
|
||||||
/>
|
/>
|
||||||
{claude && (
|
{claude && (
|
||||||
<Select<PermissionMode>
|
<Select<PermissionMode>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ vi.mock("../../../lib/api", () => ({
|
|||||||
profileInit: vi.fn(),
|
profileInit: vi.fn(),
|
||||||
mcpSync: 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` },
|
aiTools: { list: vi.fn(), iconUrl: (id: string) => `/api/ai-tools/${id}/icon` },
|
||||||
run: { start: vi.fn() },
|
run: { start: vi.fn() },
|
||||||
},
|
},
|
||||||
@@ -50,7 +50,7 @@ const tool = (id: string, label: string, over: Partial<AiTool> = {}): AiTool =>
|
|||||||
prompt: { mode: "argv" },
|
prompt: { mode: "argv" },
|
||||||
resume: false,
|
resume: false,
|
||||||
bypass: false,
|
bypass: false,
|
||||||
routerCapable: false,
|
proxyCapable: false,
|
||||||
install: null,
|
install: null,
|
||||||
update: null,
|
update: null,
|
||||||
modelFlag: false,
|
modelFlag: false,
|
||||||
@@ -62,8 +62,8 @@ const tool = (id: string, label: string, over: Partial<AiTool> = {}): AiTool =>
|
|||||||
const TOOLS: AiToolsView = {
|
const TOOLS: AiToolsView = {
|
||||||
default: "claude",
|
default: "claude",
|
||||||
tools: [
|
tools: [
|
||||||
tool("claude", "Claude Code", { launcher: "claude", routerCapable: true, prompt: null }),
|
tool("claude", "Claude Code", { launcher: "claude", proxyCapable: true, prompt: null }),
|
||||||
tool("codex", "Codex", { launcher: "codex", routerCapable: true, prompt: null }),
|
tool("codex", "Codex", { launcher: "codex", proxyCapable: true, prompt: null }),
|
||||||
tool("gemini", "Gemini"),
|
tool("gemini", "Gemini"),
|
||||||
tool("aider", "Aider", { installed: false, path: null }),
|
tool("aider", "Aider", { installed: false, path: null }),
|
||||||
tool("grok", "Grok", { enabled: false }),
|
tool("grok", "Grok", { enabled: false }),
|
||||||
@@ -158,7 +158,7 @@ beforeEach(() => {
|
|||||||
.mockReset()
|
.mockReset()
|
||||||
.mockResolvedValue({ scaffolded: false, reason: "no detectable Node.js project" });
|
.mockResolvedValue({ scaffolded: false, reason: "no detectable Node.js project" });
|
||||||
vi.mocked(api.lanes.mcpSync).mockReset().mockResolvedValue({ servers: [], profilesSeeded: [] });
|
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)", () => {
|
describe("AddLaneModal — picking an existing branch (issue #34)", () => {
|
||||||
@@ -639,12 +639,12 @@ describe("AddLaneModal — create/update readme", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("AddLaneModal — no router here", () => {
|
describe("AddLaneModal — no proxy here", () => {
|
||||||
it("asks for no AI Router: a new session picks its own", async () => {
|
it("asks for no AI Proxy: a new session picks its own", async () => {
|
||||||
renderModal({});
|
renderModal({});
|
||||||
await screen.findByRole("button", { name: /Claude Code.*Default/ });
|
await screen.findByRole("button", { name: /Claude Code.*Default/ });
|
||||||
expect(screen.queryByRole("radio", { name: "AI Router" })).toBeNull();
|
expect(screen.queryByRole("radio", { name: "AI Proxy" })).toBeNull();
|
||||||
expect(api.aiRouters.list).not.toHaveBeenCalled();
|
expect(api.aiProxies.list).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ vi.mock("../../../lib/api", () => ({
|
|||||||
locks: {
|
locks: {
|
||||||
list: vi.fn(),
|
list: vi.fn(),
|
||||||
},
|
},
|
||||||
aiRouters: {
|
aiProxies: {
|
||||||
list: vi.fn(),
|
list: vi.fn(),
|
||||||
},
|
},
|
||||||
run: {
|
run: {
|
||||||
@@ -45,10 +45,10 @@ beforeEach(() => {
|
|||||||
// stack, so the runtime strip and its button stay absent unless a test opts in.
|
// 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).mockReset();
|
||||||
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
|
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.
|
// tab has nothing to switch between, so the row stays out of every other test.
|
||||||
vi.mocked(api.aiRouters.list).mockReset();
|
vi.mocked(api.aiProxies.list).mockReset();
|
||||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [] });
|
vi.mocked(api.aiProxies.list).mockResolvedValue({ items: [] });
|
||||||
});
|
});
|
||||||
vi.mocked(api.locks.list).mockReset();
|
vi.mocked(api.locks.list).mockReset();
|
||||||
vi.mocked(api.locks.list).mockResolvedValue({ locks: [] });
|
vi.mocked(api.locks.list).mockResolvedValue({ locks: [] });
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
* @file WorkspaceHome.test.tsx
|
* @file WorkspaceHome.test.tsx
|
||||||
* @description The workspace home: a new session starts in the first free
|
* @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
|
* 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
|
* default), AI and Model in the box and AI Proxy / Permission / Worktree
|
||||||
* under it (the router's models on a router), all-busy is refused, Recents open a live pane or the
|
* 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
|
* transcript (never a resume), instructions save, context files upload and
|
||||||
* remove, a folder is added with the repo's untracked worktrees suggested
|
* remove, a folder is added with the repo's untracked worktrees suggested
|
||||||
* (minus hidden sources), an unsupported tool is said so, and memory opens
|
* (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: () => "" },
|
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());
|
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();
|
renderHome();
|
||||||
await screen.findByTestId("workspace-recent-s-live");
|
await screen.findByTestId("workspace-recent-s-live");
|
||||||
const labels = (id: string) =>
|
const labels = (id: string) =>
|
||||||
@@ -252,7 +252,7 @@ describe("WorkspaceHome", () => {
|
|||||||
].map((x) => x.getAttribute("aria-label"));
|
].map((x) => x.getAttribute("aria-label"));
|
||||||
expect(labels("workspace-home-composer")).toEqual(["AI", "model"]);
|
expect(labels("workspace-home-composer")).toEqual(["AI", "model"]);
|
||||||
expect(labels("workspace-home-options")).toEqual([
|
expect(labels("workspace-home-options")).toEqual([
|
||||||
"AI Router",
|
"AI Proxy",
|
||||||
"Permission mode",
|
"Permission mode",
|
||||||
expect.stringContaining("own git worktree"),
|
expect.stringContaining("own git worktree"),
|
||||||
]);
|
]);
|
||||||
@@ -263,8 +263,8 @@ describe("WorkspaceHome", () => {
|
|||||||
vi.mocked(api.aiTools.list).mockResolvedValue({
|
vi.mocked(api.aiTools.list).mockResolvedValue({
|
||||||
default: "claude",
|
default: "claude",
|
||||||
tools: [
|
tools: [
|
||||||
{ id: "claude", label: "Claude Code", launcher: "claude", routerCapable: true },
|
{ id: "claude", label: "Claude Code", launcher: "claude", proxyCapable: true },
|
||||||
{ id: "codex", label: "Codex", launcher: "codex", routerCapable: true, bypass: true },
|
{ id: "codex", label: "Codex", launcher: "codex", proxyCapable: true, bypass: true },
|
||||||
{ id: "cursor", label: "Cursor CLI", installed: false, enabled: true },
|
{ id: "cursor", label: "Cursor CLI", installed: false, enabled: true },
|
||||||
].map((x) => ({ installed: true, enabled: true, ...x })),
|
].map((x) => ({ installed: true, enabled: true, ...x })),
|
||||||
} as never);
|
} as never);
|
||||||
@@ -289,9 +289,9 @@ describe("WorkspaceHome", () => {
|
|||||||
__resetAiTools();
|
__resetAiTools();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("offers the router's models on a router, and sends the router", async () => {
|
it("offers the proxy's models on a proxy, and sends the proxy", async () => {
|
||||||
localStorage.setItem("agenthub.session.router", "glm");
|
localStorage.setItem("agenthub.session.proxy", "glm");
|
||||||
vi.mocked(api.aiRouters.list).mockResolvedValueOnce({
|
vi.mocked(api.aiProxies.list).mockResolvedValueOnce({
|
||||||
items: [
|
items: [
|
||||||
{ name: "native", label: "Native", builtin: true, models: {} },
|
{ name: "native", label: "Native", builtin: true, models: {} },
|
||||||
{
|
{
|
||||||
@@ -316,7 +316,7 @@ describe("WorkspaceHome", () => {
|
|||||||
expect(api.lanes.action).toHaveBeenCalledWith(
|
expect(api.lanes.action).toHaveBeenCalledWith(
|
||||||
1,
|
1,
|
||||||
"start",
|
"start",
|
||||||
expect.objectContaining({ router: "glm" })
|
expect.objectContaining({ proxy: "glm" })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
* toasts. The `+` menu opens a new tab in the free slot the server picks
|
* 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}`)
|
* (`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
|
* 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
|
* 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
|
* (`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)
|
* 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 { freeSlot, startInFreeTab } from "../lanes/startSession";
|
||||||
import { PANE_DRAG_TYPE, RunIcon, startPaneDrag, type GroupsUpdate } from "./PaneGrid";
|
import { PANE_DRAG_TYPE, RunIcon, startPaneDrag, type GroupsUpdate } from "./PaneGrid";
|
||||||
import { insertionIndex } from "../../lib/laneOrderStorage";
|
import { insertionIndex } from "../../lib/laneOrderStorage";
|
||||||
import {
|
import { type PaneGroups, detachSlot, groupOf, leaves, reorderGroups } from "../../lib/paneLayout";
|
||||||
type PaneGroups,
|
|
||||||
detachSlot,
|
|
||||||
groupOf,
|
|
||||||
leaves,
|
|
||||||
reorderGroups,
|
|
||||||
} from "../../lib/paneLayout";
|
|
||||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||||
|
|
||||||
type AiToolsState = ReturnType<typeof useAiTools>;
|
type AiToolsState = ReturnType<typeof useAiTools>;
|
||||||
|
|||||||
@@ -106,20 +106,27 @@ export function usePaneGroups(laneId: number | null): [PaneGroups, GroupsUpdate]
|
|||||||
return [current.groups, update];
|
return [current.groups, update];
|
||||||
}
|
}
|
||||||
|
|
||||||
type Zone = Edge | "center";
|
export type Zone = Edge | "center";
|
||||||
const pct = (r: Rect) => ({
|
const pct = (r: Rect) => ({
|
||||||
left: `${r.x * 100}%`,
|
left: `${r.x * 100}%`,
|
||||||
top: `${r.y * 100}%`,
|
top: `${r.y * 100}%`,
|
||||||
width: `${r.w * 100}%`,
|
width: `${r.w * 100}%`,
|
||||||
height: `${r.h * 100}%`,
|
height: `${r.h * 100}%`,
|
||||||
});
|
});
|
||||||
/** The half (or the whole) of a pane a drop zone would fill. */
|
/** What dropping `src` on a pane's zone does: the center swaps, an edge
|
||||||
function zoneRect(r: Rect, zone: Zone): Rect {
|
* splits. The drop applies it and the preview draws it, so they agree. */
|
||||||
if (zone === "left") return { ...r, w: r.w / 2 };
|
export function applyDrop(l: PaneLayout, src: number, slot: number, zone: Zone): PaneLayout {
|
||||||
if (zone === "right") return { ...r, x: r.x + r.w / 2, w: r.w / 2 };
|
return zone === "center" ? swapInLayout(l, src, slot) : moveInLayout(l, src, slot, zone);
|
||||||
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;
|
/** 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,
|
/** 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 { t } = useTranslation("lanes");
|
||||||
const gridRef = useRef<HTMLDivElement>(null);
|
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 { panes, dividers } = layoutRects(layout.root);
|
||||||
const shown = leaves(layout.root);
|
const shown = leaves(layout.root);
|
||||||
const rectOf = new Map(panes.map((p) => [p.slot, p]));
|
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" };
|
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) =>
|
const isPaneDrag = (e: React.DragEvent) =>
|
||||||
dragSlot !== null || !!e.dataTransfer?.types?.includes(PANE_DRAG_TYPE);
|
dragSlot !== null || !!e.dataTransfer?.types?.includes(PANE_DRAG_TYPE);
|
||||||
|
|
||||||
@@ -404,7 +413,10 @@ export function PaneGrid({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
|
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
|
||||||
const z = zoneAt(e);
|
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) => {
|
onDragLeave={(e) => {
|
||||||
if (!gridRef.current?.contains(e.relatedTarget as Node | null)) setDrop(null);
|
if (!gridRef.current?.contains(e.relatedTarget as Node | null)) setDrop(null);
|
||||||
@@ -417,11 +429,7 @@ export function PaneGrid({
|
|||||||
dragSlot = null;
|
dragSlot = null;
|
||||||
setDrop(null);
|
setDrop(null);
|
||||||
if (!z || !Number.isInteger(src) || src === z.slot) return;
|
if (!z || !Number.isInteger(src) || src === z.slot) return;
|
||||||
onChange((l) =>
|
onChange((l) => applyDrop(l, src, z.slot, z.zone));
|
||||||
z.zone === "center"
|
|
||||||
? swapInLayout(l, src, z.slot)
|
|
||||||
: moveInLayout(l, src, z.slot, z.zone as Edge)
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{mounted.map((slot) => {
|
{mounted.map((slot) => {
|
||||||
@@ -561,11 +569,11 @@ export function PaneGrid({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{drop && rectOf.has(drop.slot) && (
|
{preview && (
|
||||||
<div
|
<div
|
||||||
data-testid="pane-drop-preview"
|
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"
|
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>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
* (`POST /api/run/drop-image`);
|
* (`POST /api/run/drop-image`);
|
||||||
* - `CwdAutocomplete` — the folder field the Add-workspace modal uses;
|
* - `CwdAutocomplete` — the folder field the Add-workspace modal uses;
|
||||||
* - `ModelPicker` — a tool's own model list (`GET /api/ai-tools/:id/models`)
|
* - `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
|
* - `ModelMenu` — the same list as ONE composer chip, Claude Desktop's
|
||||||
* "Sonnet 5.5 · Medium": the main models with their hints, the rest under
|
* "Sonnet 5.5 · Medium": the main models with their hints, the rest under
|
||||||
* More models, Custom…, and the Effort slider (Faster → Smarter, a reset
|
* 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
|
* 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
|
* 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
|
* Claude's static aliases only when the server cannot answer. `loaded` once
|
||||||
* the list is final.
|
* the list is final.
|
||||||
*/
|
*/
|
||||||
@@ -707,7 +707,7 @@ export function ModelPicker({
|
|||||||
value: string;
|
value: string;
|
||||||
onChange: (s: string) => void;
|
onChange: (s: string) => void;
|
||||||
tool: LaneTool;
|
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[];
|
choices?: ModelChoice[];
|
||||||
variant?: "field" | "ghost";
|
variant?: "field" | "ghost";
|
||||||
}) {
|
}) {
|
||||||
@@ -781,7 +781,7 @@ export function ModelMenu({
|
|||||||
value: string;
|
value: string;
|
||||||
onChange: (s: string) => void;
|
onChange: (s: string) => void;
|
||||||
tool: LaneTool;
|
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[];
|
choices?: ModelChoice[];
|
||||||
/** Omitted for a tool that takes no effort. */
|
/** Omitted for a tool that takes no effort. */
|
||||||
effort?: EffortLevel;
|
effort?: EffortLevel;
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ vi.mock("../../../lib/api", () => ({
|
|||||||
action: vi.fn(),
|
action: vi.fn(),
|
||||||
list: vi.fn(),
|
list: vi.fn(),
|
||||||
},
|
},
|
||||||
aiRouters: { list: vi.fn().mockResolvedValue({ items: [] }) },
|
aiProxies: { list: vi.fn().mockResolvedValue({ items: [] }) },
|
||||||
aiTools: {
|
aiTools: {
|
||||||
list: vi.fn().mockResolvedValue({ default: "claude", tools: [] }),
|
list: vi.fn().mockResolvedValue({ default: "claude", tools: [] }),
|
||||||
models: vi.fn().mockResolvedValue({ flag: true, models: [], defaultModel: null }),
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
+33
-28
@@ -1,16 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* @file RouterPicker.test.tsx
|
* @file ProxyPicker.test.tsx
|
||||||
* @description The per-session AI Router picker: Native plus the saved
|
* @description The per-session AI Proxy picker: Native plus the saved
|
||||||
* profiles, Native alone and disabled on a tool that cannot use a router,
|
* profiles, Native alone and disabled on a tool that cannot use a proxy,
|
||||||
* remembering the pick, and falling back to Native when the remembered
|
* 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>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { act, render, renderHook, screen, waitFor } from "@testing-library/react";
|
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 { 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";
|
import { api } from "../../../lib/api";
|
||||||
|
|
||||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||||
@@ -22,67 +22,72 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
|||||||
list: vi.fn().mockResolvedValue({
|
list: vi.fn().mockResolvedValue({
|
||||||
default: "claude",
|
default: "claude",
|
||||||
tools: [
|
tools: [
|
||||||
{ id: "claude", label: "Claude Code", routerCapable: true },
|
{ id: "claude", label: "Claude Code", proxyCapable: true },
|
||||||
{ id: "gemini", label: "Gemini", routerCapable: false },
|
{ id: "gemini", label: "Gemini", proxyCapable: false },
|
||||||
].map((x) => ({ ...x, installed: true, enabled: true })),
|
].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 native = { name: "native", label: "Native", builtin: true } as AiProxy;
|
||||||
const nine = { name: "9router", label: "9Router", builtin: false } as AiRouter;
|
const nine = { name: "9router", label: "9Router", builtin: false } as AiProxy;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
vi.mocked(api.aiRouters.list)
|
vi.mocked(api.aiProxies.list)
|
||||||
.mockReset()
|
.mockReset()
|
||||||
.mockResolvedValue({ items: [native, nine] } as never);
|
.mockResolvedValue({ items: [native, nine] } as never);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("RouterPicker", () => {
|
describe("ProxyPicker", () => {
|
||||||
it("lists the saved profiles for a tool that can use one", async () => {
|
it("lists the saved profiles for a tool that can use one", async () => {
|
||||||
render(
|
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();
|
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(
|
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.querySelector("button")).toBeDisabled();
|
||||||
expect(picker).not.toHaveTextContent("9Router");
|
expect(picker).not.toHaveTextContent("9Router");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to Native when the remembered profile is gone", async () => {
|
it("falls back to Native when the remembered profile is gone", async () => {
|
||||||
const onChange = vi.fn();
|
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"));
|
await waitFor(() => expect(onChange).toHaveBeenCalledWith("native"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("useAiRouters reads the saved profiles once", async () => {
|
it("useAiProxies reads the saved profiles once", async () => {
|
||||||
const { result } = renderHook(() => useAiRouters());
|
const { result } = renderHook(() => useAiProxies());
|
||||||
await waitFor(() => expect(result.current).toEqual([native, nine]));
|
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", () => {
|
it("remembers the last pick for the next session", () => {
|
||||||
const first = renderHook(() => useSessionRouter());
|
const first = renderHook(() => useSessionProxy());
|
||||||
expect(first.result.current[0]).toBe("native");
|
expect(first.result.current[0]).toBe("native");
|
||||||
act(() => first.result.current[1]("9router"));
|
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", () => {
|
it("still reads the pick remembered before the rename", () => {
|
||||||
expect(routerArg(true, "9router")).toEqual({ router: "9router" });
|
localStorage.setItem("agenthub.session.router", "9router");
|
||||||
expect(routerArg(true, "native")).toEqual({});
|
expect(renderHook(() => useSessionProxy()).result.current[0]).toBe("9router");
|
||||||
expect(routerArg(false, "9router")).toEqual({});
|
});
|
||||||
|
|
||||||
|
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
|
* `PromptEditor`'s `/` and `@` autocomplete (Enter picks while the list is
|
||||||
* open, `/` only where slash commands apply), Shift/Alt+Enter newlines, a
|
* 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
|
* 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
|
* 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,
|
* same list as one chip: aliases up front, pinned versions under More models,
|
||||||
* plus the effort slider.
|
* plus the effort slider.
|
||||||
@@ -211,7 +211,7 @@ describe("ModelPicker", () => {
|
|||||||
expect(await screen.findByText("GPT-6-Sol")).toBeTruthy();
|
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(
|
render(
|
||||||
<ModelPicker
|
<ModelPicker
|
||||||
value=""
|
value=""
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* computed from — as shown on a tool's Settings tab (Overview for Claude Code
|
* computed from — as shown on a tool's Settings tab (Overview for Claude Code
|
||||||
* and Codex, the Antigravity tab itself): Claude
|
* and Codex, the Antigravity tab itself): Claude
|
||||||
* Code's shows `claude-…` rows, Codex's shows OpenAI rows (`gpt-…`, `o<n>…`,
|
* 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
|
* **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
|
* 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
|
* 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" },
|
prompt: { mode: "argv" },
|
||||||
resume: false,
|
resume: false,
|
||||||
bypass: false,
|
bypass: false,
|
||||||
routerCapable: false,
|
proxyCapable: false,
|
||||||
install: null,
|
install: null,
|
||||||
update: null,
|
update: null,
|
||||||
modelFlag: false,
|
modelFlag: false,
|
||||||
|
|||||||
@@ -215,8 +215,8 @@
|
|||||||
"proof.ticketReport": "Task report",
|
"proof.ticketReport": "Task report",
|
||||||
"renameLabel": "Rename this workspace",
|
"renameLabel": "Rename this workspace",
|
||||||
"reorderHint": "Drag to reorder · Alt+←/→",
|
"reorderHint": "Drag to reorder · Alt+←/→",
|
||||||
"router.native": "Native ({{tool}})",
|
"proxy.native": "Native ({{tool}})",
|
||||||
"router.label": "AI Router",
|
"proxy.label": "AI Proxy",
|
||||||
"runtime.boot": "▶ up",
|
"runtime.boot": "▶ up",
|
||||||
"runtime.busy.down": "stopping…",
|
"runtime.busy.down": "stopping…",
|
||||||
"runtime.busy.up": "booting…",
|
"runtime.busy.up": "booting…",
|
||||||
|
|||||||
@@ -577,13 +577,13 @@
|
|||||||
"fieldLabelPlaceholder": "Dev box"
|
"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).",
|
"cursorPathsNote": "Informational: Cursor sessions count here too — Cursor happens to use the same ~/.claude paths as Claude Code (locally and on synced remotes).",
|
||||||
"aiRouter": {
|
"aiProxy": {
|
||||||
"title": "AI Router",
|
"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.",
|
"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",
|
"add": "Add profile",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"remove": "Remove",
|
"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.",
|
"builtinHint": "Built in — this is the vendor's own API, and it cannot be edited or removed.",
|
||||||
"colLabel": "Profile",
|
"colLabel": "Profile",
|
||||||
"colBaseUrl": "Endpoint",
|
"colBaseUrl": "Endpoint",
|
||||||
@@ -592,16 +592,16 @@
|
|||||||
"keySet": "Saved",
|
"keySet": "Saved",
|
||||||
"keyMissing": "None",
|
"keyMissing": "None",
|
||||||
"fieldLabel": "Name",
|
"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",
|
"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",
|
"fieldApiKey": "API key",
|
||||||
"fieldApiKeyHint": "Stored on this machine at 0600 and never sent back to the browser.",
|
"fieldApiKeyHint": "Stored on this machine at 0600 and never sent back to the browser.",
|
||||||
"fieldApiKeyKeep": "Leave blank to keep the saved key.",
|
"fieldApiKeyKeep": "Leave blank to keep the saved key.",
|
||||||
"fieldModel": "Model",
|
"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)",
|
"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",
|
"perTier": "Per-tier overrides",
|
||||||
"perTierHint": "Leave a field blank to use the model above.",
|
"perTierHint": "Leave a field blank to use the model above.",
|
||||||
"tier": {
|
"tier": {
|
||||||
@@ -613,12 +613,12 @@
|
|||||||
},
|
},
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"cancel": "Cancel",
|
"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…",
|
"loading": "Loading profiles…",
|
||||||
"test": "Test",
|
"test": "Test",
|
||||||
"testing": "Testing…",
|
"testing": "Testing…",
|
||||||
"testOk": "The endpoint answered — {{model}} is reachable with this key.",
|
"testOk": "answered — {{model}} is reachable with this key.",
|
||||||
"testFailed": "No answer ({{status}}): {{message}}"
|
"testFailed": "no answer ({{status}}): {{message}}"
|
||||||
},
|
},
|
||||||
"aiTools": {
|
"aiTools": {
|
||||||
"title": "AI Tools",
|
"title": "AI Tools",
|
||||||
@@ -700,7 +700,7 @@
|
|||||||
},
|
},
|
||||||
"codexHome": {
|
"codexHome": {
|
||||||
"title": "CODEX_HOME",
|
"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.",
|
"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"
|
"placeholder": "/path/to/codex-home"
|
||||||
},
|
},
|
||||||
@@ -792,8 +792,8 @@
|
|||||||
"tool": {
|
"tool": {
|
||||||
"description": "{{tool}}: path, usage, default model and config files."
|
"description": "{{tool}}: path, usage, default model and config files."
|
||||||
},
|
},
|
||||||
"aiRouter": {
|
"aiProxy": {
|
||||||
"title": "AI Router",
|
"title": "AI Proxy",
|
||||||
"description": "Endpoints that stand in front of Claude Code and Codex."
|
"description": "Endpoints that stand in front of Claude Code and Codex."
|
||||||
},
|
},
|
||||||
"pricing": {
|
"pricing": {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"releaseUnavailable": "Could not read the latest release from the repository ({{reason}}); fell back to comparing commits.",
|
"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.",
|
"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...",
|
"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.",
|
"lastPluginUpdateFailed": "The last plugin update failed at step \"{{step}}\": {{error}} — run /agenthub-update in Claude Code to recover.",
|
||||||
"restartingShort": "Restarting…",
|
"restartingShort": "Restarting…",
|
||||||
"installedTitle": "Version {{version}} is installed but not running",
|
"installedTitle": "Version {{version}} is installed but not running",
|
||||||
|
|||||||
@@ -215,8 +215,8 @@
|
|||||||
"proof.ticketReport": "Báo cáo nhiệm vụ",
|
"proof.ticketReport": "Báo cáo nhiệm vụ",
|
||||||
"renameLabel": "Đổi tên workspace này",
|
"renameLabel": "Đổi tên workspace này",
|
||||||
"reorderHint": "Kéo để sắp xếp · Alt+←/→",
|
"reorderHint": "Kéo để sắp xếp · Alt+←/→",
|
||||||
"router.native": "Native ({{tool}})",
|
"proxy.native": "Native ({{tool}})",
|
||||||
"router.label": "AI Router",
|
"proxy.label": "AI Proxy",
|
||||||
"runtime.boot": "▶ chạy",
|
"runtime.boot": "▶ chạy",
|
||||||
"runtime.busy.down": "đang dừng…",
|
"runtime.busy.down": "đang dừng…",
|
||||||
"runtime.busy.up": "đang khởi động…",
|
"runtime.busy.up": "đang khởi động…",
|
||||||
|
|||||||
@@ -577,13 +577,13 @@
|
|||||||
"fieldLabelPlaceholder": "Máy dev"
|
"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ộ).",
|
"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": {
|
"aiProxy": {
|
||||||
"title": "AI Router",
|
"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.",
|
"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",
|
"add": "Thêm profile",
|
||||||
"edit": "Sửa",
|
"edit": "Sửa",
|
||||||
"remove": "Xoá",
|
"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.",
|
"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",
|
"colLabel": "Profile",
|
||||||
"colBaseUrl": "Endpoint",
|
"colBaseUrl": "Endpoint",
|
||||||
@@ -592,16 +592,16 @@
|
|||||||
"keySet": "Đã lưu",
|
"keySet": "Đã lưu",
|
||||||
"keyMissing": "Chưa có",
|
"keyMissing": "Chưa có",
|
||||||
"fieldLabel": "Tên",
|
"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",
|
"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",
|
"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.",
|
"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.",
|
"fieldApiKeyKeep": "Để trống nếu muốn giữ key đã lưu.",
|
||||||
"fieldModel": "Model",
|
"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)",
|
"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",
|
"perTier": "Ghi đè theo từng bậc",
|
||||||
"perTierHint": "Để trống một ô là dùng model ở trên.",
|
"perTierHint": "Để trống một ô là dùng model ở trên.",
|
||||||
"tier": {
|
"tier": {
|
||||||
@@ -613,12 +613,12 @@
|
|||||||
},
|
},
|
||||||
"save": "Lưu",
|
"save": "Lưu",
|
||||||
"cancel": "Huỷ",
|
"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…",
|
"loading": "Đang tải danh sách profile…",
|
||||||
"test": "Kiểm tra",
|
"test": "Kiểm tra",
|
||||||
"testing": "Đang kiểm tra…",
|
"testing": "Đang kiểm tra…",
|
||||||
"testOk": "Endpoint đã trả lời — {{model}} dùng được với key này.",
|
"testOk": "đã trả lời — {{model}} dùng được với key này.",
|
||||||
"testFailed": "Không trả lời ({{status}}): {{message}}"
|
"testFailed": "không trả lời ({{status}}): {{message}}"
|
||||||
},
|
},
|
||||||
"aiTools": {
|
"aiTools": {
|
||||||
"title": "AI Tools",
|
"title": "AI Tools",
|
||||||
@@ -700,7 +700,7 @@
|
|||||||
},
|
},
|
||||||
"codexHome": {
|
"codexHome": {
|
||||||
"title": "CODEX_HOME",
|
"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.",
|
"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"
|
"placeholder": "/đường-dẫn/tới/codex-home"
|
||||||
},
|
},
|
||||||
@@ -792,8 +792,8 @@
|
|||||||
"tool": {
|
"tool": {
|
||||||
"description": "{{tool}}: đường dẫn, mức dùng, mô hình mặc định và file cấu hình."
|
"description": "{{tool}}: đường dẫn, mức dùng, mô hình mặc định và file cấu hình."
|
||||||
},
|
},
|
||||||
"aiRouter": {
|
"aiProxy": {
|
||||||
"title": "AI Router",
|
"title": "AI Proxy",
|
||||||
"description": "Các endpoint đứng trước Claude Code và Codex."
|
"description": "Các endpoint đứng trước Claude Code và Codex."
|
||||||
},
|
},
|
||||||
"pricing": {
|
"pricing": {
|
||||||
|
|||||||
@@ -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.",
|
"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 ở đó.",
|
"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...",
|
"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.",
|
"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…",
|
"restartingShort": "Đang khởi động lại…",
|
||||||
"installedTitle": "Đã cài bản {{version}} nhưng chưa chạy",
|
"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
@@ -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
|
/** AgentHub's own git credentials (issue #45), shared by every user of this
|
||||||
* install. No response ever carries a password — only `hasToken`. */
|
* install. No response ever carries a password — only `hasToken`. */
|
||||||
gitCredentials: {
|
gitCredentials: {
|
||||||
@@ -2219,37 +2212,44 @@ export const api = {
|
|||||||
return `${BASE}/ai-tools/${encodeURIComponent(id)}/icon${q}`;
|
return `${BASE}/ai-tools/${encodeURIComponent(id)}/icon${q}`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
aiRouters: {
|
// ──────────────────────────────── AI Proxy API ──────────────────────────────
|
||||||
/** GET /api/ai-routers - every profile, the built-in `native` one first. */
|
/** Named AI Proxy endpoints (9Router, OpenRouter, a self-hosted gateway) a
|
||||||
list: () => request<{ items: AiRouter[] }>("/ai-routers"),
|
* new session can run on instead of the vendor's own API. Managed in the
|
||||||
/** POST /api/ai-routers - save a new profile. */
|
* **AI Proxy** page of Settings; chosen per session (`proxy` on a start).
|
||||||
create: (body: AiRouterInput) =>
|
*
|
||||||
request<AiRouter>("/ai-routers", { method: "POST", body: JSON.stringify(body) }),
|
* 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
|
* keeps the stored one, which is what the edit form always does: it cannot
|
||||||
* display a key, so it has none to send back.
|
* display a key, so it has none to send back.
|
||||||
*/
|
*/
|
||||||
update: (name: string, body: Partial<AiRouterInput>) =>
|
update: (name: string, body: Partial<AiProxyInput>) =>
|
||||||
request<AiRouter>(`/ai-routers/${encodeURIComponent(name)}`, {
|
request<AiProxy>(`/ai-proxies/${encodeURIComponent(name)}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}),
|
}),
|
||||||
/**
|
/**
|
||||||
* POST /api/ai-routers/test - ask the endpoint itself whether it works,
|
* POST /api/ai-proxies/test - ask the endpoint itself whether it works
|
||||||
* before a profile that cannot reach anything is saved onto a lane.
|
* for Claude Code and for Codex, before a session finds out it does not.
|
||||||
*/
|
*/
|
||||||
test: (body: AiRouterTestInput) =>
|
test: (body: AiProxyTestInput) =>
|
||||||
request<AiRouterTestResult>("/ai-routers/test", {
|
request<AiProxyTestResult>("/ai-proxies/test", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(body),
|
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.
|
* copy its key.
|
||||||
*/
|
*/
|
||||||
remove: (name: string) =>
|
remove: (name: string) =>
|
||||||
request<{ removed: string }>(`/ai-routers/${encodeURIComponent(name)}`, {
|
request<{ removed: string }>(`/ai-proxies/${encodeURIComponent(name)}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -3307,10 +3307,10 @@ export interface CcProfileUsage {
|
|||||||
* user + project scope in one response. */
|
* user + project scope in one response. */
|
||||||
export type CcScope = "user" | "project" | "all";
|
export type CcScope = "user" | "project" | "all";
|
||||||
|
|
||||||
/** The per-tier model names an AI Router profile carries. Each maps to one env
|
/** The per-tier model names an AI Proxy profile carries. Each maps to one env
|
||||||
* var Claude Code reads; a router that names its models once fills them all
|
* var Claude Code reads; a proxy that names its models once fills them all
|
||||||
* from the profile's `model`. */
|
* from the profile's `model`. */
|
||||||
export interface AiRouterModels {
|
export interface AiProxyModels {
|
||||||
opus: string;
|
opus: string;
|
||||||
sonnet: string;
|
sonnet: string;
|
||||||
haiku: string;
|
haiku: string;
|
||||||
@@ -3318,7 +3318,6 @@ export interface AiRouterModels {
|
|||||||
subagent: string;
|
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. */
|
/** A saved git login, as the server reports it: never the password. */
|
||||||
export interface GitCredential {
|
export interface GitCredential {
|
||||||
protocol: "http" | "https";
|
protocol: "http" | "https";
|
||||||
@@ -3362,8 +3361,8 @@ export interface AiTool {
|
|||||||
resume: boolean;
|
resume: boolean;
|
||||||
/** Offers the per-run "Bypass permissions" switch. */
|
/** Offers the per-run "Bypass permissions" switch. */
|
||||||
bypass: boolean;
|
bypass: boolean;
|
||||||
/** Can use an AI Router profile (Claude, Codex); others are native only. */
|
/** Can use an AI Proxy profile (Claude, Codex); others are native only. */
|
||||||
routerCapable: boolean;
|
proxyCapable: boolean;
|
||||||
/** What Install runs; null when the catalog names no install. */
|
/** What Install runs; null when the catalog names no install. */
|
||||||
install: { kind: "npm"; package: string } | { kind: "script"; url: string } | null;
|
install: { kind: "npm"; package: string } | { kind: "script"; url: string } | null;
|
||||||
/** What Update runs; `self` is the tool's own updater with these args. */
|
/** What Update runs; `self` is the tool's own updater with these args. */
|
||||||
@@ -3421,37 +3420,47 @@ export interface AiToolJob {
|
|||||||
log: string;
|
log: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AiRouter {
|
/** One saved AI Proxy profile, as the API returns it — **never** with a key. */
|
||||||
/** Identifier, derived from the label. Stable: a lane stores this. */
|
export interface AiProxy {
|
||||||
|
/** Identifier, derived from the label. Stable: a session's run row stores it. */
|
||||||
name: string;
|
name: string;
|
||||||
label: string;
|
label: string;
|
||||||
/** True only for the read-only `native` profile (the vendor's own API). */
|
/** True only for the read-only `native` profile (the vendor's own API). */
|
||||||
builtin: boolean;
|
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;
|
baseUrl: string;
|
||||||
/** Whether a key is stored. The key itself never leaves the server. */
|
/** Whether a key is stored. The key itself never leaves the server. */
|
||||||
hasKey: boolean;
|
hasKey: boolean;
|
||||||
|
/** "" for `native`, which has no model of its own. */
|
||||||
model: string;
|
model: string;
|
||||||
models: AiRouterModels;
|
/** Empty for `native`. */
|
||||||
/** Claude model id the router's own model names behave as; "" when unset. */
|
models: Partial<AiProxyModels>;
|
||||||
|
/** Claude model id the proxy's own model names behave as; "" when unset. */
|
||||||
behavesAs?: string;
|
behavesAs?: string;
|
||||||
savedAt?: string;
|
savedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Outcome of POST /api/ai-routers/test. A router that answered with an error
|
/** One probe of POST /api/ai-proxies/test. A proxy that answered with an
|
||||||
* is still a successful HTTP call: `ok` is the ROUTER's verdict, `status` is
|
* error is still a successful HTTP call: `ok` is the PROXY's verdict, `status`
|
||||||
* its HTTP code (null when nothing answered at all), and `message` is its own
|
* is its HTTP code (null when nothing answered at all), and `message` is its
|
||||||
* words — truncated server-side, never the key. */
|
* own words — truncated server-side, never the key. */
|
||||||
export interface AiRouterTestResult {
|
export interface AiProxyProbe {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
status: number | null;
|
status: number | null;
|
||||||
message: string;
|
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
|
* 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. */
|
* saved one, so a typed-in endpoint always needs a typed-in key. */
|
||||||
export interface AiRouterTestInput {
|
export interface AiProxyTestInput {
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
model: string;
|
model: string;
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
@@ -3459,14 +3468,14 @@ export interface AiRouterTestInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Body accepted when creating or updating a profile. */
|
/** Body accepted when creating or updating a profile. */
|
||||||
export interface AiRouterInput {
|
export interface AiProxyInput {
|
||||||
label: string;
|
label: string;
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
/** Omit on update to keep the stored key. */
|
/** Omit on update to keep the stored key. */
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
model: string;
|
model: string;
|
||||||
/** Any tier left out is filled from `model`. */
|
/** 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. */
|
/** A Claude model id; "" clears it. Omit on update to keep the stored one. */
|
||||||
behavesAs?: string;
|
behavesAs?: string;
|
||||||
}
|
}
|
||||||
@@ -4124,8 +4133,8 @@ export interface RunStartArgs {
|
|||||||
resumeLast?: boolean;
|
resumeLast?: boolean;
|
||||||
/** Turn off the tool's permission prompts for THIS run (its catalog bypass args). */
|
/** Turn off the tool's permission prompts for THIS run (its catalog bypass args). */
|
||||||
bypass?: boolean;
|
bypass?: boolean;
|
||||||
/** AI Router profile THIS session runs on; omitted = native, or a resume's own. */
|
/** AI Proxy profile THIS session runs on; omitted = native, or a resume's own. */
|
||||||
router?: string;
|
proxy?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A lane's tmux-backed terminal run — one per lane, id is the tmux session
|
/** A lane's tmux-backed terminal run — one per lane, id is the tmux session
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -259,7 +259,7 @@ export const SETTINGS_INDEX: {
|
|||||||
{ section: "agents/codex", titleKey: "codexConfig:tabs.plugins", to: "?tab=plugins" },
|
{ section: "agents/codex", titleKey: "codexConfig:tabs.plugins", to: "?tab=plugins" },
|
||||||
{ section: "agents/antigravity", titleKey: "antigravityHooks.title", keywords: ["agy", "hooks"] },
|
{ section: "agents/antigravity", titleKey: "antigravityHooks.title", keywords: ["agy", "hooks"] },
|
||||||
{ section: "agents/*", titleKey: "toolTab.defaultModel", keywords: ["model", "config"] },
|
{ 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: "pricing", titleKey: "pricing.title", keywords: ["cost", "price", "tokens"] },
|
||||||
{ section: "git-credentials", titleKey: "gitCreds.title", keywords: ["login", "token"] },
|
{ section: "git-credentials", titleKey: "gitCreds.title", keywords: ["login", "token"] },
|
||||||
{ section: "worktrees", titleKey: "worktrees.showUntracked", keywords: ["hide", "suggestions"] },
|
{ section: "worktrees", titleKey: "worktrees.showUntracked", keywords: ["hide", "suggestions"] },
|
||||||
|
|||||||
@@ -2652,10 +2652,9 @@ export interface Lane {
|
|||||||
kind: "managed" | "adopted";
|
kind: "managed" | "adopted";
|
||||||
/** The CLI this lane's runs spawn. Immutable once the lane exists. */
|
/** The CLI this lane's runs spawn. Immutable once the lane exists. */
|
||||||
tool: LaneTool;
|
tool: LaneTool;
|
||||||
/** Name of the AI Router profile this lane runs against, or null for the
|
/** Legacy: the per-workspace AI Proxy (then "AI Router") profile. A proxy
|
||||||
* vendor's own API. Unlike `tool` this IS changeable after creation, but
|
* is a session's choice now and the server empties this at every start, so
|
||||||
* only through `POST /api/lanes/:id/router/apply` — the column and the
|
* it is always null. */
|
||||||
* lane's own config on disk must move together. */
|
|
||||||
router_profile: string | null;
|
router_profile: string | null;
|
||||||
/** For a managed worktree lane, the repo it was provisioned from. Null for
|
/** 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). */
|
* an adopted lane (it IS a source repo, not a worktree of one). */
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* @file AiRouterConfig.tsx
|
* @file AiProxyConfig.tsx
|
||||||
* @description The **AI Router** tab of Settings — CRUD for the named router
|
* @description The **AI Proxy** page of Settings — CRUD for the named proxy
|
||||||
* endpoints (9Router, OpenRouter, a self-hosted gateway) a lane can be pointed
|
* endpoints (9Router, OpenRouter, a self-hosted gateway) a session can run on
|
||||||
* at instead of the vendor's own API.
|
* instead of the vendor's own API.
|
||||||
*
|
*
|
||||||
* A NEW SESSION chooses its router (the workspace home's prompt box, a pane's
|
* A NEW SESSION chooses its proxy (the workspace home's prompt box), not here. An edit here reaches the sessions already on a profile
|
||||||
* run form), 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 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,
|
* 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
|
* 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
|
* 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.
|
* 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)
|
* A right-click on a profile row opens its Edit / Remove (plus Copy endpoint)
|
||||||
* as a menu; the buttons stay.
|
* 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
|
* "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
|
* ever discovered inside a session otherwise, where the error surfaces as a
|
||||||
* as a broken CLI rather than a bad URL.
|
* broken CLI rather than a bad URL.
|
||||||
*
|
*
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Plus, Pencil, Trash2, Lock } from "lucide-react";
|
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 { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu";
|
||||||
import { confirmDialog } from "../lib/confirm";
|
import { confirmDialog } from "../lib/confirm";
|
||||||
import { copyText } from "../lib/clipboard";
|
import { copyText } from "../lib/clipboard";
|
||||||
import { useDialog } from "../hooks/useDialog";
|
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;
|
apiKey: string;
|
||||||
behavesAs: string;
|
behavesAs: string;
|
||||||
models: Partial<AiRouterModels>;
|
models: Partial<AiProxyModels>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const emptyForm = (): FormState => ({
|
const emptyForm = (): FormState => ({
|
||||||
@@ -54,11 +61,11 @@ const emptyForm = (): FormState => ({
|
|||||||
behavesAs: "",
|
behavesAs: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
export function AiRouterConfig() {
|
export function AiProxyConfig() {
|
||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
const [items, setItems] = useState<AiRouter[]>([]);
|
const [items, setItems] = useState<AiProxy[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 [form, setForm] = useState(emptyForm());
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -66,14 +73,14 @@ export function AiRouterConfig() {
|
|||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
const dialogRef = useRef<HTMLDivElement>(null);
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
useDialog(open, dialogRef, () => setOpen(false));
|
useDialog(open, dialogRef, () => setOpen(false));
|
||||||
// The router's own verdict on the form as it currently stands. Cleared on
|
// The proxy's own verdict on the form as it currently stands, one line per
|
||||||
// every field change: a green "works" left sitting above an edited base URL
|
// CLI. Cleared on every field change: a green "works" left sitting above an
|
||||||
// is a claim about a configuration that no longer exists.
|
// edited base URL is a claim about a configuration that no longer exists.
|
||||||
const [testResult, setTestResult] = useState<{ ok: boolean; text: string } | null>(null);
|
const [testResult, setTestResult] = useState<{ ok: boolean; text: string }[] | null>(null);
|
||||||
const [testing, setTesting] = useState(false);
|
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.
|
// 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;
|
const item = ctx.menu?.item;
|
||||||
ctx.close();
|
ctx.close();
|
||||||
if (item) fn(item);
|
if (item) fn(item);
|
||||||
@@ -91,7 +98,7 @@ export function AiRouterConfig() {
|
|||||||
// vanish. Keeping the message untranslated is the cheaper half of the trade.
|
// vanish. Keeping the message untranslated is the cheaper half of the trade.
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setItems((await api.aiRouters.list()).items);
|
setItems((await api.aiProxies.list()).items);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : String(e));
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
@@ -114,7 +121,7 @@ export function AiRouterConfig() {
|
|||||||
setOpen(true);
|
setOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const startEdit = (item: AiRouter) => {
|
const startEdit = (item: AiProxy) => {
|
||||||
setEditing(item);
|
setEditing(item);
|
||||||
// No apiKey: the server never sent one. Blank means "keep what is stored".
|
// No apiKey: the server never sent one. Blank means "keep what is stored".
|
||||||
setForm({
|
setForm({
|
||||||
@@ -129,7 +136,6 @@ export function AiRouterConfig() {
|
|||||||
setOpen(true);
|
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
|
* Ask the endpoint itself. `name` is sent when editing so a profile whose key
|
||||||
* the form cannot show is still testable — the server only honours it while
|
* the form cannot show is still testable — the server only honours it while
|
||||||
@@ -139,23 +145,23 @@ export function AiRouterConfig() {
|
|||||||
setTesting(true);
|
setTesting(true);
|
||||||
setTestResult(null);
|
setTestResult(null);
|
||||||
try {
|
try {
|
||||||
const result = await api.aiRouters.test({
|
const result = await api.aiProxies.test({
|
||||||
baseUrl: form.baseUrl.trim(),
|
baseUrl: form.baseUrl.trim(),
|
||||||
model: form.model.trim(),
|
model: form.model.trim(),
|
||||||
...(form.apiKey.trim() ? { apiKey: form.apiKey.trim() } : {}),
|
...(form.apiKey.trim() ? { apiKey: form.apiKey.trim() } : {}),
|
||||||
...(editing ? { name: editing.name } : {}),
|
...(editing ? { name: editing.name } : {}),
|
||||||
});
|
});
|
||||||
setTestResult({
|
const line = (cli: string, probe: AiProxyProbe) => ({
|
||||||
ok: result.ok,
|
ok: probe.ok,
|
||||||
text: result.ok
|
text: `${cli}: ${
|
||||||
? t("aiRouter.testOk", { model: result.message })
|
probe.ok
|
||||||
: t("aiRouter.testFailed", {
|
? t("aiProxy.testOk", { model: probe.message })
|
||||||
status: result.status ?? "—",
|
: t("aiProxy.testFailed", { status: probe.status ?? "—", message: probe.message })
|
||||||
message: result.message,
|
}`,
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
setTestResult([line("Claude Code", result.claude), line("Codex", result.codex)]);
|
||||||
} catch (e) {
|
} 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 {
|
} finally {
|
||||||
setTesting(false);
|
setTesting(false);
|
||||||
}
|
}
|
||||||
@@ -167,13 +173,13 @@ export function AiRouterConfig() {
|
|||||||
try {
|
try {
|
||||||
// Send only the tiers that differ from the headline model, so the server
|
// 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
|
// keeps filling the rest from it — that is what makes one symbolic name
|
||||||
// enough for a router that wants the same value everywhere.
|
// enough for a proxy that wants the same value everywhere.
|
||||||
const models: Partial<AiRouterModels> = {};
|
const models: Partial<AiProxyModels> = {};
|
||||||
for (const tier of TIERS) {
|
for (const tier of TIERS) {
|
||||||
const value = (form.models[tier] || "").trim();
|
const value = (form.models[tier] || "").trim();
|
||||||
if (value && value !== form.model.trim()) models[tier] = value;
|
if (value && value !== form.model.trim()) models[tier] = value;
|
||||||
}
|
}
|
||||||
const body: AiRouterInput = {
|
const body: AiProxyInput = {
|
||||||
label: form.label.trim(),
|
label: form.label.trim(),
|
||||||
baseUrl: form.baseUrl.trim(),
|
baseUrl: form.baseUrl.trim(),
|
||||||
model: form.model.trim(),
|
model: form.model.trim(),
|
||||||
@@ -182,8 +188,8 @@ export function AiRouterConfig() {
|
|||||||
};
|
};
|
||||||
if (form.apiKey.trim()) body.apiKey = form.apiKey.trim();
|
if (form.apiKey.trim()) body.apiKey = form.apiKey.trim();
|
||||||
|
|
||||||
if (editing) await api.aiRouters.update(editing.name, body);
|
if (editing) await api.aiProxies.update(editing.name, body);
|
||||||
else await api.aiRouters.create(body);
|
else await api.aiProxies.create(body);
|
||||||
|
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
await load();
|
await load();
|
||||||
@@ -194,17 +200,17 @@ export function AiRouterConfig() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const remove = async (item: AiRouter) => {
|
const remove = async (item: AiProxy) => {
|
||||||
if (
|
if (
|
||||||
!(await confirmDialog({
|
!(await confirmDialog({
|
||||||
title: t("aiRouter.removeConfirm", { label: item.label }),
|
title: t("aiProxy.removeConfirm", { label: item.label }),
|
||||||
confirmLabel: t("aiRouter.remove"),
|
confirmLabel: t("aiProxy.remove"),
|
||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await api.aiRouters.remove(item.name);
|
await api.aiProxies.remove(item.name);
|
||||||
await load();
|
await load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : String(e));
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
@@ -215,14 +221,14 @@ export function AiRouterConfig() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
{/* The page header above already names the section. */}
|
{/* 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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={startAdd}
|
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"
|
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" />
|
<Plus className="h-3.5 w-3.5" />
|
||||||
{t("aiRouter.add")}
|
{t("aiProxy.add")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -236,10 +242,10 @@ export function AiRouterConfig() {
|
|||||||
<table className="w-full text-left text-xs">
|
<table className="w-full text-left text-xs">
|
||||||
<thead className="bg-surface-2 text-[10px] uppercase tracking-wider text-fg-muted">
|
<thead className="bg-surface-2 text-[10px] uppercase tracking-wider text-fg-muted">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-3 py-2 font-medium">{t("aiRouter.colLabel")}</th>
|
<th className="px-3 py-2 font-medium">{t("aiProxy.colLabel")}</th>
|
||||||
<th className="px-3 py-2 font-medium">{t("aiRouter.colBaseUrl")}</th>
|
<th className="px-3 py-2 font-medium">{t("aiProxy.colBaseUrl")}</th>
|
||||||
<th className="px-3 py-2 font-medium">{t("aiRouter.colModel")}</th>
|
<th className="px-3 py-2 font-medium">{t("aiProxy.colModel")}</th>
|
||||||
<th className="px-3 py-2 font-medium">{t("aiRouter.colKey")}</th>
|
<th className="px-3 py-2 font-medium">{t("aiProxy.colKey")}</th>
|
||||||
<th className="px-3 py-2" />
|
<th className="px-3 py-2" />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -257,27 +263,25 @@ export function AiRouterConfig() {
|
|||||||
</span>
|
</span>
|
||||||
{item.builtin && (
|
{item.builtin && (
|
||||||
<span className="mt-0.5 block text-[10px] text-fg-muted">
|
<span className="mt-0.5 block text-[10px] text-fg-muted">
|
||||||
{t("aiRouter.builtinHint")}
|
{t("aiProxy.builtinHint")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 font-mono text-[11px] text-fg-secondary">
|
<td className="px-3 py-2 font-mono text-[11px] text-fg-secondary">
|
||||||
{item.baseUrl || "—"}
|
{item.baseUrl || "—"}
|
||||||
</td>
|
</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">
|
<td className="px-3 py-2 text-fg-secondary">
|
||||||
{item.builtin
|
{item.builtin ? "—" : item.hasKey ? t("aiProxy.keySet") : t("aiProxy.keyMissing")}
|
||||||
? "—"
|
|
||||||
: item.hasKey
|
|
||||||
? t("aiRouter.keySet")
|
|
||||||
: t("aiRouter.keyMissing")}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-right">
|
<td className="px-3 py-2 text-right">
|
||||||
{!item.builtin && (
|
{!item.builtin && (
|
||||||
<span className="inline-flex gap-1">
|
<span className="inline-flex gap-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`${t("aiRouter.edit")} ${item.label}`}
|
aria-label={`${t("aiProxy.edit")} ${item.label}`}
|
||||||
onClick={() => startEdit(item)}
|
onClick={() => startEdit(item)}
|
||||||
className="rounded p-1 text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary"
|
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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`${t("aiRouter.remove")} ${item.label}`}
|
aria-label={`${t("aiProxy.remove")} ${item.label}`}
|
||||||
onClick={() => remove(item)}
|
onClick={() => remove(item)}
|
||||||
className="rounded p-1 text-fg-muted transition-colors hover:bg-surface-3 hover:text-status-danger"
|
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) && (
|
{(loading || items.length <= 1) && (
|
||||||
<tr className="border-t border-border">
|
<tr className="border-t border-border">
|
||||||
<td colSpan={5} className="px-3 py-4 text-center text-fg-muted">
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
@@ -307,8 +311,8 @@ export function AiRouterConfig() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{ctx.menu && (
|
{ctx.menu && (
|
||||||
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="ai-router-menu">
|
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="ai-proxy-menu">
|
||||||
<MenuItem onSelect={pick(startEdit)}>{t("aiRouter.edit")}</MenuItem>
|
<MenuItem onSelect={pick(startEdit)}>{t("aiProxy.edit")}</MenuItem>
|
||||||
{ctx.menu.item.baseUrl && (
|
{ctx.menu.item.baseUrl && (
|
||||||
<MenuItem onSelect={pick((item) => void copyText(item.baseUrl))}>
|
<MenuItem onSelect={pick((item) => void copyText(item.baseUrl))}>
|
||||||
{t("rowMenu.copyUrl")}
|
{t("rowMenu.copyUrl")}
|
||||||
@@ -316,7 +320,7 @@ export function AiRouterConfig() {
|
|||||||
)}
|
)}
|
||||||
<MenuSeparator />
|
<MenuSeparator />
|
||||||
<MenuItem danger onSelect={pick((item) => void remove(item))}>
|
<MenuItem danger onSelect={pick((item) => void remove(item))}>
|
||||||
{t("aiRouter.remove")}
|
{t("aiProxy.remove")}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
)}
|
)}
|
||||||
@@ -330,36 +334,36 @@ export function AiRouterConfig() {
|
|||||||
ref={dialogRef}
|
ref={dialogRef}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
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"
|
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">
|
<h3 id="proxy-form-title" className="mb-3 text-sm font-semibold text-fg-primary">
|
||||||
{editing ? t("aiRouter.edit") : t("aiRouter.add")}
|
{editing ? t("aiProxy.edit") : t("aiProxy.add")}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Field
|
<Field
|
||||||
id="router-label"
|
id="proxy-label"
|
||||||
label={t("aiRouter.fieldLabel")}
|
label={t("aiProxy.fieldLabel")}
|
||||||
hint={t("aiRouter.fieldLabelHint")}
|
hint={t("aiProxy.fieldLabelHint")}
|
||||||
value={form.label}
|
value={form.label}
|
||||||
onChange={(v) => setForm((f) => ({ ...f, label: v }))}
|
onChange={(v) => setForm((f) => ({ ...f, label: v }))}
|
||||||
/>
|
/>
|
||||||
<Field
|
<Field
|
||||||
id="router-base-url"
|
id="proxy-base-url"
|
||||||
label={t("aiRouter.fieldBaseUrl")}
|
label={t("aiProxy.fieldBaseUrl")}
|
||||||
hint={t("aiRouter.fieldBaseUrlHint")}
|
hint={t("aiProxy.fieldBaseUrlHint")}
|
||||||
value={form.baseUrl}
|
value={form.baseUrl}
|
||||||
mono
|
mono
|
||||||
onChange={(v) => setForm((f) => ({ ...f, baseUrl: v }))}
|
onChange={(v) => setForm((f) => ({ ...f, baseUrl: v }))}
|
||||||
/>
|
/>
|
||||||
<Field
|
<Field
|
||||||
id="router-api-key"
|
id="proxy-api-key"
|
||||||
label={t("aiRouter.fieldApiKey")}
|
label={t("aiProxy.fieldApiKey")}
|
||||||
hint={
|
hint={
|
||||||
editing && editing.hasKey
|
editing && editing.hasKey
|
||||||
? t("aiRouter.fieldApiKeyKeep")
|
? t("aiProxy.fieldApiKeyKeep")
|
||||||
: t("aiRouter.fieldApiKeyHint")
|
: t("aiProxy.fieldApiKeyHint")
|
||||||
}
|
}
|
||||||
value={form.apiKey}
|
value={form.apiKey}
|
||||||
type="password"
|
type="password"
|
||||||
@@ -367,17 +371,17 @@ export function AiRouterConfig() {
|
|||||||
onChange={(v) => setForm((f) => ({ ...f, apiKey: v }))}
|
onChange={(v) => setForm((f) => ({ ...f, apiKey: v }))}
|
||||||
/>
|
/>
|
||||||
<Field
|
<Field
|
||||||
id="router-model"
|
id="proxy-model"
|
||||||
label={t("aiRouter.fieldModel")}
|
label={t("aiProxy.fieldModel")}
|
||||||
hint={t("aiRouter.fieldModelHint")}
|
hint={t("aiProxy.fieldModelHint")}
|
||||||
value={form.model}
|
value={form.model}
|
||||||
mono
|
mono
|
||||||
onChange={(v) => setForm((f) => ({ ...f, model: v }))}
|
onChange={(v) => setForm((f) => ({ ...f, model: v }))}
|
||||||
/>
|
/>
|
||||||
<Field
|
<Field
|
||||||
id="router-behaves-as"
|
id="proxy-behaves-as"
|
||||||
label={t("aiRouter.fieldBehavesAs")}
|
label={t("aiProxy.fieldBehavesAs")}
|
||||||
hint={t("aiRouter.fieldBehavesAsHint")}
|
hint={t("aiProxy.fieldBehavesAsHint")}
|
||||||
value={form.behavesAs}
|
value={form.behavesAs}
|
||||||
mono
|
mono
|
||||||
placeholder="claude-opus-5-5"
|
placeholder="claude-opus-5-5"
|
||||||
@@ -386,15 +390,15 @@ export function AiRouterConfig() {
|
|||||||
|
|
||||||
<details className="rounded-lg border border-border bg-surface-2 p-2">
|
<details className="rounded-lg border border-border bg-surface-2 p-2">
|
||||||
<summary className="cursor-pointer text-xs text-fg-secondary">
|
<summary className="cursor-pointer text-xs text-fg-secondary">
|
||||||
{t("aiRouter.perTier")}
|
{t("aiProxy.perTier")}
|
||||||
</summary>
|
</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">
|
<div className="mt-2 space-y-2">
|
||||||
{TIERS.map((tier) => (
|
{TIERS.map((tier) => (
|
||||||
<Field
|
<Field
|
||||||
key={tier}
|
key={tier}
|
||||||
id={`router-model-${tier}`}
|
id={`proxy-model-${tier}`}
|
||||||
label={t(`aiRouter.tier.${tier}`)}
|
label={t(`aiProxy.tier.${tier}`)}
|
||||||
value={form.models[tier] || ""}
|
value={form.models[tier] || ""}
|
||||||
mono
|
mono
|
||||||
placeholder={form.model}
|
placeholder={form.model}
|
||||||
@@ -416,35 +420,36 @@ export function AiRouterConfig() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{testResult && (
|
{testResult?.map((r) => (
|
||||||
<p
|
<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 ${
|
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-success/40 bg-status-success/10 text-status-success"
|
||||||
: "border-status-danger/40 bg-status-danger/10 text-status-danger"
|
: "border-status-danger/40 bg-status-danger/10 text-status-danger"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{testResult.text}
|
{r.text}
|
||||||
</p>
|
</p>
|
||||||
)}
|
))}
|
||||||
|
|
||||||
<div className="mt-4 flex items-center justify-end gap-2">
|
<div className="mt-4 flex items-center justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="router-test"
|
data-testid="proxy-test"
|
||||||
onClick={test}
|
onClick={test}
|
||||||
disabled={testing || busy || !form.baseUrl.trim() || !form.model.trim()}
|
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"
|
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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
className="rounded-md px-3 py-1.5 text-xs text-fg-secondary transition-colors hover:bg-surface-3"
|
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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -452,7 +457,7 @@ export function AiRouterConfig() {
|
|||||||
disabled={busy || !form.label.trim() || !form.baseUrl.trim() || !form.model.trim()}
|
disabled={busy || !form.label.trim() || !form.baseUrl.trim() || !form.model.trim()}
|
||||||
className="btn-primary px-3 py-1.5 text-xs"
|
className="btn-primary px-3 py-1.5 text-xs"
|
||||||
>
|
>
|
||||||
{t("aiRouter.save")}
|
{t("aiProxy.save")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -46,7 +46,7 @@ import { Settings } from "./Settings";
|
|||||||
import { CcConfig } from "./CcConfig";
|
import { CcConfig } from "./CcConfig";
|
||||||
import { CodexConfig } from "./CodexConfig";
|
import { CodexConfig } from "./CodexConfig";
|
||||||
import { AiToolConfig } from "./AiToolConfig";
|
import { AiToolConfig } from "./AiToolConfig";
|
||||||
import { AiRouterConfig } from "./AiRouterConfig";
|
import { AiProxyConfig } from "./AiProxyConfig";
|
||||||
import { GitCredentialsConfig } from "./GitCredentialsConfig";
|
import { GitCredentialsConfig } from "./GitCredentialsConfig";
|
||||||
import { AppearanceSettings } from "../components/settings/AppearanceSettings";
|
import { AppearanceSettings } from "../components/settings/AppearanceSettings";
|
||||||
import { SnapshotSettings } from "../components/settings/SnapshotSettings";
|
import { SnapshotSettings } from "../components/settings/SnapshotSettings";
|
||||||
@@ -82,7 +82,7 @@ const LEGACY_TOOL: Record<string, string> = {
|
|||||||
dashboard: "appearance",
|
dashboard: "appearance",
|
||||||
"claude-code": "agents/claude-code",
|
"claude-code": "agents/claude-code",
|
||||||
codex: "agents/codex",
|
codex: "agents/codex",
|
||||||
"ai-router": "ai-router",
|
"ai-router": "ai-proxy",
|
||||||
"git-credentials": "git-credentials",
|
"git-credentials": "git-credentials",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -117,7 +117,12 @@ function toolNavIcon(id: string, label: string) {
|
|||||||
return Icon;
|
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. */
|
/** An unknown section: say so, then show Appearance. */
|
||||||
function UnknownSection({ name }: { name: string }) {
|
function UnknownSection({ name }: { name: string }) {
|
||||||
@@ -290,11 +295,11 @@ export function SettingsHub() {
|
|||||||
render: () => <AiToolConfig key={x.id} toolId={x.id} />,
|
render: () => <AiToolConfig key={x.id} toolId={x.id} />,
|
||||||
})),
|
})),
|
||||||
{
|
{
|
||||||
id: "ai-router",
|
id: "ai-proxy",
|
||||||
group: "routing",
|
group: "routing",
|
||||||
Icon: Router,
|
Icon: Router,
|
||||||
...s("aiRouter"),
|
...s("aiProxy"),
|
||||||
render: () => <AiRouterConfig />,
|
render: () => <AiProxyConfig />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "pricing",
|
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" },
|
prompt: { mode: "flag" },
|
||||||
resume: false,
|
resume: false,
|
||||||
bypass: true,
|
bypass: true,
|
||||||
routerCapable: false,
|
proxyCapable: false,
|
||||||
install: { kind: "npm", package: "@google/gemini-cli" },
|
install: { kind: "npm", package: "@google/gemini-cli" },
|
||||||
update: { kind: "npm" },
|
update: { kind: "npm" },
|
||||||
modelFlag: true,
|
modelFlag: true,
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ vi.mock("../CodexConfig", () => ({ CodexConfig: () => <div data-testid="codex-co
|
|||||||
vi.mock("../AiToolConfig", () => ({
|
vi.mock("../AiToolConfig", () => ({
|
||||||
AiToolConfig: ({ toolId }: { toolId: string }) => <div data-testid={`tool-${toolId}`} />,
|
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("../GitCredentialsConfig", () => ({ GitCredentialsConfig: () => null }));
|
||||||
vi.mock("../../components/settings/AiToolsSettings", () => ({
|
vi.mock("../../components/settings/AiToolsSettings", () => ({
|
||||||
AiToolsSettings: () => <div data-testid="ai-tools" />,
|
AiToolsSettings: () => <div data-testid="ai-tools" />,
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
|||||||
features: { list: r({ features: [] }), show: r({ feature: null }) },
|
features: { list: r({ features: [] }), show: r({ feature: null }) },
|
||||||
proof: { list: r({ features: [] }), imageUrl: () => "" },
|
proof: { list: r({ features: [] }), imageUrl: () => "" },
|
||||||
},
|
},
|
||||||
aiRouters: { list: r({ items: [] }) },
|
aiProxies: { list: r({ items: [] }) },
|
||||||
sessions: { list: r({ sessions: [], total: 0, limit: 50, offset: 0 }) },
|
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.
|
// shape that renders no picker — nothing here asserts on it.
|
||||||
aiRouters: {
|
aiProxies: {
|
||||||
list: vi.fn().mockImplementation(async () => {
|
list: vi.fn().mockImplementation(async () => {
|
||||||
recordCall("GET", "/api/ai-routers");
|
recordCall("GET", "/api/ai-proxies");
|
||||||
return { items: [] };
|
return { items: [] };
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7233,7 +7233,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
|||||||
<p
|
<p
|
||||||
class="text-[10px] text-status-warning/90 mt-1"
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -258,7 +258,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
|||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
api: {
|
api: {
|
||||||
aiRouters: { list: r({ items: [] }) },
|
aiProxies: { list: r({ items: [] }) },
|
||||||
stats: { get: r(stats), facets: r({ cwds: [] }) },
|
stats: { get: r(stats), facets: r({ cwds: [] }) },
|
||||||
sessions: {
|
sessions: {
|
||||||
list: r({ sessions: [], total: 0, limit: 50, offset: 0 }),
|
list: r({ sessions: [], total: 0, limit: 50, offset: 0 }),
|
||||||
|
|||||||
+24
-20
@@ -313,7 +313,7 @@ When the session's tool is Codex, three fields control Codex-specific behavior:
|
|||||||
- **`approval`** — one of `on-request` or `never`, or omitted/empty for no approval prompt. Passed to `codex -a <value>`.
|
- **`approval`** — one of `on-request` or `never`, or omitted/empty for no approval prompt. Passed to `codex -a <value>`.
|
||||||
- **`resumeSessionId`** or **`resumeLast: true`** — resume an earlier run instead of starting new. For Codex workspaces specifically, `resumeLast: true` uses `codex resume --last`; `resumeSessionId` uses `codex resume <sessionId>`. For Claude workspaces, `resumeSessionId` re-attaches a Claude session by id.
|
- **`resumeSessionId`** or **`resumeLast: true`** — resume an earlier run instead of starting new. For Codex workspaces specifically, `resumeLast: true` uses `codex resume --last`; `resumeSessionId` uses `codex resume <sessionId>`. For Claude workspaces, `resumeSessionId` re-attaches a Claude session by id.
|
||||||
- **`forkSession: true`** (with `resumeSessionId`, Claude workspaces only) — resume into a NEW session id (`claude --resume <id> --fork-session`), leaving the original untouched; the run records no session until the fork's first hook. Without `resumeSessionId` it is `400 EBADSESSION`, on any other tool `400 EUNSUPPORTED`.
|
- **`forkSession: true`** (with `resumeSessionId`, Claude workspaces only) — resume into a NEW session id (`claude --resume <id> --fork-session`), leaving the original untouched; the run records no session until the fork's first hook. Without `resumeSessionId` it is `400 EBADSESSION`, on any other tool `400 EUNSUPPORTED`.
|
||||||
- **`router: "<name>"`** — the AI Router profile THIS session runs on (omitted = native, or for a resume the router its session last ran on); see § AI Router profiles. `400 EBADROUTER` for an unknown name, `400 EUNSUPPORTED` on a tool other than Claude/Codex.
|
- **`proxy: "<name>"`** — the AI Proxy profile THIS session runs on (omitted = native, or for a resume the proxy its session last ran on; the pre-rename `router` field is still accepted); see § AI Proxy profiles. `400 EBADPROXY` for an unknown name, `400 EUNSUPPORTED` on a tool other than Claude/Codex.
|
||||||
- **`worktree: true`** (Claude workspaces, no `resumeSessionId`) — a NEW session in a git worktree of its own, Claude Code's way: the pane runs `claude --worktree=ah-<id>-<base36 time>` (the `=` form, since `-w [name]` would take the prompt as the name; the name is made by the server), so Claude itself creates `<repo>/.claude/worktrees/<name>` on branch `worktree-<name>`, copies `.worktreeinclude`, runs the user's `WorktreeCreate` hook and cleans up on exit. `/.claude/worktrees/` is added to the repo root's `info/exclude`; the run is recorded with `worktree: 1` (and the run handle carries `worktree: true`). `400 EUNSUPPORTED` on any other tool. A `resumeSessionId` (or fork) of a session that ran in such a worktree starts the pane IN that worktree; one whose worktree is gone is `400 ENOWORKTREE`.
|
- **`worktree: true`** (Claude workspaces, no `resumeSessionId`) — a NEW session in a git worktree of its own, Claude Code's way: the pane runs `claude --worktree=ah-<id>-<base36 time>` (the `=` form, since `-w [name]` would take the prompt as the name; the name is made by the server), so Claude itself creates `<repo>/.claude/worktrees/<name>` on branch `worktree-<name>`, copies `.worktreeinclude`, runs the user's `WorktreeCreate` hook and cleans up on exit. `/.claude/worktrees/` is added to the repo root's `info/exclude`; the run is recorded with `worktree: 1` (and the run handle carries `worktree: true`). `400 EUNSUPPORTED` on any other tool. A `resumeSessionId` (or fork) of a session that ran in such a worktree starts the pane IN that worktree; one whose worktree is gone is `400 ENOWORKTREE`.
|
||||||
|
|
||||||
- **`slot`** — which Workspace session to start, `0`–`9` (ten per workspace) or `"auto"` (default `0`, else `400 EBADSLOT`). `"auto"` lets the server pick the first slot with no live session, under the workspace lock (a client's own pick may come from a stale list); when all ten are busy it answers `409 ERUNLIVE` "every tab of this workspace is busy", while a specific busy slot answers `409 ERUNLIVE` "that tab already has a live session". Slot 0 is the workspace's own `agenthub-lane-<id>` and is what `run_id` records; slots 1–9 are extra sessions on the same workspace, `agenthub-lane-<id>-<slot>`, and never touch `run_id`/`status`. The response adds `run_id`: the session this start opened. `stop`, `reset` and `remove` kill every slot's session, not only `run_id`.
|
- **`slot`** — which Workspace session to start, `0`–`9` (ten per workspace) or `"auto"` (default `0`, else `400 EBADSLOT`). `"auto"` lets the server pick the first slot with no live session, under the workspace lock (a client's own pick may come from a stale list); when all ten are busy it answers `409 ERUNLIVE` "every tab of this workspace is busy", while a specific busy slot answers `409 ERUNLIVE` "that tab already has a live session". Slot 0 is the workspace's own `agenthub-lane-<id>` and is what `run_id` records; slots 1–9 are extra sessions on the same workspace, `agenthub-lane-<id>-<slot>`, and never touch `run_id`/`status`. The response adds `run_id`: the session this start opened. `stop`, `reset` and `remove` kill every slot's session, not only `run_id`.
|
||||||
@@ -606,7 +606,7 @@ answer `{available:false}` with HTTP **200** for a cwd git cannot read.
|
|||||||
never returned. Capped at 1 MB — a larger file is `truncated: true`, never
|
never returned. Capped at 1 MB — a larger file is `truncated: true`, never
|
||||||
`ENOFILE` (a working-tree file is read only up to the cap, never whole).
|
`ENOFILE` (a working-tree file is read only up to the cap, never whole).
|
||||||
`400 EBADPATH`, `EBADREV` or `ENOFILE`. `.claude/settings.local.json`
|
`400 EBADPATH`, `EBADREV` or `ENOFILE`. `.claude/settings.local.json`
|
||||||
(where a workspace's AI Router key lives) is `403 ESECRET` at EVERY rev and at
|
(where an older build kept a workspace's AI Proxy key) is `403 ESECRET` at EVERY rev and at
|
||||||
ANY depth (`sub/.claude/settings.local.json`, a subdirectory workspace's key, as
|
ANY depth (`sub/.claude/settings.local.json`, a subdirectory workspace's key, as
|
||||||
seen from a workspace at the repo root), and so is any path that RESOLVES to it
|
seen from a workspace at the repo root), and so is any path that RESOLVES to it
|
||||||
(a symlinked `.claude` → `conf/` makes it `conf/settings.local.json`). In
|
(a symlinked `.claude` → `conf/` makes it `conf/settings.local.json`). In
|
||||||
@@ -2466,40 +2466,44 @@ GET /api/ai-tools/versions { [id]: { npmManaged, installed, latest, u
|
|||||||
GET /api/ai-tools/:id/icon The tool's favicon, fetched once by the server; 204 → letter avatar
|
GET /api/ai-tools/:id/icon The tool's favicon, fetched once by the server; 204 → letter avatar
|
||||||
```
|
```
|
||||||
|
|
||||||
Each tool: `{ id, label, command, homepageUrl, installed, path, enabled, source: "builtin"|"custom", launcher, prompt: { mode }, resume: bool, bypass: bool, routerCapable: bool }` — flags only, never the arg arrays. Each tool also carries `modelFlag` (the CLI takes a model: Claude, Codex, or a catalog `model: { flag, vendor? }` field) and `defaultModel` (what a new run starts with, `null` = the CLI's own; a `PUT` with `""`/`null` clears it, an id that is not a plain model name is `400 EBADMODEL`). The config files are only those a built-in or release catalog file lists in `config` (`~/`-relative, no `..`, no credential-looking name); a read or save answers `404 ENOFILE` (no such index) / `ENOENT` (missing — never created), `403 ESECRET` (the name, or a symlink target's, looks like a credential), `413 ETOOBIG` (> 1 MB), `415 EBINARY`, `400 EBADSYNTAX` (JSON/JSONC that does not parse), `409 ESTALE` (the file is no longer at `baseSha`); a save backs the old text up under `<data dir>/ai-tool-config-backups/<id>/` and is same-origin only. `GET /:id/models` reads each tool's own source: Codex's `$CODEX_HOME/models_cache.json` (listed entries by priority — nothing else from that file), Claude's aliases named after the newest priced version plus the priced pinned versions, a catalog tool's vendor-priced ids plus the models its own sessions used; `flag: false` and `[]` for a tool with no model flag, `404 ENOTOOL` for an unknown id. `default` is the effective default: the stored one if installed and enabled, else `claude` if installed and enabled, else the first installed enabled tool, else `null`. `PUT` answers `404 ENOTOOL` for an unknown id, `400 EBADDEFAULT` for a default that is not installed or disabled, `400 EBADFIELD` for a non-boolean `enabled`; disabling the default clears it. Writes are same-origin only. State lives in `<data dir>/ai-tools.json`. Each tool also carries `install` (`{kind:"npm", package}` or `{kind:"script", url}`, else null) and `update` (`{kind:"npm"}`, `{kind:"script"}`, `{kind:"self", args}`, else null), and the body carries `catalog: { source: "builtin"|"remote", tag, fetchedAt }` — `remote` while a copy of the newest release's catalog, newer than this build, is in use (synced daily, off with `AGENTHUB_AI_TOOLS_SYNC=0`).
|
Each tool: `{ id, label, command, homepageUrl, installed, path, enabled, source: "builtin"|"custom", launcher, prompt: { mode }, resume: bool, bypass: bool, proxyCapable: bool }` — flags only, never the arg arrays. Each tool also carries `modelFlag` (the CLI takes a model: Claude, Codex, or a catalog `model: { flag, vendor? }` field) and `defaultModel` (what a new run starts with, `null` = the CLI's own; a `PUT` with `""`/`null` clears it, an id that is not a plain model name is `400 EBADMODEL`). The config files are only those a built-in or release catalog file lists in `config` (`~/`-relative, no `..`, no credential-looking name); a read or save answers `404 ENOFILE` (no such index) / `ENOENT` (missing — never created), `403 ESECRET` (the name, or a symlink target's, looks like a credential), `413 ETOOBIG` (> 1 MB), `415 EBINARY`, `400 EBADSYNTAX` (JSON/JSONC that does not parse), `409 ESTALE` (the file is no longer at `baseSha`); a save backs the old text up under `<data dir>/ai-tool-config-backups/<id>/` and is same-origin only. `GET /:id/models` reads each tool's own source: Codex's `$CODEX_HOME/models_cache.json` (listed entries by priority — nothing else from that file), Claude's aliases named after the newest priced version plus the priced pinned versions, a catalog tool's vendor-priced ids plus the models its own sessions used; `flag: false` and `[]` for a tool with no model flag, `404 ENOTOOL` for an unknown id. `default` is the effective default: the stored one if installed and enabled, else `claude` if installed and enabled, else the first installed enabled tool, else `null`. `PUT` answers `404 ENOTOOL` for an unknown id, `400 EBADDEFAULT` for a default that is not installed or disabled, `400 EBADFIELD` for a non-boolean `enabled`; disabling the default clears it. Writes are same-origin only. State lives in `<data dir>/ai-tools.json`. Each tool also carries `install` (`{kind:"npm", package}` or `{kind:"script", url}`, else null) and `update` (`{kind:"npm"}`, `{kind:"script"}`, `{kind:"self", args}`, else null), and the body carries `catalog: { source: "builtin"|"remote", tag, fetchedAt }` — `remote` while a copy of the newest release's catalog, newer than this build, is in use (synced daily, off with `AGENTHUB_AI_TOOLS_SYNC=0`).
|
||||||
|
|
||||||
**Install / update jobs.** Commands come only from built-in or release catalog files, never from the request or `DASHBOARD_AI_TOOLS_DIR`. npm is the login shell's (`npm install -g <package>@latest`); a script is downloaded by the server (https, same site as the tool's homepage, ≤ 1 MB) and run as `bash <file>`; `self` runs `<detected path> ...args`. One job server-wide; the job is `{ id, tool, action, status: "running"|"ok"|"failed", startedAt, endedAt, exitCode, log }` (last 256 KB, 10-minute timeout) and lives in memory. A successful install enables the tool. Errors: `404 ENOTOOL`; `400 ENOINSTALL`/`ENOUPDATE`/`ENOTHING`; `409 EINSTALLED`/`ENOTINSTALLED`/`ENOTNPM` (npm update of a tool whose binary is not under `npm prefix -g`)/`ENONPM`/`EBUSY`. Icons are the root `/favicon.ico`, raster only by magic bytes (never SVG), ≤ 256 KB, cached in `<data dir>/runtime/ai-tool-icons/`, served with `nosniff` and `Content-Security-Policy: sandbox`; an `<img>` passes the dashboard token as `?token=`. The client shows its own shipped mark instead where it has one (`client/public/ai-tools/<id>.svg` — claude, codex, openclaw, and hermes as a `-black`/`-white` pair swapped by the theme; `BUNDLED` in `client/src/components/AiToolIcon.tsx`), so this route is asked only for the other tools.
|
**Install / update jobs.** Commands come only from built-in or release catalog files, never from the request or `DASHBOARD_AI_TOOLS_DIR`. npm is the login shell's (`npm install -g <package>@latest`); a script is downloaded by the server (https, same site as the tool's homepage, ≤ 1 MB) and run as `bash <file>`; `self` runs `<detected path> ...args`. One job server-wide; the job is `{ id, tool, action, status: "running"|"ok"|"failed", startedAt, endedAt, exitCode, log }` (last 256 KB, 10-minute timeout) and lives in memory. A successful install enables the tool. Errors: `404 ENOTOOL`; `400 ENOINSTALL`/`ENOUPDATE`/`ENOTHING`; `409 EINSTALLED`/`ENOTINSTALLED`/`ENOTNPM` (npm update of a tool whose binary is not under `npm prefix -g`)/`ENONPM`/`EBUSY`. Icons are the root `/favicon.ico`, raster only by magic bytes (never SVG), ≤ 256 KB, cached in `<data dir>/runtime/ai-tool-icons/`, served with `nosniff` and `Content-Security-Policy: sandbox`; an `<img>` passes the dashboard token as `?token=`. The client shows its own shipped mark instead where it has one (`client/public/ai-tools/<id>.svg` — claude, codex, openclaw, and hermes as a `-black`/`-white` pair swapped by the theme; `BUNDLED` in `client/src/components/AiToolIcon.tsx`), so this route is asked only for the other tools.
|
||||||
|
|
||||||
### AI Router profiles
|
### AI Proxy profiles
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /api/ai-routers Every saved profile, the built-in `native` one first
|
GET /api/ai-proxies Every saved profile, the built-in `native` one first
|
||||||
POST /api/ai-routers Create — Body: { label, baseUrl, apiKey?, model, models?, behavesAs? }
|
POST /api/ai-proxies Create — Body: { label, baseUrl, apiKey?, model, models?, behavesAs? }
|
||||||
POST /api/ai-routers/test Probe an endpoint — Body: { baseUrl, model, apiKey?, name? }
|
POST /api/ai-proxies/test Probe an endpoint — Body: { baseUrl, model, apiKey?, name? }
|
||||||
PATCH /api/ai-routers/:name Update the same fields, all optional
|
PATCH /api/ai-proxies/:name Update the same fields, all optional
|
||||||
DELETE /api/ai-routers/:name Delete one saved profile
|
DELETE /api/ai-proxies/:name Delete one saved profile
|
||||||
```
|
```
|
||||||
|
|
||||||
A **router profile** is an endpoint that stands in front of Claude Code or Codex (9Router, OpenRouter, a self-hosted gateway) plus the model names to use against it. It powers the **AI Router** page of Settings (`/settings/ai-router`), which stores profiles and attaches nothing — the tab's **Test** button (`POST /api/ai-routers/test`, below) is its only outbound call. A profile is chosen per NEW SESSION, not per workspace: the **AI Router** select beside the model picker in the workspace home's prompt box and in a pane's run form (shown only for Claude/Codex and only when a profile beyond Native is saved; the last pick is remembered in `agenthub.session.router`).
|
`/api/ai-routers` (the name before the AI Router → AI Proxy rename) is the same router mounted a second time, kept for a client older than the rename.
|
||||||
|
|
||||||
`models` carries the five per-tier names (`opus`, `sonnet`, `haiku`, `smallFast`, `subagent`). Any tier omitted is filled from `model`, so a router that answers to one symbolic name needs one field, not six.
|
A **proxy profile** is an endpoint that stands in front of Claude Code or Codex (9Router, OpenRouter, a self-hosted gateway) plus the model names to use against it. It powers the **AI Proxy** page of Settings (`/settings/ai-proxy`; the old `/settings/ai-router` redirects there), which stores profiles and attaches nothing — the page's **Test** button (`POST /api/ai-proxies/test`, below) is its only outbound call. A profile is chosen per NEW SESSION, not per workspace: the **AI Proxy** select beside the model picker in the workspace home's prompt box (shown only for Claude/Codex and only when a profile beyond Native is saved; the last pick is remembered in `agenthub.session.proxy`, which falls back to the pre-rename `agenthub.session.router`).
|
||||||
|
|
||||||
`behavesAs` (optional, `""` clears it, omitted on `PATCH` keeps it) is a Claude model id (`claude-…`, else `400 EBADMODEL`) that Claude Code should treat the router's own model names as. Without it, a name Claude Code's model catalog does not know — a 9Router combo such as `agent` — makes every session print "isn't described by this version's model catalog" and compact at an assumed 200k window (issue #56). The user picks it, never AgentHub: it also sets the context window Claude Code compacts at, and only the user knows which model the router really sends a name to.
|
`baseUrl` is the proxy's ROOT, without `/v1` — e.g. `http://localhost:20128`. A trailing `/v1` is dropped on save, and on read of a profile an older build stored with one. Claude Code gets the root as `ANTHROPIC_BASE_URL` (it appends `/v1/messages`); the Codex layer gets root + `/v1` (Codex appends `/responses`).
|
||||||
|
|
||||||
**No response here ever contains key material.** A saved profile reports `hasKey: true|false` and there is deliberately no `GET /:name` — a route returning one profile in full would be the single way an API key could leave the server. Profiles live one JSON file each under `<CLAUDE_HOME>/agenthub-routers/` (dir `0700`, files `0600`). An omitted `apiKey` on `PATCH` keeps the stored one; the edit form cannot display a key, so it has none to send back.
|
`models` carries the five per-tier names (`opus`, `sonnet`, `haiku`, `smallFast`, `subagent`). Any tier omitted is filled from `model`, so a proxy that answers to one symbolic name needs one field, not six.
|
||||||
|
|
||||||
**`POST /api/ai-routers/test`** answers the question saving cannot: does this endpoint, with this key, actually serve this model? It sends one Anthropic-style message capped at a single output token to `<baseUrl>/v1/messages` (a 15 s timeout), which is the wire shape a claude workspace's eight env keys point at — a Codex-only router is not covered. A router that answers is a `200` here whatever it replies: `{ ok, status, message }` carries the router's own words (truncated to 300 characters), and `status` is `null` when nothing answered at all. Only bad input is a 4xx.
|
`behavesAs` (optional, `""` clears it, omitted on `PATCH` keeps it) is a Claude model id (`claude-…`, else `400 EBADMODEL`) that Claude Code should treat the proxy's own model names as. Without it, a name Claude Code's model catalog does not know — a 9Router combo such as `agent` — makes every session print "isn't described by this version's model catalog" and compact at an assumed 200k window (issue #56). The user picks it, never AgentHub: it also sets the context window Claude Code compacts at, and only the user knows which model the proxy really sends a name to.
|
||||||
|
|
||||||
The stored key is only ever sent to the endpoint it was stored against. A blank `apiKey` falls back to the key of the profile named by `name`, but **only** while the submitted `baseUrl` still equals the saved one — so testing a retyped endpoint needs a retyped key (`400 EBADKEY`). Without that rule this route would be a way to make the server POST a saved API key to any URL a browser names.
|
**No response here ever contains key material.** A saved profile reports `hasKey: true|false` and there is deliberately no `GET /:name` — a route returning one profile in full would be the single way an API key could leave the server. Profiles live one JSON file each under `<CLAUDE_HOME>/agenthub-proxies/` (dir `0700`, files `0600`); the first read after the rename moves an existing `agenthub-routers/` there and leaves a symlink at the old name, so an older build on the same CLAUDE_HOME still finds them. An omitted `apiKey` on `PATCH` keeps the stored one; the edit form cannot display a key, so it has none to send back.
|
||||||
|
|
||||||
`native` is a built-in profile, not the absence of one: empty `baseUrl`/`apiKey` and the vendor's own model names, so switching back is the same write path as switching to a router. It answers `409 EBUILTIN` to any update, delete, or attempt to shadow its name. Other errors: `400 EBADLABEL` / `EBADURL` (an endpoint must be `http:` or `https:`) / `EBADMODEL`, `404 ENOTFOUND`.
|
**`POST /api/ai-proxies/test`** answers the question saving cannot: does this endpoint, with this key, actually serve this model — for each CLI? It sends, in parallel and with a 15 s timeout each, one Anthropic-style message capped at a single output token to `<baseUrl>/v1/messages` (what Claude Code calls) and one OpenAI Responses call (`max_output_tokens: 16`) to `<baseUrl>/v1/responses` (what Codex calls). A proxy that answers is a `200` here whatever it replies: `{ claude: { ok, status, message }, codex: { ok, status, message } }`, each carrying the proxy's own words (truncated to 300 characters), `status` `null` when nothing answered at all. A proxy that serves only one wire shape shows one green line and one red. Only bad input is a 4xx.
|
||||||
|
|
||||||
**Starting a session on a router.** `POST /api/lanes/:id/start` and `POST /api/run` take `router: "<name>"` (omitted, `""` or `"native"` = the vendor's own API). Only the name comes from the request; the key and endpoint come from the store. An unknown name is `400 EBADROUTER`, a router on a tool other than Claude/Codex `400 EUNSUPPORTED`. A resume (`resumeSessionId`, `resume`) that names no router goes back to the one its session last ran on (`dashboard_runs.router`), or native once that profile is deleted. Each spawn (re)writes the profile's files:
|
The stored key is only ever sent to the endpoint it was stored against. A blank `apiKey` falls back to the key of the profile named by `name`, but **only** while the submitted `baseUrl` still equals the saved one (both compared without `/v1`) — so testing a retyped endpoint needs a retyped key (`400 EBADKEY`). Without that rule this route would be a way to make the server POST a saved API key to any URL a browser names.
|
||||||
|
|
||||||
- **Claude** gets `--settings=<data dir>/router-settings/<name>.json` (dir `0700`, file `0600`): an `env` block with the eight keys `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, `ANTHROPIC_DEFAULT_HAIKU_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `CLAUDE_CODE_SUBAGENT_MODEL` (an empty one left out), plus — only when the profile sets `behavesAs` — `modelPicker: { options: [{ model, behavesAs }] }` with one row per distinct model name that is not a bare `claude-*` id. Claude Code reads `modelPicker` from the policy, flag and user layers and takes the FIRST it finds, so while set it replaces a `modelPicker` in the user's own `~/.claude/settings.json` for that session (verified on 2.1.290). The command-line layer outranks the project-local and user settings, so two sessions of one workspace can run on two routers, and only the path is in argv. Native adds nothing, so a router the user set in their own `~/.claude/settings.json` still applies to a native session.
|
`native` is a built-in profile, not the absence of one: empty `baseUrl`/`apiKey`, and no model of its own (`model: ""`, `models: {}` — the session's model picker picks), so choosing it is the same operation as choosing a proxy. It answers `409 EBUILTIN` to any update, delete, or attempt to shadow its name. Other errors: `400 EBADLABEL` / `EBADURL` (an endpoint must be `http:` or `https:`) / `EBADMODEL`, `404 ENOTFOUND`.
|
||||||
- **Codex** gets `-p <name>` and `$CODEX_HOME/<name>.config.toml`, the layer that flag loads on top of the base config (never touched). The layer sets `wire_api = "responses"` (codex-cli 0.149 refuses `"chat"`); `env_key` is written only when the profile has a key, and the key reaches the pane as `AGENTHUB_ROUTER_KEY` through tmux's own `-e`, never as typed text.
|
|
||||||
|
|
||||||
`PATCH` rewrites the profile's files that exist (a live Claude session reads its file again; the next Codex session gets the new layer) and `DELETE` removes both. Workspaces no longer carry a router: `POST /api/lanes/:id/router/apply` is gone, and at every start the server takes the eight keys an older build wrote into a workspace's `.claude/settings.local.json` back out and clears `lanes.router_profile` (a file that is not valid JSON is left alone and retried next start).
|
**Starting a session on a proxy.** `POST /api/lanes/:id/start` and `POST /api/run` take `proxy: "<name>"` (omitted, `""` or `"native"` = the vendor's own API; the pre-rename `router` field is still accepted). Only the name comes from the request; the key and endpoint come from the store. An unknown name is `400 EBADPROXY`, a proxy on a tool other than Claude/Codex `400 EUNSUPPORTED`. A resume (`resumeSessionId`, `resume`) that names no proxy goes back to the one its session last ran on (`dashboard_runs.proxy`), or native once that profile is deleted. Each spawn (re)writes the profile's files:
|
||||||
|
|
||||||
|
- **Claude** gets `--settings=<data dir>/proxy-settings/<name>.json` (dir `0700`, file `0600`): an `env` block with the eight keys `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, `ANTHROPIC_DEFAULT_HAIKU_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `CLAUDE_CODE_SUBAGENT_MODEL` (an empty one left out), plus — only when the profile sets `behavesAs` — `modelPicker: { options: [{ model, behavesAs }] }` with one row per distinct model name that is not a bare `claude-*` id. Claude Code reads `modelPicker` from the policy, flag and user layers and takes the FIRST it finds, so while set it replaces a `modelPicker` in the user's own `~/.claude/settings.json` for that session (verified on 2.1.290). The command-line layer outranks the project-local and user settings, so two sessions of one workspace can run on two proxies, and only the path is in argv. Native adds nothing, so a proxy the user set in their own `~/.claude/settings.json` still applies to a native session.
|
||||||
|
- **Codex** gets `-p <name>` and `$CODEX_HOME/<name>.config.toml`, the layer that flag loads on top of the base config (never touched). The layer sets `base_url` to the root + `/v1` and `wire_api = "responses"` (codex-cli 0.149 refuses `"chat"`); `env_key` is written only when the profile has a key, and the key reaches the pane as `AGENTHUB_PROXY_KEY` through tmux's own `-e`, never as typed text.
|
||||||
|
|
||||||
|
`PATCH` rewrites the profile's files that exist (a live Claude session reads its file again; the next Codex session gets the new layer) and `DELETE` removes them, the pre-rename `<data dir>/router-settings/<name>.json` copy included. Workspaces no longer carry a proxy: `POST /api/lanes/:id/router/apply` is gone, and at every start the server takes the eight keys an older build wrote into a workspace's `.claude/settings.local.json` back out and clears the legacy `lanes.router_profile` column (a file that is not valid JSON is left alone and retried next start).
|
||||||
|
|
||||||
### Pets (the mascot's custom pets)
|
### Pets (the mascot's custom pets)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -398,7 +398,7 @@ CREATE TABLE notifications (
|
|||||||
|
|
||||||
Per-model pricing rules for cost calculation, keyed by `model_pattern` (a SQL-style glob; `%` matches any characters). Rates are per **million** tokens (USD). The longest matching pattern wins.
|
Per-model pricing rules for cost calculation, keyed by `model_pattern` (a SQL-style glob; `%` matches any characters). Rates are per **million** tokens (USD). The longest matching pattern wins.
|
||||||
|
|
||||||
**Automatic rates.** `server/lib/pricing-sync.js` runs at server start and every 15 minutes, and fetches LiteLLM's public price list (`model_prices_and_context_window.json` on GitHub) at most once a day. Each pass (1) INSERTS a row, `source = 'sync'`, for every Claude, OpenAI or Gemini model seen in `token_usage` whose family (the id minus a `-YYYYMMDD` / `-YYYY-MM-DD` suffix; OpenAI means `gpt-…`, `o<n>…` or `codex-…`; Gemini means `gemini-…`, taken whole) has no `<family>%` row of its own, AND for every **current** model of the three vendors before anyone uses it — a list key that is a family itself, `mode` `chat`/`responses`, not past its `deprecation_date`, not an audio/search/TTS/realtime/image/embedding variant, and of the current generation (any `claude-<name>-…`; `gpt-` 5 and up; `gemini-` 3 and up — `MIN_GEN`); (2) UPDATES the rates of its own `sync` rows when the list changes; (3) adopts a row from before the `source` column (`NULL`: a seed, an old sync insert or an old edit) as `sync` only while its rates still equal the list exactly. A row edited through `PUT /api/pricing` becomes `source = 'user'` and is never touched again; a deleted row's pattern goes to `model_pricing_dismissed` and is never added back. An entry is taken only from its own vendor (`litellm_provider` `anthropic` / `openai`, and `vertex_ai-language-models` or `gemini` for Gemini); an OpenAI or Gemini row carries no cache-write charge unless the list names one, and no fast rate. Gemini's >200k-token rate is not modelled (the base rate is used). A router's model id (`clinepass/…`) is never priced automatically. `POST /api/settings/reset-pricing` empties `model_pricing_dismissed` for its scope and runs a sync right after reseeding (waiting up to 3 s), so the OpenAI and Gemini rows a reset wiped come straight back; with `{scope: "claude"}` (the Claude Code tab's **Reset Defaults**) it deletes and reseeds only `claude-…` rows, so other vendors' rows and a custom router row survive. A rate outside 0–1000 $/MTok is rejected, and a failed fetch changes nothing. Set `AGENTHUB_PRICING_SYNC=0` (or `false`/`off`) to turn it off.
|
**Automatic rates.** `server/lib/pricing-sync.js` runs at server start and every 15 minutes, and fetches LiteLLM's public price list (`model_prices_and_context_window.json` on GitHub) at most once a day. Each pass (1) INSERTS a row, `source = 'sync'`, for every Claude, OpenAI or Gemini model seen in `token_usage` whose family (the id minus a `-YYYYMMDD` / `-YYYY-MM-DD` suffix; OpenAI means `gpt-…`, `o<n>…` or `codex-…`; Gemini means `gemini-…`, taken whole) has no `<family>%` row of its own, AND for every **current** model of the three vendors before anyone uses it — a list key that is a family itself, `mode` `chat`/`responses`, not past its `deprecation_date`, not an audio/search/TTS/realtime/image/embedding variant, and of the current generation (any `claude-<name>-…`; `gpt-` 5 and up; `gemini-` 3 and up — `MIN_GEN`); (2) UPDATES the rates of its own `sync` rows when the list changes; (3) adopts a row from before the `source` column (`NULL`: a seed, an old sync insert or an old edit) as `sync` only while its rates still equal the list exactly. A row edited through `PUT /api/pricing` becomes `source = 'user'` and is never touched again; a deleted row's pattern goes to `model_pricing_dismissed` and is never added back. An entry is taken only from its own vendor (`litellm_provider` `anthropic` / `openai`, and `vertex_ai-language-models` or `gemini` for Gemini); an OpenAI or Gemini row carries no cache-write charge unless the list names one, and no fast rate. Gemini's >200k-token rate is not modelled (the base rate is used). A proxy's model id (`clinepass/…`) is never priced automatically. `POST /api/settings/reset-pricing` empties `model_pricing_dismissed` for its scope and runs a sync right after reseeding (waiting up to 3 s), so the OpenAI and Gemini rows a reset wiped come straight back; with `{scope: "claude"}` (the Claude Code tab's **Reset Defaults**) it deletes and reseeds only `claude-…` rows, so other vendors' rows and a custom proxy row survive. A rate outside 0–1000 $/MTok is rejected, and a failed fetch changes nothing. Set `AGENTHUB_PRICING_SYNC=0` (or `false`/`off`) to turn it off.
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE TABLE model_pricing (
|
CREATE TABLE model_pricing (
|
||||||
|
|||||||
+8
-8
@@ -34,7 +34,7 @@ agenthub workspace add --repo /path/to/repo --title "My Feature" --base main --s
|
|||||||
|
|
||||||
Adopting your main repo (`agenthub workspace add --cwd $(pwd)`) is also how you get stage detection working for sessions that work directly in it rather than in a worktree (once a pipeline template is installed) — see "Stage detection" below. Once adopted, that workspace's own card lists every managed-worktree workspace whose `source_repo` matches its `cwd`, each a clickable link to jump to that workspace.
|
Adopting your main repo (`agenthub workspace add --cwd $(pwd)`) is also how you get stage detection working for sessions that work directly in it rather than in a worktree (once a pipeline template is installed) — see "Stage detection" below. Once adopted, that workspace's own card lists every managed-worktree workspace whose `source_repo` matches its `cwd`, each a clickable link to jump to that workspace.
|
||||||
|
|
||||||
Adding a workspace through the dashboard's "+ Add workspace" flow also auto-runs, best-effort, in parallel: `agenthub workspace profile init` (only if a Node.js project is detected — most repos won't be, and that's a normal outcome, not a failure), `agenthub workspace mcp sync`, and the AI Router pick. None of the three blocks the workspace from being created or from each other — a workspace whose repo has no MCP servers configured, for instance, still gets created and is still usable, just without a synced `.mcp.json`. The modal shows a ✓/✗ summary of the three results and stays open until dismissed (Cancel/X) — it does not auto-close. Run any of the three manually later (from the workspace's own card, or the CLI) if the automatic attempt didn't apply.
|
Adding a workspace through the dashboard's "+ Add workspace" flow also auto-runs, best-effort, in parallel: `agenthub workspace profile init` (only if a Node.js project is detected — most repos won't be, and that's a normal outcome, not a failure), `agenthub workspace mcp sync`, and the AI Proxy pick. None of the three blocks the workspace from being created or from each other — a workspace whose repo has no MCP servers configured, for instance, still gets created and is still usable, just without a synced `.mcp.json`. The modal shows a ✓/✗ summary of the three results and stays open until dismissed (Cancel/X) — it does not auto-close. Run any of the three manually later (from the workspace's own card, or the CLI) if the automatic attempt didn't apply.
|
||||||
|
|
||||||
### The "+ Add workspace" modal
|
### The "+ Add workspace" modal
|
||||||
|
|
||||||
@@ -87,13 +87,13 @@ terminal with `agenthub workspace add --pipeline <id>`. *Model endpoint* is the
|
|||||||
per-workspace extra the form still asks for, and it sits in the form itself rather
|
per-workspace extra the form still asks for, and it sits in the form itself rather
|
||||||
than behind a disclosure — a disclosure around a single field only hid it.
|
than behind a disclosure — a disclosure around a single field only hid it.
|
||||||
|
|
||||||
A workspace has no AI Router of its own: each NEW session picks one. The **AI Router** select in the workspace home's prompt box lists the profiles saved in Settings › AI Router (9Router, OpenRouter, a self-hosted gateway) plus **Native** (the vendor's own API); it shows Native alone, disabled, when the session's AI tool cannot use a router (only Claude and Codex can), and it remembers the last pick (`agenthub.session.router`). The start sends `router: "<name>"`; a Claude pane then runs with `--settings=<data dir>/router-settings/<name>.json`, which outranks the workspace's own settings, and a Codex pane with `-p <name>` plus its key through tmux `-e`. So two sessions of one workspace can run on two routers, a worktree session gets its router too, and switching never touches a running session. A resume goes back to the router its session last ran on (`dashboard_runs.router`). Editing a profile in Settings rewrites those files (a rotated key reaches live Claude sessions); deleting it removes them. Older builds wrote a workspace's router into its `.claude/settings.local.json`; every server start takes those eight keys back out. See `docs/API.md` § AI Router profiles.
|
A workspace has no AI Proxy of its own: each NEW session picks one. The **AI Proxy** select in the workspace home's prompt box lists the profiles saved in Settings › AI Proxy (9Router, OpenRouter, a self-hosted gateway) plus **Native** (the vendor's own API); it shows Native alone, disabled, when the session's AI tool cannot use a proxy (only Claude and Codex can), and it remembers the last pick (`agenthub.session.proxy`, falling back to the pre-rename `agenthub.session.router`). The start sends `proxy: "<name>"` (`router` is still accepted); a Claude pane then runs with `--settings=<data dir>/proxy-settings/<name>.json`, which outranks the workspace's own settings, and a Codex pane with `-p <name>` plus its key through tmux `-e`. So two sessions of one workspace can run on two proxies, a worktree session gets its proxy too, and switching never touches a running session. A resume goes back to the proxy its session last ran on (`dashboard_runs.proxy`). Editing a profile in Settings rewrites those files (a rotated key reaches live Claude sessions); deleting it removes them. Older builds wrote a workspace's proxy into its `.claude/settings.local.json`; every server start takes those eight keys back out. See `docs/API.md` § AI Proxy profiles.
|
||||||
|
|
||||||
Both modes' path field has a **Browse** button next to it that opens the operating system's own folder picker (`POST /api/lanes/pick-path`). A browser cannot hand a web page an absolute path from its own dialog, so the SERVER opens the dialog on its desktop (zenity/kdialog on Linux, `osascript` on macOS, PowerShell on Windows) — which is your screen only when the browser runs on the same machine. A browser on another machine, or a server with no desktop or no zenity/kdialog, gets the small in-app folder browser instead (`GET /api/lanes/browse?path=<abs>`): click a subfolder to descend, ".." to go up, "Select this folder" to fill the path field; git repos are marked. The same button sits beside the workspace home's **Folder** field, Import history's folder, the project-memory folder, CLAUDE_HOME/CODEX_HOME and a remote source's SSH identity file (a file picker, no in-app fallback).
|
Both modes' path field has a **Browse** button next to it that opens the operating system's own folder picker (`POST /api/lanes/pick-path`). A browser cannot hand a web page an absolute path from its own dialog, so the SERVER opens the dialog on its desktop (zenity/kdialog on Linux, `osascript` on macOS, PowerShell on Windows) — which is your screen only when the browser runs on the same machine. A browser on another machine, or a server with no desktop or no zenity/kdialog, gets the small in-app folder browser instead (`GET /api/lanes/browse?path=<abs>`): click a subfolder to descend, ".." to go up, "Select this folder" to fill the path field; git repos are marked. The same button sits beside the workspace home's **Folder** field, Import history's folder, the project-memory folder, CLAUDE_HOME/CODEX_HOME and a remote source's SSH identity file (a file picker, no in-app fallback).
|
||||||
|
|
||||||
## Workspace tool: Claude or Codex
|
## Workspace tool: Claude or Codex
|
||||||
|
|
||||||
A workspace has a default tool: **Claude** (default) or **Codex** (or any AI Tools id), chosen in the "+ Add workspace" modal and immutable once the workspace exists. Each NEW session picks its own in the workspace home's prompt box — the **AI** select at the left of the box (beside it, on the right, ONE model chip holding the model list and Effort; AI Router, Permission and Local / New worktree sit in a row under the box, like Claude Desktop), lists every installed, enabled tool and starts on the workspace's — and `POST /api/lanes/:id/start` takes it as `tool`; a resume keeps the tool its session ran on (`sessions.tool`). The sidebar draws each session's tool icon at the right of its row.
|
A workspace has a default tool: **Claude** (default) or **Codex** (or any AI Tools id), chosen in the "+ Add workspace" modal and immutable once the workspace exists. Each NEW session picks its own in the workspace home's prompt box — the **AI** select at the left of the box (beside it, on the right, ONE model chip holding the model list and Effort; AI Proxy, Permission and Local / New worktree sit in a row under the box, like Claude Desktop), lists every installed, enabled tool and starts on the workspace's — and `POST /api/lanes/:id/start` takes it as `tool`; a resume keeps the tool its session ran on (`sessions.tool`). The sidebar draws each session's tool icon at the right of its row.
|
||||||
|
|
||||||
- **`claude` (default)** — `claude` binary is invoked; the session's hooks, messages, and tool calls flow through the dashboard. Claude workspaces render Sessions, Analytics, cost, and stage-detection badges; liveness is tracked by the Claude Code session.
|
- **`claude` (default)** — `claude` binary is invoked; the session's hooks, messages, and tool calls flow through the dashboard. Claude workspaces render Sessions, Analytics, cost, and stage-detection badges; liveness is tracked by the Claude Code session.
|
||||||
- **`codex` (experimental)** — `codex` binary is invoked instead. The home's prompt box shows Sandbox and Approval in place of Claude's permission mode. Codex workspaces carry **no pipeline**: no stage chip, no time-on-stage, no pipeline or feature picker and no PipelineMap, and nothing infers or advances their stage. They also get no Sessions/Analytics/cost. (The console pre-fills nothing into the prompt box, for either tool.)
|
- **`codex` (experimental)** — `codex` binary is invoked instead. The home's prompt box shows Sandbox and Approval in place of Claude's permission mode. Codex workspaces carry **no pipeline**: no stage chip, no time-on-stage, no pipeline or feature picker and no PipelineMap, and nothing infers or advances their stage. They also get no Sessions/Analytics/cost. (The console pre-fills nothing into the prompt box, for either tool.)
|
||||||
@@ -392,9 +392,9 @@ The dashboard web UI merges workspaces and runs into a single **Workspace** page
|
|||||||
- **No page header.** The page title and the four counters (`lanes`, `running`, `needs you`, `dead`) are gone: the sidebar's workspace list shows every workspace with its own status dot, and the status bar at the bottom of every page counts running and need-you workspaces. `GET /api/lanes` still returns `counts` for other callers.
|
- **No page header.** The page title and the four counters (`lanes`, `running`, `needs you`, `dead`) are gone: the sidebar's workspace list shows every workspace with its own status dot, and the status bar at the bottom of every page counts running and need-you workspaces. `GET /api/lanes` still returns `counts` for other callers.
|
||||||
- **The sidebar's workspace list picks the workspace** — on every page, below the nav (`client/src/components/WorkspaceTree.tsx`), shaped like Claude Desktop's project list: every workspace on ONE level (no repo grouping, no worktree rows), in your stored order. A row (`WorkspaceRow.tsx`) is a chevron, a status dot (green = active, pulsing while running; amber row = needs you; red = dead or error; grey = idle), the title or folder name and the tool's icon, with `+` (open it for a new session) and `⋯` on hover. Clicking the name opens `/workspace?lane=<id>` and expands the row: the URL is the selection, so a notification link, F5 and the back button all land on the same workspace, and a bare `/workspace` reopens the last one shown (`agenthub.workspace.selected`) or the first. The header has a **tool filter** (shown once workspaces of two tools exist; kept in `agenthub.sidebar.projects` → `tool`, a stored tool no workspace has filters nothing), search and `+` (Add workspace). Search matches workspace names and paths — such a workspace keeps all its sessions — and session names: a workspace whose name does not match still shows when one of its sessions does, with only the matching sessions under it (every list stays mounted, hidden, to load and count). In the collapsed rail the list is a single icon that opens the sidebar. Drag the expanded sidebar's right edge (or focus it and press ←/→) to widen it from 240 px up to 480 px — never more than 40% of the window; the width is kept in `agenthub.sidebar.width` and a double-click on the edge resets it (`client/src/components/Layout.tsx`).
|
- **The sidebar's workspace list picks the workspace** — on every page, below the nav (`client/src/components/WorkspaceTree.tsx`), shaped like Claude Desktop's project list: every workspace on ONE level (no repo grouping, no worktree rows), in your stored order. A row (`WorkspaceRow.tsx`) is a chevron, a status dot (green = active, pulsing while running; amber row = needs you; red = dead or error; grey = idle), the title or folder name and the tool's icon, with `+` (open it for a new session) and `⋯` on hover. Clicking the name opens `/workspace?lane=<id>` and expands the row: the URL is the selection, so a notification link, F5 and the back button all land on the same workspace, and a bare `/workspace` reopens the last one shown (`agenthub.workspace.selected`) or the first. The header has a **tool filter** (shown once workspaces of two tools exist; kept in `agenthub.sidebar.projects` → `tool`, a stored tool no workspace has filters nothing), search and `+` (Add workspace). Search matches workspace names and paths — such a workspace keeps all its sessions — and session names: a workspace whose name does not match still shows when one of its sessions does, with only the matching sessions under it (every list stays mounted, hidden, to load and count). In the collapsed rail the list is a single icon that opens the sidebar. Drag the expanded sidebar's right edge (or focus it and press ←/→) to widen it from 240 px up to 480 px — never more than 40% of the window; the width is kept in `agenthub.sidebar.width` and a double-click on the edge resets it (`client/src/components/Layout.tsx`).
|
||||||
- **A workspace's sessions** — the chevron expands the row to the sessions the workspace OWNS and has not archived (`WorkspaceSessionList.tsx`: `GET /api/sessions?lane=<id>&archived=0`, the newest 20, re-read when the workspace's status or session moves; which rows are expanded is kept in `agenthub.sidebar.projects` → `expanded`). A live AI tab whose session has no row yet — a tab opened bare from `+`, before its first prompt — is listed at the top as **New session** straight from the run (its session id is known from the start), so a new tab shows in its workspace at once; the row becomes the real session when its first hook lands. The sidebar's live panes follow the Workspace page's own run-list poll (`LIVE_RUNS_EVENT`), since a session in slots 1–9 moves no workspace column. A workspace owns a session its pane held — `bindRunSession` stamps `sessions.lane_id` from the pane's run, first writer wins, a nested `claude -p` excluded — and lists nothing else: a `claude` typed into a terminal in the same folder, or history from before the workspace existed, belongs to no workspace and is found on the Sessions page. Sessions older than this ownership column are not guessed: only those `dashboard_runs` still named were backfilled. A session started by a tool with no hooks (Codex without its hooks, any catalog tool) is never stamped. A row's dot is the session's state (`sessionDot`: amber waiting for you, red failed, green a live pane, hollow at rest); double-click renames it (`PATCH {name}`), and its hover button **archives** it (`PATCH {archived: true}` — hidden from the workspace, nothing deleted; the home lists the archived ones folded with **Unarchive**). A filled dot marks a session that still has a live pane (a `GET /api/run` handle carrying its session id — every pane the dashboard starts carries `AGENTHUB_RUN_ID=<its tmux name>`, the hook handler forwards it as `run_id`, and `bindRunSession` in `server/routes/hooks.js` records the hook's session on that run: the first session always, afterwards — for a Claude pane — only a `SessionStart` that is not a fresh `startup` (`/clear`, `/resume`), so a `claude -p` the agent runs inside the pane never takes it over; the rule is Claude-only, and a Codex or Antigravity pane follows every new session): clicking it opens `/workspace?lane=<id>&run=<runId>`, which ATTACHES to that tmux session (`attachRequest` on `LaneConsolePane`) — it starts nothing and resumes nothing. The session opens on its own tab (the tab of its tmux slot): in the pane already showing it (focused), else in the focused pane; **Ctrl/Cmd+click** opens it in a split beside the focused pane (see **Split panes** below). Any other session opens in its workspace the way Claude Desktop opens an old one: `/workspace?lane=<id>&session=<id>` shows its conversation, read-only (`SessionReplay.tsx`, the console hidden — never unmounted — behind it), with a prompt box under it. Opening it resumes nothing; **sending** a message resumes it (`POST /api/lanes/:id/start {slot, resumeSessionId, prompt}` in the first free pane slot, on the tool the session ran on) and opens that pane — a resume re-reads the whole transcript into context, so it waits for that explicit send. The session's analysis page (`/sessions/<id>`: agents, events, cost, transcript) is the **Analyst** button in the session view's right panel, beside Files and Changes (disabled while the pane's run has reported no session yet).
|
- **A workspace's sessions** — the chevron expands the row to the sessions the workspace OWNS and has not archived (`WorkspaceSessionList.tsx`: `GET /api/sessions?lane=<id>&archived=0`, the newest 20, re-read when the workspace's status or session moves; which rows are expanded is kept in `agenthub.sidebar.projects` → `expanded`). A live AI tab whose session has no row yet — a tab opened bare from `+`, before its first prompt — is listed at the top as **New session** straight from the run (its session id is known from the start), so a new tab shows in its workspace at once; the row becomes the real session when its first hook lands. The sidebar's live panes follow the Workspace page's own run-list poll (`LIVE_RUNS_EVENT`), since a session in slots 1–9 moves no workspace column. A workspace owns a session its pane held — `bindRunSession` stamps `sessions.lane_id` from the pane's run, first writer wins, a nested `claude -p` excluded — and lists nothing else: a `claude` typed into a terminal in the same folder, or history from before the workspace existed, belongs to no workspace and is found on the Sessions page. Sessions older than this ownership column are not guessed: only those `dashboard_runs` still named were backfilled. A session started by a tool with no hooks (Codex without its hooks, any catalog tool) is never stamped. A row's dot is the session's state (`sessionDot`: amber waiting for you, red failed, green a live pane, hollow at rest); double-click renames it (`PATCH {name}`), and its hover button **archives** it (`PATCH {archived: true}` — hidden from the workspace, nothing deleted; the home lists the archived ones folded with **Unarchive**). A filled dot marks a session that still has a live pane (a `GET /api/run` handle carrying its session id — every pane the dashboard starts carries `AGENTHUB_RUN_ID=<its tmux name>`, the hook handler forwards it as `run_id`, and `bindRunSession` in `server/routes/hooks.js` records the hook's session on that run: the first session always, afterwards — for a Claude pane — only a `SessionStart` that is not a fresh `startup` (`/clear`, `/resume`), so a `claude -p` the agent runs inside the pane never takes it over; the rule is Claude-only, and a Codex or Antigravity pane follows every new session): clicking it opens `/workspace?lane=<id>&run=<runId>`, which ATTACHES to that tmux session (`attachRequest` on `LaneConsolePane`) — it starts nothing and resumes nothing. The session opens on its own tab (the tab of its tmux slot): in the pane already showing it (focused), else in the focused pane; **Ctrl/Cmd+click** opens it in a split beside the focused pane (see **Split panes** below). Any other session opens in its workspace the way Claude Desktop opens an old one: `/workspace?lane=<id>&session=<id>` shows its conversation, read-only (`SessionReplay.tsx`, the console hidden — never unmounted — behind it), with a prompt box under it. Opening it resumes nothing; **sending** a message resumes it (`POST /api/lanes/:id/start {slot, resumeSessionId, prompt}` in the first free pane slot, on the tool the session ran on) and opens that pane — a resume re-reads the whole transcript into context, so it waits for that explicit send. The session's analysis page (`/sessions/<id>`: agents, events, cost, transcript) is the **Analyst** button in the session view's right panel, beside Files and Changes (disabled while the pane's run has reported no session yet).
|
||||||
- **Row menu** — right-click a row, or its `⋯` button: Rename, the Model (AI Router) select, New workspace here…, Copy path, Reset worktree… (managed only) and Delete workspace…, each the call the rest of the UI already makes. Delete is record-only exactly as before (see "Removing a workspace").
|
- **Row menu** — right-click a row, or its `⋯` button: Rename, the Model (AI Proxy) select, New workspace here…, Copy path, Reset worktree… (managed only) and Delete workspace…, each the call the rest of the UI already makes. Delete is record-only exactly as before (see "Removing a workspace").
|
||||||
- **Right-click menus** — every item that has actions opens them on a right-click too, in one shell (`client/src/components/ContextMenu.tsx`: placed at the pointer, kept inside the viewport, closed by Escape, a click outside or an item). The buttons stay; the menu repeats what they do and nothing more. A session row — in the sidebar, the home's Recents and the archived fold (`lanes/SessionMenu.tsx`) — offers Open, Open in split (live), Rename and Archive (sidebar), Unarchive (archived), Session details, Copy session ID and Close tab (live); the sidebar's **New session** row Open, Open in split and Close tab. A console tab offers Open, Open in split, Session details, Copy session ID and Close tab; a pane header (split view) Show <session> here, Split right/down with a new terminal and Close pane. In the Changes panel a changed file (overview row or review header) offers Open, Stage/Unstage, Discard — two clicks, as on its trash button — or the conflict's Keep mine / Take theirs / Mark resolved, and Copy relative path; a Files tree entry Open and Copy relative path; a History commit View commit, Copy commit SHA and Copy commit message. The home's title opens the row menu, and a Context file or extra folder offers Copy path and Remove. Outside the workspace the same shell serves the Sessions table, the Dashboard's agent cards and recent events, the Activity feed and Workflow runs (`components/SessionRowMenu.tsx`: Open session details, Open in its workspace when it has one, Copy session ID, Copy folder path), an attached prompt image (View / Remove), the managed workspace card (Reset), every Settings list (Git credentials, AI Router, Pricing, alert rules, webhooks, remote sources, AI tools) and every Claude Code / Codex config list (skills, agents, commands, output styles and memory files via `FileItemMenu` in `components/config/MdItemList.tsx`; plugins, MCP servers, marketplaces, auth profiles). A delete or remove there takes the button's own confirm — a modal, or two clicks. Text areas (the terminal, a conversation, a diff) keep the browser's own menu. The shell stops the menu's clicks from reaching the card it was opened from.
|
- **Right-click menus** — every item that has actions opens them on a right-click too, in one shell (`client/src/components/ContextMenu.tsx`: placed at the pointer, kept inside the viewport, closed by Escape, a click outside or an item). The buttons stay; the menu repeats what they do and nothing more. A session row — in the sidebar, the home's Recents and the archived fold (`lanes/SessionMenu.tsx`) — offers Open, Open in split (live), Rename and Archive (sidebar), Unarchive (archived), Session details, Copy session ID and Close tab (live); the sidebar's **New session** row Open, Open in split and Close tab. A console tab offers Open, Open in split, Session details, Copy session ID and Close tab; a pane header (split view) Show <session> here, Split right/down with a new terminal and Close pane. In the Changes panel a changed file (overview row or review header) offers Open, Stage/Unstage, Discard — two clicks, as on its trash button — or the conflict's Keep mine / Take theirs / Mark resolved, and Copy relative path; a Files tree entry Open and Copy relative path; a History commit View commit, Copy commit SHA and Copy commit message. The home's title opens the row menu, and a Context file or extra folder offers Copy path and Remove. Outside the workspace the same shell serves the Sessions table, the Dashboard's agent cards and recent events, the Activity feed and Workflow runs (`components/SessionRowMenu.tsx`: Open session details, Open in its workspace when it has one, Copy session ID, Copy folder path), an attached prompt image (View / Remove), the managed workspace card (Reset), every Settings list (Git credentials, AI Proxy, Pricing, alert rules, webhooks, remote sources, AI tools) and every Claude Code / Codex config list (skills, agents, commands, output styles and memory files via `FileItemMenu` in `components/config/MdItemList.tsx`; plugins, MCP servers, marketplaces, auth profiles). A delete or remove there takes the button's own confirm — a modal, or two clicks. Text areas (the terminal, a conversation, a diff) keep the browser's own menu. The shell stops the menu's clicks from reaching the card it was opened from.
|
||||||
- **The workspace home** — `/workspace?lane=<id>` opens on the workspace's home (`client/src/components/lanes/WorkspaceHome.tsx`), shaped like a Claude Desktop project page: the name (rename from its `⋯` menu, which is the sidebar row menu) with the repository it belongs to under it (root, the main worktree's branch, the worktree count, from `GET /api/projects` — the facts Settings → Projects used to show), a prompt box — the one place a session starts WITH a prompt (a pane has no run form; the session view's tab bar `+` only starts a bare CLI or a shell) — whose fields run in order **AI** (the session's own tool: every installed, enabled one, the workspace's by default), **AI Router** (Native or a saved profile; Native alone, disabled, for a tool that cannot use one), **Model** (the tool's own list, or on a router that router's models), **Permission** (Claude's permission mode; Codex's sandbox and approval; a catalog tool's per-session bypass switch) and **Effort** (Claude and Codex), whose half-typed text is kept per workspace in `agenthub.workspace.draft.<lane id>` (localStorage, so it survives leaving the page or switching workspace; cleared once sent — attached images are not kept), and whose **Start** opens a NEW session in the first slot with no live run (0–9, `POST /api/lanes/:id/start {slot}`; all ten busy is refused) and then shows it in a tab of its own, and **Recents** — the workspace's own sessions (`?lane=<id>`) with their cost and time, a live one opening its pane and any other its conversation (`&session=`), exactly like the sidebar. The transcript page (`/sessions/<id>`) of a Claude session started in a workspace's own folder carries an explicit **Resume** (`client/src/components/lanes/SessionResumeButton.tsx`, the one place that resumes) and beside it **Fork**, which resumes the history into a NEW session id (`{forkSession: true}` → `--fork-session`, the original untouched, offered even while the original has a live pane): it warns that a resume re-reads the whole transcript into context, re-reads the live panes, then starts it in the workspace's first free pane (`POST /api/lanes/:id/start {slot, resumeSessionId}`) and opens it. A session from a SUBFOLDER gets no Resume — the pane starts in the workspace's folder and Claude finds a session by the folder it ran in — and a session that still has a live pane shows **Open pane** instead. On the right sit the workspace's own context: **Instructions** (appended to every new session's system prompt), **Memory** (read-only: the folder's CLAUDE.md / AGENTS.md family and Claude's auto memory), **Context** (uploaded files, ≤ 10 MB each, stored by AgentHub) and **Folder** (the workspace's own folder plus extra ones, with the repo's untracked worktrees suggested). Only Claude and Codex panes can take them (`--add-dir`, `--append-system-prompt-file=` / `-c developer_instructions=`); for any other tool the page says so and its panes start without them. See `docs/API.md` → Workspace context. The home HIDES the session view with CSS — the console never unmounts behind it.
|
- **The workspace home** — `/workspace?lane=<id>` opens on the workspace's home (`client/src/components/lanes/WorkspaceHome.tsx`), shaped like a Claude Desktop project page: the name (rename from its `⋯` menu, which is the sidebar row menu) with the repository it belongs to under it (root, the main worktree's branch, the worktree count, from `GET /api/projects` — the facts Settings → Projects used to show), a prompt box — the one place a session starts WITH a prompt (a pane has no run form; the session view's tab bar `+` only starts a bare CLI or a shell) — whose fields run in order **AI** (the session's own tool: every installed, enabled one, the workspace's by default), **AI Proxy** (Native or a saved profile; Native alone, disabled, for a tool that cannot use one), **Model** (the tool's own list, or on a router that router's models), **Permission** (Claude's permission mode; Codex's sandbox and approval; a catalog tool's per-session bypass switch) and **Effort** (Claude and Codex), whose half-typed text is kept per workspace in `agenthub.workspace.draft.<lane id>` (localStorage, so it survives leaving the page or switching workspace; cleared once sent — attached images are not kept), and whose **Start** opens a NEW session in the first slot with no live run (0–9, `POST /api/lanes/:id/start {slot}`; all ten busy is refused) and then shows it in a tab of its own, and **Recents** — the workspace's own sessions (`?lane=<id>`) with their cost and time, a live one opening its pane and any other its conversation (`&session=`), exactly like the sidebar. The transcript page (`/sessions/<id>`) of a Claude session started in a workspace's own folder carries an explicit **Resume** (`client/src/components/lanes/SessionResumeButton.tsx`, the one place that resumes) and beside it **Fork**, which resumes the history into a NEW session id (`{forkSession: true}` → `--fork-session`, the original untouched, offered even while the original has a live pane): it warns that a resume re-reads the whole transcript into context, re-reads the live panes, then starts it in the workspace's first free pane (`POST /api/lanes/:id/start {slot, resumeSessionId}`) and opens it. A session from a SUBFOLDER gets no Resume — the pane starts in the workspace's folder and Claude finds a session by the folder it ran in — and a session that still has a live pane shows **Open pane** instead. On the right sit the workspace's own context: **Instructions** (appended to every new session's system prompt), **Memory** (read-only: the folder's CLAUDE.md / AGENTS.md family and Claude's auto memory), **Context** (uploaded files, ≤ 10 MB each, stored by AgentHub) and **Folder** (the workspace's own folder plus extra ones, with the repo's untracked worktrees suggested). Only Claude and Codex panes can take them (`--add-dir`, `--append-system-prompt-file=` / `-c developer_instructions=`); for any other tool the page says so and its panes start without them. See `docs/API.md` → Workspace context. The home HIDES the session view with CSS — the console never unmounts behind it.
|
||||||
- **A session in its own worktree** — Claude Desktop's worktree toggle: the row under the home's prompt box has a **Local / New worktree** select (Claude workspaces in a git repo). Claude Code itself makes a worktree only when started with `--worktree` or when asked mid-session — and a worktree it enters mid-session is invisible to AgentHub, which reads a session's folder once, at its start, and the new session's pane runs `claude --worktree=<name>`, so CLAUDE CODE makes `<repo>/.claude/worktrees/<name>` on branch `worktree-<name>`, copies `.worktreeinclude`, runs your `WorktreeCreate` hook, and on exit removes a worktree with no changes (or asks). AgentHub adds `/.claude/worktrees/` to the repo's `info/exclude` (Claude does not, and the main repo would list it). The session stays this workspace's own — listed with a branch icon and the worktree's name — and the session view's Changes panel works in ITS worktree (every git route with `?session=`, resolved from the session row: owned by the workspace, registered by `git worktree list`; `ENOWORKTREE` otherwise, never the main folder). Resume and Fork start in that worktree. **Archive** of a worktree session with no live pane asks first and removes the worktree like Claude Desktop — `git worktree remove` without force (git refuses unsaved work, and then nothing is archived) and `git branch -d` (unmerged work keeps its branch); a lock held by a live process refuses, a stale one is cleared. The workspace's own folder is never removed.
|
- **A session in its own worktree** — Claude Desktop's worktree toggle: the row under the home's prompt box has a **Local / New worktree** select (Claude workspaces in a git repo). Claude Code itself makes a worktree only when started with `--worktree` or when asked mid-session — and a worktree it enters mid-session is invisible to AgentHub, which reads a session's folder once, at its start, and the new session's pane runs `claude --worktree=<name>`, so CLAUDE CODE makes `<repo>/.claude/worktrees/<name>` on branch `worktree-<name>`, copies `.worktreeinclude`, runs your `WorktreeCreate` hook, and on exit removes a worktree with no changes (or asks). AgentHub adds `/.claude/worktrees/` to the repo's `info/exclude` (Claude does not, and the main repo would list it). The session stays this workspace's own — listed with a branch icon and the worktree's name — and the session view's Changes panel works in ITS worktree (every git route with `?session=`, resolved from the session row: owned by the workspace, registered by `git worktree list`; `ENOWORKTREE` otherwise, never the main folder). Resume and Fork start in that worktree. **Archive** of a worktree session with no live pane asks first and removes the worktree like Claude Desktop — `git worktree remove` without force (git refuses unsaved work, and then nothing is archived) and `git branch -d` (unmerged work keeps its branch); a lock held by a live process refuses, a stale one is cleared. The workspace's own folder is never removed.
|
||||||
- **The session view** — `/workspace?lane=<id>&run=<runId>` (a live session picked in the sidebar or Recents, or one just started) shows the console's tabs and the Changes panel, with that run's tab active. The sidebar's workspace row always opens the workspace's home, live tabs or not (a live tab opens from its session row); the **Home** button at the left end of the tab bar opens the home of a workspace whose tabs stay running. Every workspace keeps its own tabs, panes and sessions: another workspace's tab is never shown. A browser notification for a workspace with a live pane opens it here directly.
|
- **The session view** — `/workspace?lane=<id>&run=<runId>` (a live session picked in the sidebar or Recents, or one just started) shows the console's tabs and the Changes panel, with that run's tab active. The sidebar's workspace row always opens the workspace's home, live tabs or not (a live tab opens from its session row); the **Home** button at the left end of the tab bar opens the home of a workspace whose tabs stay running. Every workspace keeps its own tabs, panes and sessions: another workspace's tab is never shown. A browser notification for a workspace with a live pane opens it here directly.
|
||||||
- **Untracked worktrees** are no longer listed in the sidebar; Settings → Git → **Worktrees** (`showUntracked`, hidden sources `claude` = `<repo>/.claude/worktrees/*` and `agenthub` = `LANES_ROOT`) now decides which of them the home's Add folder suggests.
|
- **Untracked worktrees** are no longer listed in the sidebar; Settings → Git → **Worktrees** (`showUntracked`, hidden sources `claude` = `<repo>/.claude/worktrees/*` and `agenthub` = `LANES_ROOT`) now decides which of them the home's Add folder suggests.
|
||||||
@@ -1157,7 +1157,7 @@ disables) it lists `$CODEX_HOME/sessions/<YYYY>/<MM>/<DD>/rollout-*.jsonl`
|
|||||||
(`$CODEX_HOME` defaults to `~/.codex`; every codex pane is started with the
|
(`$CODEX_HOME` defaults to `~/.codex`; every codex pane is started with the
|
||||||
dashboard's own `CODEX_HOME` through tmux `-e`, so the pane writes where this
|
dashboard's own `CODEX_HOME` through tmux `-e`, so the pane writes where this
|
||||||
reads; it is editable in Settings → Codex → Overview, and a save re-writes each
|
reads; it is editable in Settings → Codex → Overview, and a save re-writes each
|
||||||
codex workspace's AI Router layer into the new home — a pane already running keeps
|
codex workspace's AI Proxy layer into the new home — a pane already running keeps
|
||||||
the old one until it restarts), reads each thread's `cwd` from the
|
the old one until it restarts), reads each thread's `cwd` from the
|
||||||
`session_meta` on the file's first line, and calls the owning workspace `running`
|
`session_meta` on the file's first line, and calls the owning workspace `running`
|
||||||
while that journal's mtime is under two minutes old — and `idle` once it is
|
while that journal's mtime is under two minutes old — and `idle` once it is
|
||||||
@@ -1749,4 +1749,4 @@ A catalog file:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`detect` lists bare command names (the first found on PATH is what runs), `requires` other commands that must be present, `args` fixed leading args, `prompt.mode` one of `argv` (optional `separator: "--"`), `flag` (`flag`), or `after-start` (`delayMs`, default 3000 — the prompt is pasted into the pane once the CLI is up). `model.flag` (optional `vendor`: `claude`/`openai`/`google`, whose priced models the picker offers) puts the run form's model before the prompt; `config` (built-in and release files only, like `install`) lists the `~/`-relative files the tool's Settings tab may edit — `format` `json`/`jsonc`/`text`, guessed from the extension, and never a credential-looking name; `resume` enables **Resume last session**; `bypass`/`bypassEnv` enable the run form's per-run **Bypass permissions** switch, which is off every time the form opens. Every non-Claude workspace has no pipeline, no agents install and no AI Router (native only, except Codex); running/idle comes from the exited-pane heal sweep.
|
`detect` lists bare command names (the first found on PATH is what runs), `requires` other commands that must be present, `args` fixed leading args, `prompt.mode` one of `argv` (optional `separator: "--"`), `flag` (`flag`), or `after-start` (`delayMs`, default 3000 — the prompt is pasted into the pane once the CLI is up). `model.flag` (optional `vendor`: `claude`/`openai`/`google`, whose priced models the picker offers) puts the run form's model before the prompt; `config` (built-in and release files only, like `install`) lists the `~/`-relative files the tool's Settings tab may edit — `format` `json`/`jsonc`/`text`, guessed from the extension, and never a credential-looking name; `resume` enables **Resume last session**; `bypass`/`bypassEnv` enable the run form's per-run **Bypass permissions** switch, which is off every time the form opens. Every non-Claude workspace has no pipeline, no agents install and no AI Proxy (native only, except Codex); running/idle comes from the exited-pane heal sweep.
|
||||||
|
|||||||
+6
-30
@@ -3,7 +3,7 @@
|
|||||||
openapi: 3.0.3
|
openapi: 3.0.3
|
||||||
info:
|
info:
|
||||||
title: Agent Dashboard for Claude Code API
|
title: Agent Dashboard for Claude Code API
|
||||||
version: 2.5.4
|
version: 2.5.5
|
||||||
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
||||||
contact:
|
contact:
|
||||||
name: Nguyễn Ngọc Trí Vĩ
|
name: Nguyễn Ngọc Trí Vĩ
|
||||||
@@ -4177,9 +4177,9 @@ components:
|
|||||||
bypass:
|
bypass:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Offers the per-run Bypass permissions switch
|
description: Offers the per-run Bypass permissions switch
|
||||||
routerCapable:
|
proxyCapable:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Can use an AI Router profile
|
description: Can use an AI Proxy profile
|
||||||
install:
|
install:
|
||||||
type: object
|
type: object
|
||||||
nullable: true
|
nullable: true
|
||||||
@@ -4564,14 +4564,6 @@ components:
|
|||||||
env_owned:
|
env_owned:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: 'True when the real environment, or the checkout''s own `.env`, set this variable at startup: either wins over the settings file, so a saved value is overridden again at the next start.'
|
description: 'True when the real environment, or the checkout''s own `.env`, set this variable at startup: either wins over the settings file, so a saved value is overridden again at the next start.'
|
||||||
routers:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
missing:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
description: '`<lane id> (<title>): <profile>` for every lane naming an AI Router profile the new home''s store (`<CLAUDE_HOME>/agenthub-routers/`) does not have — such a lane starts on its base configuration.'
|
|
||||||
WorkflowToolRun:
|
WorkflowToolRun:
|
||||||
type: object
|
type: object
|
||||||
description: 'A Claude Code Workflow-tool run (issue #167): a fleet of sub-agents spawned by the ''Workflow'' tool (or self-paced /loop). These emit no hooks; the source of truth is the on-disk run journal, ingested into the `workflows` table (see server/lib/workflow-ingest.js). Keyed by `run_id` and parented to the launching session. The JSON-blob columns `phases` and `progress` are parsed into arrays before serialization.'
|
description: 'A Claude Code Workflow-tool run (issue #167): a fleet of sub-agents spawned by the ''Workflow'' tool (or self-paced /loop). These emit no hooks; the source of truth is the on-disk run journal, ingested into the `workflows` table (see server/lib/workflow-ingest.js). Keyed by `run_id` and parented to the launching session. The JSON-blob columns `phases` and `progress` are parsed into arrays before serialization.'
|
||||||
@@ -11521,7 +11513,7 @@ paths:
|
|||||||
'400':
|
'400':
|
||||||
description: EBADPATH (incl. a symlink resolving outside the lane), EBADREV or ENOFILE.
|
description: EBADPATH (incl. a symlink resolving outside the lane), EBADREV or ENOFILE.
|
||||||
'403':
|
'403':
|
||||||
description: ESECRET — `.claude/settings.local.json` (the AI Router key) is refused at every rev, at any depth, and under any path that resolves to it (a symlinked `.claude`).
|
description: ESECRET — `.claude/settings.local.json` (where an older build kept the AI Proxy key) is refused at every rev, at any depth, and under any path that resolves to it (a symlinked `.claude`).
|
||||||
'404':
|
'404':
|
||||||
description: Lane not found.
|
description: Lane not found.
|
||||||
/api/lanes/{id}/git/raw:
|
/api/lanes/{id}/git/raw:
|
||||||
@@ -13028,7 +13020,7 @@ paths:
|
|||||||
tags:
|
tags:
|
||||||
- Settings
|
- Settings
|
||||||
summary: Get the active Codex home directory
|
summary: Get the active Codex home directory
|
||||||
description: Returns the Codex home directory (CODEX_HOME, else `<homedir>/.codex`) the dashboard reads Codex config and rollout journals from, writes AI Router layers into, and hands every codex lane pane through tmux `-e`.
|
description: Returns the Codex home directory (CODEX_HOME, else `<homedir>/.codex`) the dashboard reads Codex config and rollout journals from, writes AI Proxy layers into at each spawn, and hands every codex lane pane through tmux `-e`.
|
||||||
operationId: getCodexHome
|
operationId: getCodexHome
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
@@ -13052,7 +13044,7 @@ paths:
|
|||||||
tags:
|
tags:
|
||||||
- Settings
|
- Settings
|
||||||
summary: Update the Codex home directory
|
summary: Update the Codex home directory
|
||||||
description: Same validation and persistence as PUT /api/settings/claude-home (403 EBADORIGIN from another site, settings file, EBADHOME for a path that is or contains the OS home, INVALID_PATH otherwise). On success it re-writes the AI Router layer (`<name>.config.toml`) of every codex lane on a router into the new home, reported as `routers`, because `codex -p <name>` loads it from there. A codex pane already running keeps its old home until restarted.
|
description: 'Same validation and persistence as PUT /api/settings/claude-home (403 EBADORIGIN from another site, settings file, EBADHOME for a path that is or contains the OS home, INVALID_PATH otherwise). Nothing is re-written here: a session''s AI Proxy layer (`<name>.config.toml`) is written into the current home at its spawn. A codex pane already running keeps its old home until restarted.'
|
||||||
operationId: updateCodexHome
|
operationId: updateCodexHome
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
@@ -13078,7 +13070,6 @@ paths:
|
|||||||
- ok
|
- ok
|
||||||
- codex_home
|
- codex_home
|
||||||
- env_owned
|
- env_owned
|
||||||
- routers
|
|
||||||
properties:
|
properties:
|
||||||
ok:
|
ok:
|
||||||
type: boolean
|
type: boolean
|
||||||
@@ -13088,25 +13079,10 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
env_owned:
|
env_owned:
|
||||||
type: boolean
|
type: boolean
|
||||||
routers:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- updated
|
|
||||||
- failed
|
|
||||||
properties:
|
|
||||||
updated:
|
|
||||||
type: integer
|
|
||||||
failed:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
example:
|
example:
|
||||||
ok: true
|
ok: true
|
||||||
codex_home: /Users/son/.codex-work
|
codex_home: /Users/son/.codex-work
|
||||||
env_owned: false
|
env_owned: false
|
||||||
routers:
|
|
||||||
updated: 1
|
|
||||||
failed: []
|
|
||||||
'400':
|
'400':
|
||||||
description: Invalid path (INVALID_PATH) or too broad (EBADHOME).
|
description: Invalid path (INVALID_PATH) or too broad (EBADHOME).
|
||||||
content:
|
content:
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.5.4",
|
"version": "2.5.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.5.4",
|
"version": "2.5.5",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.5.4",
|
"version": "2.5.5",
|
||||||
"bin": {
|
"bin": {
|
||||||
"agenthub": "bin/agenthub.js"
|
"agenthub": "bin/agenthub.js"
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -701,7 +701,7 @@ Because sync runs non-interactively (`ssh -o BatchMode=yes`), the connection mus
|
|||||||
| `GET` | `/api/settings/claude-home` | `{claude_home, env_owned}` — `env_owned` when the real environment or the checkout's `.env` set `CLAUDE_HOME` (a saved value is overridden at the next start) |
|
| `GET` | `/api/settings/claude-home` | `{claude_home, env_owned}` — `env_owned` when the real environment or the checkout's `.env` set `CLAUDE_HOME` (a saved value is overridden at the next start) |
|
||||||
| `PUT` | `/api/settings/claude-home` | `{path}` → validate, apply, persist to AgentHub's settings file. 400 `EBADHOME` (is or contains the OS home) / `INVALID_PATH`, 500 `ESAVE`, 403 `EBADORIGIN` (`sameOriginGuard`) |
|
| `PUT` | `/api/settings/claude-home` | `{path}` → validate, apply, persist to AgentHub's settings file. 400 `EBADHOME` (is or contains the OS home) / `INVALID_PATH`, 500 `ESAVE`, 403 `EBADORIGIN` (`sameOriginGuard`) |
|
||||||
| `GET` | `/api/settings/codex-home` | `{codex_home, env_owned}` |
|
| `GET` | `/api/settings/codex-home` | `{codex_home, env_owned}` |
|
||||||
| `PUT` | `/api/settings/codex-home` | Same checks as `claude-home`; a session's AI Router layer is written into the current home at its spawn, so nothing is re-written here |
|
| `PUT` | `/api/settings/codex-home` | Same checks as `claude-home`; a session's AI Proxy layer is written into the current home at its spawn, so nothing is re-written here |
|
||||||
| `GET` | `/api/settings/codex-hooks` | Status of AgentHub's hooks in `$CODEX_HOME/hooks.json` (installed, per-event, Codex's trust as read from `config.toml`) |
|
| `GET` | `/api/settings/codex-hooks` | Status of AgentHub's hooks in `$CODEX_HOME/hooks.json` (installed, per-event, Codex's trust as read from `config.toml`) |
|
||||||
| `POST` | `/api/settings/codex-hooks` | Install or reinstall them (`sameOriginGuard`). 400 `EBADJSON` / `EUNSUPPORTED` / `EFOREIGN` (the forwarder path holds a file that is not AgentHub's) |
|
| `POST` | `/api/settings/codex-hooks` | Install or reinstall them (`sameOriginGuard`). 400 `EBADJSON` / `EUNSUPPORTED` / `EFOREIGN` (the forwarder path holds a file that is not AgentHub's) |
|
||||||
| `DELETE` | `/api/settings/codex-hooks` | Remove AgentHub's hooks only (`sameOriginGuard`); a foreign forwarder is left alone |
|
| `DELETE` | `/api/settings/codex-hooks` | Remove AgentHub's hooks only (`sameOriginGuard`); a foreign forwarder is left alone |
|
||||||
|
|||||||
@@ -0,0 +1,330 @@
|
|||||||
|
/**
|
||||||
|
* @file Tests for the AI Proxy profile store — the CRUD surface, the built-in
|
||||||
|
* native profile, and the guarantee that a stored API key never appears in a
|
||||||
|
* response the browser can read.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
|
||||||
|
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-ai-proxies-"));
|
||||||
|
process.env.CLAUDE_HOME = path.join(SUITE_ROOT, ".claude");
|
||||||
|
|
||||||
|
const { describe, it, beforeEach, after } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
|
||||||
|
const proxies = require("../lib/ai-proxies");
|
||||||
|
|
||||||
|
const PROXIES_DIR = path.join(process.env.CLAUDE_HOME, "agenthub-proxies");
|
||||||
|
|
||||||
|
after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
beforeEach(() => fs.rmSync(PROXIES_DIR, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
/** The smallest profile the API accepts. */
|
||||||
|
function nineRouter(over = {}) {
|
||||||
|
return {
|
||||||
|
label: "9Router",
|
||||||
|
baseUrl: "http://localhost:20128/v1",
|
||||||
|
apiKey: "sk-secret-value",
|
||||||
|
model: "Highest",
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ai-proxies — the built-in native profile", () => {
|
||||||
|
it("is listed even before anything has been saved", () => {
|
||||||
|
const { items } = proxies.listProxies();
|
||||||
|
assert.equal(items.length, 1);
|
||||||
|
assert.equal(items[0].name, "native");
|
||||||
|
assert.equal(items[0].builtin, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the two connection variables empty rather than absent", () => {
|
||||||
|
// The reference native settings.json sets both to "" — an absent key would
|
||||||
|
// let a user-level proxy base URL leak into a lane meant to run native.
|
||||||
|
const native = proxies.getProxy("native");
|
||||||
|
assert.equal(native.baseUrl, "");
|
||||||
|
assert.equal(native.apiKey, "");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries no model ids of its own — the session's model picker picks", () => {
|
||||||
|
const native = proxies.getProxy("native");
|
||||||
|
assert.equal(native.model, "");
|
||||||
|
assert.deepEqual(native.models, {});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to be edited or removed", () => {
|
||||||
|
assert.throws(() => proxies.updateProxy("native", { label: "mine" }), { code: "EBUILTIN" });
|
||||||
|
assert.throws(() => proxies.removeProxy("native"), { code: "EBUILTIN" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ai-proxies — create", () => {
|
||||||
|
it("saves a profile and derives its name from the label", () => {
|
||||||
|
const created = proxies.createProxy(nineRouter());
|
||||||
|
assert.equal(created.name, "9Router".toLowerCase());
|
||||||
|
assert.equal(created.label, "9Router");
|
||||||
|
assert.ok(fs.existsSync(path.join(PROXIES_DIR, `${created.name}.json`)));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores the file 0600 inside a 0700 directory", () => {
|
||||||
|
const created = proxies.createProxy(nineRouter());
|
||||||
|
const file = fs.statSync(path.join(PROXIES_DIR, `${created.name}.json`));
|
||||||
|
const dir = fs.statSync(PROXIES_DIR);
|
||||||
|
assert.equal(file.mode & 0o777, 0o600);
|
||||||
|
assert.equal(dir.mode & 0o777, 0o700);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fills every model slot from `model` when no per-tier value is given", () => {
|
||||||
|
// The 9Router reference sets all six slots to one symbolic value; typing it
|
||||||
|
// once must be enough.
|
||||||
|
proxies.createProxy(nineRouter());
|
||||||
|
const saved = proxies.getProxy("9router");
|
||||||
|
assert.equal(saved.models.opus, "Highest");
|
||||||
|
assert.equal(saved.models.sonnet, "Highest");
|
||||||
|
assert.equal(saved.models.haiku, "Highest");
|
||||||
|
assert.equal(saved.models.smallFast, "Highest");
|
||||||
|
assert.equal(saved.models.subagent, "Highest");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a per-tier value that was given explicitly", () => {
|
||||||
|
proxies.createProxy(nineRouter({ models: { opus: "PremiumCombo" } }));
|
||||||
|
const saved = proxies.getProxy("9router");
|
||||||
|
assert.equal(saved.models.opus, "PremiumCombo");
|
||||||
|
assert.equal(saved.models.sonnet, "Highest");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives a second profile with the same label a free name", () => {
|
||||||
|
const a = proxies.createProxy(nineRouter());
|
||||||
|
const b = proxies.createProxy(nineRouter());
|
||||||
|
assert.notEqual(a.name, b.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a label that is empty or carries control characters", () => {
|
||||||
|
assert.throws(() => proxies.createProxy(nineRouter({ label: " " })), { code: "EBADLABEL" });
|
||||||
|
assert.throws(() => proxies.createProxy(nineRouter({ label: "ab" })), {
|
||||||
|
code: "EBADLABEL",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a base URL that is not http(s)", () => {
|
||||||
|
// A `file:` or `javascript:` base URL is never a proxy; it is either a
|
||||||
|
// mistake or an attempt to point the CLI somewhere it should not go.
|
||||||
|
assert.throws(() => proxies.createProxy(nineRouter({ baseUrl: "file:///etc/passwd" })), {
|
||||||
|
code: "EBADURL",
|
||||||
|
});
|
||||||
|
assert.throws(() => proxies.createProxy(nineRouter({ baseUrl: "not a url" })), {
|
||||||
|
code: "EBADURL",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to shadow the built-in native name", () => {
|
||||||
|
assert.throws(() => proxies.createProxy(nineRouter({ label: "native" })), {
|
||||||
|
code: "EBUILTIN",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ai-proxies — the key never reaches the browser", () => {
|
||||||
|
it("is absent from the list, which reports only that one is set", () => {
|
||||||
|
proxies.createProxy(nineRouter());
|
||||||
|
const { items } = proxies.listProxies();
|
||||||
|
const saved = items.find((i) => i.name === "9router");
|
||||||
|
assert.equal(saved.hasKey, true);
|
||||||
|
assert.equal(saved.apiKey, undefined);
|
||||||
|
assert.ok(!JSON.stringify(items).includes("sk-secret-value"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is still absent when the profile has no key at all", () => {
|
||||||
|
proxies.createProxy(nineRouter({ apiKey: "" }));
|
||||||
|
const { items } = proxies.listProxies();
|
||||||
|
assert.equal(items.find((i) => i.name === "9router").hasKey, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ai-proxies — update", () => {
|
||||||
|
it("changes the label without changing the name a lane already references", () => {
|
||||||
|
// A lane stores the name. Renaming the label must not orphan that lane.
|
||||||
|
const created = proxies.createProxy(nineRouter());
|
||||||
|
const updated = proxies.updateProxy(created.name, { label: "9Router (nhà)" });
|
||||||
|
assert.equal(updated.name, created.name);
|
||||||
|
assert.equal(updated.label, "9Router (nhà)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the stored key when the update omits it", () => {
|
||||||
|
// The edit form cannot show the key, so it cannot send it back either.
|
||||||
|
const created = proxies.createProxy(nineRouter());
|
||||||
|
proxies.updateProxy(created.name, { label: "renamed" });
|
||||||
|
assert.equal(proxies.getProxy(created.name).apiKey, "sk-secret-value");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces the key when a new one is given", () => {
|
||||||
|
const created = proxies.createProxy(nineRouter());
|
||||||
|
proxies.updateProxy(created.name, { apiKey: "sk-rotated" });
|
||||||
|
assert.equal(proxies.getProxy(created.name).apiKey, "sk-rotated");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a name that does not exist", () => {
|
||||||
|
assert.throws(() => proxies.updateProxy("ghost", { label: "x" }), { code: "ENOTFOUND" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets, keeps and clears behavesAs, and refuses an id that is not a Claude model's", () => {
|
||||||
|
const created = proxies.createProxy(nineRouter({ behavesAs: " claude-opus-5-5 " }));
|
||||||
|
assert.equal(created.behavesAs, "claude-opus-5-5");
|
||||||
|
assert.equal(
|
||||||
|
proxies.updateProxy(created.name, { label: "renamed" }).behavesAs,
|
||||||
|
"claude-opus-5-5"
|
||||||
|
);
|
||||||
|
assert.equal(proxies.updateProxy(created.name, { behavesAs: "" }).behavesAs, "");
|
||||||
|
for (const bad of ["agent", "claude opus", "-claude-x", 5]) {
|
||||||
|
assert.throws(() => proxies.updateProxy(created.name, { behavesAs: bad }), {
|
||||||
|
code: "EBADMODEL",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ai-proxies — remove", () => {
|
||||||
|
it("deletes the file", () => {
|
||||||
|
const created = proxies.createProxy(nineRouter());
|
||||||
|
proxies.removeProxy(created.name);
|
||||||
|
assert.equal(fs.existsSync(path.join(PROXIES_DIR, `${created.name}.json`)), false);
|
||||||
|
assert.equal(proxies.listProxies().items.length, 1); // native only
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a name that does not exist", () => {
|
||||||
|
assert.throws(() => proxies.removeProxy("ghost"), { code: "ENOTFOUND" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ai-proxies — a corrupt file is skipped, never fatal", () => {
|
||||||
|
it("lists the healthy profiles around it", () => {
|
||||||
|
proxies.createProxy(nineRouter());
|
||||||
|
fs.writeFileSync(path.join(PROXIES_DIR, "broken.json"), "{not json");
|
||||||
|
const { items } = proxies.listProxies();
|
||||||
|
assert.ok(items.some((i) => i.name === "9router"));
|
||||||
|
assert.ok(!items.some((i) => i.name === "broken"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ai-proxies — base URL is the root", () => {
|
||||||
|
it("drops a trailing /v1 on save", () => {
|
||||||
|
const created = proxies.createProxy(nineRouter());
|
||||||
|
assert.equal(created.baseUrl, "http://localhost:20128");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a /v1 an older build stored as the root", () => {
|
||||||
|
fs.mkdirSync(PROXIES_DIR, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(PROXIES_DIR, "old.json"),
|
||||||
|
JSON.stringify({ ...nineRouter({ baseUrl: "http://localhost:20128/v1/" }), models: {} })
|
||||||
|
);
|
||||||
|
assert.equal(proxies.getProxy("old").baseUrl, "http://localhost:20128");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ai-proxies — the pre-rename store", () => {
|
||||||
|
const LEGACY = path.join(process.env.CLAUDE_HOME, "agenthub-routers");
|
||||||
|
after(() => fs.rmSync(LEGACY, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
it("moves agenthub-routers/ here once and leaves a symlink behind", () => {
|
||||||
|
fs.mkdirSync(LEGACY, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(LEGACY, "home.json"), JSON.stringify(nineRouter({ models: {} })));
|
||||||
|
assert.ok(proxies.listProxies().items.some((i) => i.name === "home"));
|
||||||
|
assert.ok(fs.existsSync(path.join(PROXIES_DIR, "home.json")));
|
||||||
|
assert.ok(fs.lstatSync(LEGACY).isSymbolicLink());
|
||||||
|
// An older build reading the old path still finds the profile.
|
||||||
|
assert.ok(fs.existsSync(path.join(LEGACY, "home.json")));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ai-proxies — testProxy", () => {
|
||||||
|
const realFetch = global.fetch;
|
||||||
|
after(() => {
|
||||||
|
global.fetch = realFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Capture the one request testProxy makes, and answer it however asked. */
|
||||||
|
function stubFetch(answer) {
|
||||||
|
const calls = [];
|
||||||
|
global.fetch = async (url, init) => {
|
||||||
|
calls.push({ url, init });
|
||||||
|
if (answer instanceof Error) throw answer;
|
||||||
|
return answer;
|
||||||
|
};
|
||||||
|
return calls;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = () => ({ ok: true, status: 200, statusText: "OK", text: async () => "{}" });
|
||||||
|
|
||||||
|
it("probes Claude's /v1/messages and Codex's /v1/responses on the root", async () => {
|
||||||
|
const calls = stubFetch(ok());
|
||||||
|
const result = await proxies.testProxy(nineRouter());
|
||||||
|
assert.deepEqual(result.claude, { ok: true, status: 200, message: "Highest" });
|
||||||
|
assert.deepEqual(result.codex, { ok: true, status: 200, message: "Highest" });
|
||||||
|
const byUrl = Object.fromEntries(calls.map((c) => [c.url, c.init]));
|
||||||
|
// The typed `/v1` is dropped: one `/v1`, never two.
|
||||||
|
const claude = byUrl["http://localhost:20128/v1/messages"];
|
||||||
|
const codex = byUrl["http://localhost:20128/v1/responses"];
|
||||||
|
assert.equal(JSON.parse(claude.body).max_tokens, 1);
|
||||||
|
assert.equal(JSON.parse(claude.body).model, "Highest");
|
||||||
|
assert.equal(claude.headers["x-api-key"], "sk-secret-value");
|
||||||
|
assert.equal(JSON.parse(codex.body).model, "Highest");
|
||||||
|
assert.equal(codex.headers.authorization, "Bearer sk-secret-value");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a proxy that answered with an error rather than throwing", async () => {
|
||||||
|
stubFetch({ ok: false, status: 401, statusText: "Unauthorized", text: async () => "bad key" });
|
||||||
|
const result = await proxies.testProxy(nineRouter());
|
||||||
|
assert.deepEqual(result.claude, { ok: false, status: 401, message: "bad key" });
|
||||||
|
assert.deepEqual(result.codex, { ok: false, status: 401, message: "bad key" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports an endpoint that never answered", async () => {
|
||||||
|
// The shape node's own fetch throws: a useless outer message wrapping the
|
||||||
|
// one thing the user can act on.
|
||||||
|
stubFetch(
|
||||||
|
Object.assign(new Error("fetch failed"), {
|
||||||
|
cause: new Error("connect ECONNREFUSED 127.0.0.1:20199"),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const { claude } = await proxies.testProxy(nineRouter());
|
||||||
|
assert.equal(claude.ok, false);
|
||||||
|
assert.equal(claude.status, null);
|
||||||
|
assert.match(claude.message, /ECONNREFUSED/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the stored key for the endpoint it was stored against", async () => {
|
||||||
|
const created = proxies.createProxy(nineRouter());
|
||||||
|
const calls = stubFetch(ok());
|
||||||
|
await proxies.testProxy({
|
||||||
|
name: created.name,
|
||||||
|
baseUrl: "http://localhost:20128/v1",
|
||||||
|
model: "Highest",
|
||||||
|
});
|
||||||
|
assert.equal(calls[0].init.headers["x-api-key"], "sk-secret-value");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never sends the stored key to a different endpoint", async () => {
|
||||||
|
const created = proxies.createProxy(nineRouter());
|
||||||
|
const calls = stubFetch(ok());
|
||||||
|
await assert.rejects(
|
||||||
|
proxies.testProxy({
|
||||||
|
name: created.name,
|
||||||
|
baseUrl: "https://attacker.example/v1",
|
||||||
|
model: "Highest",
|
||||||
|
}),
|
||||||
|
{ code: "EBADKEY" }
|
||||||
|
);
|
||||||
|
assert.equal(calls.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a base URL that is not http(s)", async () => {
|
||||||
|
await assert.rejects(proxies.testProxy(nineRouter({ baseUrl: "file:///etc/passwd" })), {
|
||||||
|
code: "EBADURL",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,288 +0,0 @@
|
|||||||
/**
|
|
||||||
* @file Tests for the AI Router profile store — the CRUD surface, the built-in
|
|
||||||
* native profile, and the guarantee that a stored API key never appears in a
|
|
||||||
* response the browser can read.
|
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
||||||
*/
|
|
||||||
|
|
||||||
const os = require("node:os");
|
|
||||||
const path = require("node:path");
|
|
||||||
const fs = require("node:fs");
|
|
||||||
|
|
||||||
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-ai-routers-"));
|
|
||||||
process.env.CLAUDE_HOME = path.join(SUITE_ROOT, ".claude");
|
|
||||||
|
|
||||||
const { describe, it, beforeEach, after } = require("node:test");
|
|
||||||
const assert = require("node:assert/strict");
|
|
||||||
|
|
||||||
const routers = require("../lib/ai-routers");
|
|
||||||
|
|
||||||
const ROUTERS_DIR = path.join(process.env.CLAUDE_HOME, "agenthub-routers");
|
|
||||||
|
|
||||||
after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true }));
|
|
||||||
|
|
||||||
beforeEach(() => fs.rmSync(ROUTERS_DIR, { recursive: true, force: true }));
|
|
||||||
|
|
||||||
/** The smallest profile the API accepts. */
|
|
||||||
function nineRouter(over = {}) {
|
|
||||||
return {
|
|
||||||
label: "9Router",
|
|
||||||
baseUrl: "http://localhost:20128/v1",
|
|
||||||
apiKey: "sk-secret-value",
|
|
||||||
model: "Highest",
|
|
||||||
...over,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("ai-routers — the built-in native profile", () => {
|
|
||||||
it("is listed even before anything has been saved", () => {
|
|
||||||
const { items } = routers.listRouters();
|
|
||||||
assert.equal(items.length, 1);
|
|
||||||
assert.equal(items[0].name, "native");
|
|
||||||
assert.equal(items[0].builtin, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves the two connection variables empty rather than absent", () => {
|
|
||||||
// The reference native settings.json sets both to "" — an absent key would
|
|
||||||
// let a user-level router base URL leak into a lane meant to run native.
|
|
||||||
const native = routers.getRouter("native");
|
|
||||||
assert.equal(native.baseUrl, "");
|
|
||||||
assert.equal(native.apiKey, "");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("refuses to be edited or removed", () => {
|
|
||||||
assert.throws(() => routers.updateRouter("native", { label: "mine" }), { code: "EBUILTIN" });
|
|
||||||
assert.throws(() => routers.removeRouter("native"), { code: "EBUILTIN" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("ai-routers — create", () => {
|
|
||||||
it("saves a profile and derives its name from the label", () => {
|
|
||||||
const created = routers.createRouter(nineRouter());
|
|
||||||
assert.equal(created.name, "9Router".toLowerCase());
|
|
||||||
assert.equal(created.label, "9Router");
|
|
||||||
assert.ok(fs.existsSync(path.join(ROUTERS_DIR, `${created.name}.json`)));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stores the file 0600 inside a 0700 directory", () => {
|
|
||||||
const created = routers.createRouter(nineRouter());
|
|
||||||
const file = fs.statSync(path.join(ROUTERS_DIR, `${created.name}.json`));
|
|
||||||
const dir = fs.statSync(ROUTERS_DIR);
|
|
||||||
assert.equal(file.mode & 0o777, 0o600);
|
|
||||||
assert.equal(dir.mode & 0o777, 0o700);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("fills every model slot from `model` when no per-tier value is given", () => {
|
|
||||||
// The 9Router reference sets all six slots to one symbolic value; typing it
|
|
||||||
// once must be enough.
|
|
||||||
routers.createRouter(nineRouter());
|
|
||||||
const saved = routers.getRouter("9router");
|
|
||||||
assert.equal(saved.models.opus, "Highest");
|
|
||||||
assert.equal(saved.models.sonnet, "Highest");
|
|
||||||
assert.equal(saved.models.haiku, "Highest");
|
|
||||||
assert.equal(saved.models.smallFast, "Highest");
|
|
||||||
assert.equal(saved.models.subagent, "Highest");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps a per-tier value that was given explicitly", () => {
|
|
||||||
routers.createRouter(nineRouter({ models: { opus: "PremiumCombo" } }));
|
|
||||||
const saved = routers.getRouter("9router");
|
|
||||||
assert.equal(saved.models.opus, "PremiumCombo");
|
|
||||||
assert.equal(saved.models.sonnet, "Highest");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("gives a second profile with the same label a free name", () => {
|
|
||||||
const a = routers.createRouter(nineRouter());
|
|
||||||
const b = routers.createRouter(nineRouter());
|
|
||||||
assert.notEqual(a.name, b.name);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a label that is empty or carries control characters", () => {
|
|
||||||
assert.throws(() => routers.createRouter(nineRouter({ label: " " })), { code: "EBADLABEL" });
|
|
||||||
assert.throws(() => routers.createRouter(nineRouter({ label: "ab" })), {
|
|
||||||
code: "EBADLABEL",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a base URL that is not http(s)", () => {
|
|
||||||
// A `file:` or `javascript:` base URL is never a router; it is either a
|
|
||||||
// mistake or an attempt to point the CLI somewhere it should not go.
|
|
||||||
assert.throws(() => routers.createRouter(nineRouter({ baseUrl: "file:///etc/passwd" })), {
|
|
||||||
code: "EBADURL",
|
|
||||||
});
|
|
||||||
assert.throws(() => routers.createRouter(nineRouter({ baseUrl: "not a url" })), {
|
|
||||||
code: "EBADURL",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("refuses to shadow the built-in native name", () => {
|
|
||||||
assert.throws(() => routers.createRouter(nineRouter({ label: "native" })), {
|
|
||||||
code: "EBUILTIN",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("ai-routers — the key never reaches the browser", () => {
|
|
||||||
it("is absent from the list, which reports only that one is set", () => {
|
|
||||||
routers.createRouter(nineRouter());
|
|
||||||
const { items } = routers.listRouters();
|
|
||||||
const saved = items.find((i) => i.name === "9router");
|
|
||||||
assert.equal(saved.hasKey, true);
|
|
||||||
assert.equal(saved.apiKey, undefined);
|
|
||||||
assert.ok(!JSON.stringify(items).includes("sk-secret-value"));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is still absent when the profile has no key at all", () => {
|
|
||||||
routers.createRouter(nineRouter({ apiKey: "" }));
|
|
||||||
const { items } = routers.listRouters();
|
|
||||||
assert.equal(items.find((i) => i.name === "9router").hasKey, false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("ai-routers — update", () => {
|
|
||||||
it("changes the label without changing the name a lane already references", () => {
|
|
||||||
// A lane stores the name. Renaming the label must not orphan that lane.
|
|
||||||
const created = routers.createRouter(nineRouter());
|
|
||||||
const updated = routers.updateRouter(created.name, { label: "9Router (nhà)" });
|
|
||||||
assert.equal(updated.name, created.name);
|
|
||||||
assert.equal(updated.label, "9Router (nhà)");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the stored key when the update omits it", () => {
|
|
||||||
// The edit form cannot show the key, so it cannot send it back either.
|
|
||||||
const created = routers.createRouter(nineRouter());
|
|
||||||
routers.updateRouter(created.name, { label: "renamed" });
|
|
||||||
assert.equal(routers.getRouter(created.name).apiKey, "sk-secret-value");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("replaces the key when a new one is given", () => {
|
|
||||||
const created = routers.createRouter(nineRouter());
|
|
||||||
routers.updateRouter(created.name, { apiKey: "sk-rotated" });
|
|
||||||
assert.equal(routers.getRouter(created.name).apiKey, "sk-rotated");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports a name that does not exist", () => {
|
|
||||||
assert.throws(() => routers.updateRouter("ghost", { label: "x" }), { code: "ENOTFOUND" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sets, keeps and clears behavesAs, and refuses an id that is not a Claude model's", () => {
|
|
||||||
const created = routers.createRouter(nineRouter({ behavesAs: " claude-opus-5-5 " }));
|
|
||||||
assert.equal(created.behavesAs, "claude-opus-5-5");
|
|
||||||
assert.equal(
|
|
||||||
routers.updateRouter(created.name, { label: "renamed" }).behavesAs,
|
|
||||||
"claude-opus-5-5"
|
|
||||||
);
|
|
||||||
assert.equal(routers.updateRouter(created.name, { behavesAs: "" }).behavesAs, "");
|
|
||||||
for (const bad of ["agent", "claude opus", "-claude-x", 5]) {
|
|
||||||
assert.throws(() => routers.updateRouter(created.name, { behavesAs: bad }), {
|
|
||||||
code: "EBADMODEL",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("ai-routers — remove", () => {
|
|
||||||
it("deletes the file", () => {
|
|
||||||
const created = routers.createRouter(nineRouter());
|
|
||||||
routers.removeRouter(created.name);
|
|
||||||
assert.equal(fs.existsSync(path.join(ROUTERS_DIR, `${created.name}.json`)), false);
|
|
||||||
assert.equal(routers.listRouters().items.length, 1); // native only
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports a name that does not exist", () => {
|
|
||||||
assert.throws(() => routers.removeRouter("ghost"), { code: "ENOTFOUND" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("ai-routers — a corrupt file is skipped, never fatal", () => {
|
|
||||||
it("lists the healthy profiles around it", () => {
|
|
||||||
routers.createRouter(nineRouter());
|
|
||||||
fs.writeFileSync(path.join(ROUTERS_DIR, "broken.json"), "{not json");
|
|
||||||
const { items } = routers.listRouters();
|
|
||||||
assert.ok(items.some((i) => i.name === "9router"));
|
|
||||||
assert.ok(!items.some((i) => i.name === "broken"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("ai-routers — testRouter", () => {
|
|
||||||
const realFetch = global.fetch;
|
|
||||||
after(() => {
|
|
||||||
global.fetch = realFetch;
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Capture the one request testRouter makes, and answer it however asked. */
|
|
||||||
function stubFetch(answer) {
|
|
||||||
const calls = [];
|
|
||||||
global.fetch = async (url, init) => {
|
|
||||||
calls.push({ url, init });
|
|
||||||
if (answer instanceof Error) throw answer;
|
|
||||||
return answer;
|
|
||||||
};
|
|
||||||
return calls;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ok = () => ({ ok: true, status: 200, statusText: "OK", text: async () => "{}" });
|
|
||||||
|
|
||||||
it("posts one capped message to the endpoint's /v1/messages", async () => {
|
|
||||||
const calls = stubFetch(ok());
|
|
||||||
const result = await routers.testRouter(nineRouter());
|
|
||||||
assert.equal(result.ok, true);
|
|
||||||
assert.equal(calls.length, 1);
|
|
||||||
assert.equal(calls[0].url, "http://localhost:20128/v1/v1/messages");
|
|
||||||
const body = JSON.parse(calls[0].init.body);
|
|
||||||
assert.equal(body.max_tokens, 1);
|
|
||||||
assert.equal(body.model, "Highest");
|
|
||||||
assert.equal(calls[0].init.headers["x-api-key"], "sk-secret-value");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports a router that answered with an error rather than throwing", async () => {
|
|
||||||
stubFetch({ ok: false, status: 401, statusText: "Unauthorized", text: async () => "bad key" });
|
|
||||||
const result = await routers.testRouter(nineRouter());
|
|
||||||
assert.deepEqual(result, { ok: false, status: 401, message: "bad key" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports an endpoint that never answered", async () => {
|
|
||||||
// The shape node's own fetch throws: a useless outer message wrapping the
|
|
||||||
// one thing the user can act on.
|
|
||||||
stubFetch(
|
|
||||||
Object.assign(new Error("fetch failed"), {
|
|
||||||
cause: new Error("connect ECONNREFUSED 127.0.0.1:20199"),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
const result = await routers.testRouter(nineRouter());
|
|
||||||
assert.equal(result.ok, false);
|
|
||||||
assert.equal(result.status, null);
|
|
||||||
assert.match(result.message, /ECONNREFUSED/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to the stored key for the endpoint it was stored against", async () => {
|
|
||||||
const created = routers.createRouter(nineRouter());
|
|
||||||
const calls = stubFetch(ok());
|
|
||||||
await routers.testRouter({
|
|
||||||
name: created.name,
|
|
||||||
baseUrl: "http://localhost:20128/v1",
|
|
||||||
model: "Highest",
|
|
||||||
});
|
|
||||||
assert.equal(calls[0].init.headers["x-api-key"], "sk-secret-value");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("never sends the stored key to a different endpoint", async () => {
|
|
||||||
const created = routers.createRouter(nineRouter());
|
|
||||||
const calls = stubFetch(ok());
|
|
||||||
await assert.rejects(
|
|
||||||
routers.testRouter({
|
|
||||||
name: created.name,
|
|
||||||
baseUrl: "https://attacker.example/v1",
|
|
||||||
model: "Highest",
|
|
||||||
}),
|
|
||||||
{ code: "EBADKEY" }
|
|
||||||
);
|
|
||||||
assert.equal(calls.length, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("refuses a base URL that is not http(s)", async () => {
|
|
||||||
await assert.rejects(routers.testRouter(nineRouter({ baseUrl: "file:///etc/passwd" })), {
|
|
||||||
code: "EBADURL",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -195,7 +195,7 @@ describe("enable / default", () => {
|
|||||||
prompt: { mode: "flag" },
|
prompt: { mode: "flag" },
|
||||||
resume: false,
|
resume: false,
|
||||||
bypass: false,
|
bypass: false,
|
||||||
routerCapable: false,
|
proxyCapable: false,
|
||||||
modelFlag: false,
|
modelFlag: false,
|
||||||
defaultModel: null,
|
defaultModel: null,
|
||||||
modelVendor: null,
|
modelVendor: null,
|
||||||
@@ -203,7 +203,7 @@ describe("enable / default", () => {
|
|||||||
install: null,
|
install: null,
|
||||||
update: null,
|
update: null,
|
||||||
});
|
});
|
||||||
assert.equal(v.tools.find((t) => t.id === "codex").routerCapable, true);
|
assert.equal(v.tools.find((t) => t.id === "codex").proxyCapable, true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -607,7 +607,7 @@ describe("patchSettings — backup and atomicity", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("writes and backs up nothing when the patch changes nothing", () => {
|
it("writes and backs up nothing when the patch changes nothing", () => {
|
||||||
// Re-attaching the same AI Router profile sends the same eight env keys
|
// Re-attaching the same AI Proxy profile sends the same eight env keys
|
||||||
// every time. With an unconditional backup that left a `.bak` per apply
|
// every time. With an unconditional backup that left a `.bak` per apply
|
||||||
// piling up inside the user's own project — a dozen untracked files in a
|
// piling up inside the user's own project — a dozen untracked files in a
|
||||||
// minute of switching lanes.
|
// minute of switching lanes.
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* @file Verifies db.js renames `dashboard_runs.router` (before the AI Router →
|
||||||
|
* AI Proxy rename) to `proxy` in place, keeping each run's value, so a resume
|
||||||
|
* still goes back to the proxy its session last ran on.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { describe, it, before, after } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const path = require("path");
|
||||||
|
const fs = require("fs");
|
||||||
|
const os = require("os");
|
||||||
|
const Database = require("better-sqlite3");
|
||||||
|
|
||||||
|
let TEST_DB;
|
||||||
|
|
||||||
|
before(() => {
|
||||||
|
TEST_DB = path.join(os.tmpdir(), `dashboard-runs-router-${Date.now()}-${process.pid}.db`);
|
||||||
|
process.env.DASHBOARD_DB_PATH = TEST_DB;
|
||||||
|
const raw = new Database(TEST_DB);
|
||||||
|
raw.exec(`
|
||||||
|
CREATE TABLE dashboard_runs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
session_id TEXT,
|
||||||
|
mode TEXT,
|
||||||
|
cwd TEXT NOT NULL,
|
||||||
|
model TEXT,
|
||||||
|
permission_mode TEXT,
|
||||||
|
effort TEXT,
|
||||||
|
resume_session_id TEXT,
|
||||||
|
prompt_preview TEXT,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
exit_code INTEGER,
|
||||||
|
started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
ended_at TEXT,
|
||||||
|
router TEXT
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
raw
|
||||||
|
.prepare(
|
||||||
|
"INSERT INTO dashboard_runs (id, session_id, cwd, status, router) VALUES (?, ?, ?, ?, ?)"
|
||||||
|
)
|
||||||
|
.run("agenthub-lane-1", "sess-1", "/tmp/x", "completed", "9router");
|
||||||
|
raw.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
for (const suffix of ["", "-wal", "-shm", "-migrate.lock"]) {
|
||||||
|
fs.rmSync(TEST_DB + suffix, { force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dashboard_runs.router → proxy", () => {
|
||||||
|
it("renames the column and keeps the value", () => {
|
||||||
|
const { db } = require("../db");
|
||||||
|
const cols = db
|
||||||
|
.prepare("PRAGMA table_info(dashboard_runs)")
|
||||||
|
.all()
|
||||||
|
.map((c) => c.name);
|
||||||
|
assert.ok(cols.includes("proxy"));
|
||||||
|
assert.ok(!cols.includes("router"));
|
||||||
|
assert.equal(require("../lib/dashboard-runs").proxyForSession("sess-1"), "9router");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -247,7 +247,7 @@ describe("GET /api/lanes/:id/git/raw", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("GET /api/lanes/:id/git/file maps a secret to 403 ESECRET", () => {
|
describe("GET /api/lanes/:id/git/file maps a secret to 403 ESECRET", () => {
|
||||||
it("answers 403 for the router's settings file", async () => {
|
it("answers 403 for the old proxy key file (settings.local.json)", async () => {
|
||||||
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "api.db");
|
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "api.db");
|
||||||
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
|
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
|
||||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||||
|
|||||||
@@ -331,19 +331,22 @@ describe("/api/lanes", () => {
|
|||||||
ws.close();
|
ws.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("starts a session on the router it names, and refuses one the store lacks", async () => {
|
it("starts a session on the proxy it names, and refuses one the store lacks", async () => {
|
||||||
const tmux = require("../lib/tmux");
|
const tmux = require("../lib/tmux");
|
||||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-router-start-"));
|
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-proxy-start-"));
|
||||||
const id = (await request("POST", "/api/lanes", { cwd })).body.lane.id;
|
const id = (await request("POST", "/api/lanes", { cwd })).body.lane.id;
|
||||||
try {
|
try {
|
||||||
tmux.__setExecImpl((args) => {
|
tmux.__setExecImpl((args) => {
|
||||||
if (args[0] === "has-session") throw new Error("no such session");
|
if (args[0] === "has-session") throw new Error("no such session");
|
||||||
return "";
|
return "";
|
||||||
});
|
});
|
||||||
const bad = await request("POST", `/api/lanes/${id}/start`, { router: "nope" });
|
const bad = await request("POST", `/api/lanes/${id}/start`, { proxy: "nope" });
|
||||||
assert.equal(bad.status, 400);
|
assert.equal(bad.status, 400);
|
||||||
assert.equal(bad.body.error.code, "EBADROUTER");
|
assert.equal(bad.body.error.code, "EBADPROXY");
|
||||||
// The per-workspace route is gone: a router belongs to a session now.
|
// The pre-rename field still names a proxy, for a client older than it.
|
||||||
|
const legacy = await request("POST", `/api/lanes/${id}/start`, { router: "nope" });
|
||||||
|
assert.equal(legacy.body.error.code, "EBADPROXY");
|
||||||
|
// The per-workspace route is gone: a proxy belongs to a session now.
|
||||||
const gone = await request("POST", `/api/lanes/${id}/router/apply`, { profile: "native" });
|
const gone = await request("POST", `/api/lanes/${id}/router/apply`, { profile: "native" });
|
||||||
assert.equal(gone.status, 404);
|
assert.equal(gone.status, 404);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* @file Tests for starting one session on an AI Router profile — Claude Code's
|
* @file Tests for starting one session on an AI Proxy profile — Claude Code's
|
||||||
* `--settings=<file>` env file and the Codex `<name>.config.toml` layer, an
|
* `--settings=<file>` env file and the Codex `<name>.config.toml` layer, an
|
||||||
* edit or delete reaching those files, and the one-time cleanup of the old
|
* edit or delete reaching those files, and the one-time cleanup of the old
|
||||||
* per-workspace router out of a workspace's `settings.local.json`.
|
* per-workspace proxy out of a workspace's `settings.local.json`.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ const os = require("node:os");
|
|||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
const fs = require("node:fs");
|
const fs = require("node:fs");
|
||||||
|
|
||||||
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-router-apply-"));
|
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-proxy-apply-"));
|
||||||
process.env.CLAUDE_HOME = path.join(SUITE_ROOT, ".claude");
|
process.env.CLAUDE_HOME = path.join(SUITE_ROOT, ".claude");
|
||||||
process.env.CODEX_HOME = path.join(SUITE_ROOT, ".codex");
|
process.env.CODEX_HOME = path.join(SUITE_ROOT, ".codex");
|
||||||
process.env.DASHBOARD_DATA_DIR = path.join(SUITE_ROOT, "data");
|
process.env.DASHBOARD_DATA_DIR = path.join(SUITE_ROOT, "data");
|
||||||
@@ -19,27 +19,27 @@ process.env.DASHBOARD_DB_PATH = path.join(SUITE_ROOT, "dashboard.db");
|
|||||||
const { describe, it, beforeEach, after } = require("node:test");
|
const { describe, it, beforeEach, after } = require("node:test");
|
||||||
const assert = require("node:assert/strict");
|
const assert = require("node:assert/strict");
|
||||||
|
|
||||||
const routers = require("../lib/ai-routers");
|
const proxies = require("../lib/ai-proxies");
|
||||||
const lanesLib = require("../lib/lanes");
|
const lanesLib = require("../lib/lanes");
|
||||||
const { db } = require("../db");
|
const { db } = require("../db");
|
||||||
const {
|
const {
|
||||||
resolveRouter,
|
resolveProxy,
|
||||||
resumeRouter,
|
resumeProxy,
|
||||||
routerForSpawn,
|
proxyForSpawn,
|
||||||
refreshProfileFiles,
|
refreshProfileFiles,
|
||||||
removeProfileFiles,
|
removeProfileFiles,
|
||||||
clearLaneRouters,
|
clearLaneProxies,
|
||||||
} = require("../lib/router-apply");
|
} = require("../lib/proxy-apply");
|
||||||
|
|
||||||
const settingsFile = (name) =>
|
const settingsFile = (name) =>
|
||||||
path.join(process.env.DASHBOARD_DATA_DIR, "router-settings", `${name}.json`);
|
path.join(process.env.DASHBOARD_DATA_DIR, "proxy-settings", `${name}.json`);
|
||||||
const layerFile = (name) => path.join(process.env.CODEX_HOME, `${name}.config.toml`);
|
const layerFile = (name) => path.join(process.env.CODEX_HOME, `${name}.config.toml`);
|
||||||
|
|
||||||
after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true }));
|
after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true }));
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
for (const dir of [
|
for (const dir of [
|
||||||
path.join(process.env.CLAUDE_HOME, "agenthub-routers"),
|
path.join(process.env.CLAUDE_HOME, "agenthub-proxies"),
|
||||||
process.env.CODEX_HOME,
|
process.env.CODEX_HOME,
|
||||||
process.env.DASHBOARD_DATA_DIR,
|
process.env.DASHBOARD_DATA_DIR,
|
||||||
]) {
|
]) {
|
||||||
@@ -48,7 +48,7 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function saveNineRouter() {
|
function saveNineRouter() {
|
||||||
return routers.createRouter({
|
return proxies.createProxy({
|
||||||
label: "9Router",
|
label: "9Router",
|
||||||
baseUrl: "http://localhost:20128/v1",
|
baseUrl: "http://localhost:20128/v1",
|
||||||
apiKey: "sk-secret-value",
|
apiKey: "sk-secret-value",
|
||||||
@@ -56,32 +56,32 @@ function saveNineRouter() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const spawnFor = (tool, name) => routerForSpawn(tool, resolveRouter(tool, name));
|
const spawnFor = (tool, name) => proxyForSpawn(tool, resolveProxy(tool, name));
|
||||||
|
|
||||||
describe("resolveRouter", () => {
|
describe("resolveProxy", () => {
|
||||||
it("reads null, empty and native as the vendor's own API", () => {
|
it("reads null, empty and native as the vendor's own API", () => {
|
||||||
for (const name of [null, undefined, "", "native"])
|
for (const name of [null, undefined, "", "native"])
|
||||||
assert.equal(resolveRouter("claude", name), null);
|
assert.equal(resolveProxy("claude", name), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("refuses a profile the store does not have", () => {
|
it("refuses a profile the store does not have", () => {
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => resolveRouter("claude", "nope"),
|
() => resolveProxy("claude", "nope"),
|
||||||
(e) => e.code === "EBADROUTER"
|
(e) => e.code === "EBADPROXY"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("refuses a router on a tool with no endpoint to point at one", () => {
|
it("refuses a proxy on a tool with no endpoint to point at one", () => {
|
||||||
const { name } = saveNineRouter();
|
const { name } = saveNineRouter();
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => resolveRouter("antigravity", name),
|
() => resolveProxy("antigravity", name),
|
||||||
(e) => e.code === "EUNSUPPORTED"
|
(e) => e.code === "EUNSUPPORTED"
|
||||||
);
|
);
|
||||||
assert.equal(resolveRouter("antigravity", "native"), null);
|
assert.equal(resolveProxy("antigravity", "native"), null);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("routerForSpawn — Claude", () => {
|
describe("proxyForSpawn — Claude", () => {
|
||||||
it("passes a 0600 settings file holding the eight env keys, never the key in argv", () => {
|
it("passes a 0600 settings file holding the eight env keys, never the key in argv", () => {
|
||||||
const { name } = saveNineRouter();
|
const { name } = saveNineRouter();
|
||||||
const { argv, env } = spawnFor("claude", name);
|
const { argv, env } = spawnFor("claude", name);
|
||||||
@@ -89,7 +89,7 @@ describe("routerForSpawn — Claude", () => {
|
|||||||
assert.deepEqual(env, {});
|
assert.deepEqual(env, {});
|
||||||
assert.ok(!argv.join(" ").includes("sk-secret-value"));
|
assert.ok(!argv.join(" ").includes("sk-secret-value"));
|
||||||
const written = JSON.parse(fs.readFileSync(settingsFile(name), "utf8")).env;
|
const written = JSON.parse(fs.readFileSync(settingsFile(name), "utf8")).env;
|
||||||
assert.equal(written.ANTHROPIC_BASE_URL, "http://localhost:20128/v1");
|
assert.equal(written.ANTHROPIC_BASE_URL, "http://localhost:20128"); // Claude Code appends /v1/messages
|
||||||
assert.equal(written.ANTHROPIC_AUTH_TOKEN, "sk-secret-value");
|
assert.equal(written.ANTHROPIC_AUTH_TOKEN, "sk-secret-value");
|
||||||
for (const k of [
|
for (const k of [
|
||||||
"ANTHROPIC_MODEL",
|
"ANTHROPIC_MODEL",
|
||||||
@@ -105,7 +105,7 @@ describe("routerForSpawn — Claude", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("leaves a blank key out instead of blanking the user's own setting", () => {
|
it("leaves a blank key out instead of blanking the user's own setting", () => {
|
||||||
const { name } = routers.createRouter({
|
const { name } = proxies.createProxy({
|
||||||
label: "Local",
|
label: "Local",
|
||||||
baseUrl: "http://127.0.0.1:20128/v1",
|
baseUrl: "http://127.0.0.1:20128/v1",
|
||||||
apiKey: "",
|
apiKey: "",
|
||||||
@@ -127,8 +127,8 @@ describe("routerForSpawn — Claude", () => {
|
|||||||
assert.equal("modelPicker" in written, false);
|
assert.equal("modelPicker" in written, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps every distinct router model name with behavesAs (issue #56)", () => {
|
it("maps every distinct proxy model name with behavesAs (issue #56)", () => {
|
||||||
const { name } = routers.createRouter({
|
const { name } = proxies.createProxy({
|
||||||
label: "9Router combo",
|
label: "9Router combo",
|
||||||
baseUrl: "http://localhost:20128/v1",
|
baseUrl: "http://localhost:20128/v1",
|
||||||
model: "agent",
|
model: "agent",
|
||||||
@@ -148,7 +148,7 @@ describe("routerForSpawn — Claude", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("routerForSpawn — Codex", () => {
|
describe("proxyForSpawn — Codex", () => {
|
||||||
it("writes a profile layer, never the base config, and hands the key over in env", () => {
|
it("writes a profile layer, never the base config, and hands the key over in env", () => {
|
||||||
const base = path.join(process.env.CODEX_HOME, "config.toml");
|
const base = path.join(process.env.CODEX_HOME, "config.toml");
|
||||||
fs.mkdirSync(process.env.CODEX_HOME, { recursive: true });
|
fs.mkdirSync(process.env.CODEX_HOME, { recursive: true });
|
||||||
@@ -156,19 +156,19 @@ describe("routerForSpawn — Codex", () => {
|
|||||||
const { name } = saveNineRouter();
|
const { name } = saveNineRouter();
|
||||||
const { argv, env } = spawnFor("codex", name);
|
const { argv, env } = spawnFor("codex", name);
|
||||||
assert.deepEqual(argv, ["-p", name]);
|
assert.deepEqual(argv, ["-p", name]);
|
||||||
assert.deepEqual(env, { AGENTHUB_ROUTER_KEY: "sk-secret-value" });
|
assert.deepEqual(env, { AGENTHUB_PROXY_KEY: "sk-secret-value" });
|
||||||
assert.equal(fs.readFileSync(base, "utf8"), 'approval_policy = "never"\n');
|
assert.equal(fs.readFileSync(base, "utf8"), 'approval_policy = "never"\n');
|
||||||
const layer = fs.readFileSync(layerFile(name), "utf8");
|
const layer = fs.readFileSync(layerFile(name), "utf8");
|
||||||
assert.match(layer, /model_provider = "9router"/);
|
assert.match(layer, /model_provider = "9router"/);
|
||||||
assert.match(layer, /base_url = "http:\/\/localhost:20128\/v1"/);
|
assert.match(layer, /base_url = "http:\/\/localhost:20128\/v1"/);
|
||||||
assert.match(layer, /env_key = "AGENTHUB_ROUTER_KEY"/);
|
assert.match(layer, /env_key = "AGENTHUB_PROXY_KEY"/);
|
||||||
// Not "chat": codex-cli 0.149 refuses to load it at all.
|
// Not "chat": codex-cli 0.149 refuses to load it at all.
|
||||||
assert.match(layer, /wire_api = "responses"/);
|
assert.match(layer, /wire_api = "responses"/);
|
||||||
assert.ok(!layer.includes("sk-secret-value"));
|
assert.ok(!layer.includes("sk-secret-value"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("names no env_key when the profile has no key", () => {
|
it("names no env_key when the profile has no key", () => {
|
||||||
const { name } = routers.createRouter({
|
const { name } = proxies.createProxy({
|
||||||
label: "Local gateway",
|
label: "Local gateway",
|
||||||
baseUrl: "http://127.0.0.1:20128/v1",
|
baseUrl: "http://127.0.0.1:20128/v1",
|
||||||
apiKey: "",
|
apiKey: "",
|
||||||
@@ -184,7 +184,7 @@ describe("an edit or a delete reaches the session files", () => {
|
|||||||
it("an edit rewrites the files that exist and creates none", () => {
|
it("an edit rewrites the files that exist and creates none", () => {
|
||||||
const { name } = saveNineRouter();
|
const { name } = saveNineRouter();
|
||||||
spawnFor("claude", name);
|
spawnFor("claude", name);
|
||||||
routers.updateRouter(name, { apiKey: "sk-rotated" });
|
proxies.updateProxy(name, { apiKey: "sk-rotated" });
|
||||||
refreshProfileFiles(name);
|
refreshProfileFiles(name);
|
||||||
const env = JSON.parse(fs.readFileSync(settingsFile(name), "utf8")).env;
|
const env = JSON.parse(fs.readFileSync(settingsFile(name), "utf8")).env;
|
||||||
assert.equal(env.ANTHROPIC_AUTH_TOKEN, "sk-rotated");
|
assert.equal(env.ANTHROPIC_AUTH_TOKEN, "sk-rotated");
|
||||||
@@ -201,25 +201,25 @@ describe("an edit or a delete reaches the session files", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("resumeRouter", () => {
|
describe("resumeProxy", () => {
|
||||||
it("goes back to the router the session last ran on, while it exists", () => {
|
it("goes back to the proxy the session last ran on, while it exists", () => {
|
||||||
const { name } = saveNineRouter();
|
const { name } = saveNineRouter();
|
||||||
const runs = require("../lib/dashboard-runs");
|
const runs = require("../lib/dashboard-runs");
|
||||||
runs.recordRun({
|
runs.recordRun({
|
||||||
id: "agenthub-lane-91",
|
id: "agenthub-lane-91",
|
||||||
sessionId: "sess-router-1",
|
sessionId: "sess-proxy-1",
|
||||||
mode: "interactive",
|
mode: "interactive",
|
||||||
router: name,
|
proxy: name,
|
||||||
});
|
});
|
||||||
assert.equal(resumeRouter("sess-router-1"), name);
|
assert.equal(resumeProxy("sess-proxy-1"), name);
|
||||||
routers.removeRouter(name);
|
proxies.removeProxy(name);
|
||||||
assert.equal(resumeRouter("sess-router-1"), null);
|
assert.equal(resumeProxy("sess-proxy-1"), null);
|
||||||
assert.equal(resumeRouter("sess-unknown"), null);
|
assert.equal(resumeProxy("sess-unknown"), null);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("clearLaneRouters", () => {
|
describe("clearLaneProxies", () => {
|
||||||
it("takes the old router's keys out of a workspace's settings and clears the column", () => {
|
it("takes the old proxy's keys out of a workspace's settings and clears the column", () => {
|
||||||
const cwd = path.join(SUITE_ROOT, "old-lane");
|
const cwd = path.join(SUITE_ROOT, "old-lane");
|
||||||
const file = path.join(cwd, ".claude", "settings.local.json");
|
const file = path.join(cwd, ".claude", "settings.local.json");
|
||||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||||
@@ -231,7 +231,7 @@ describe("clearLaneRouters", () => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
const lane = lanesLib.createLane({ title: "old", cwd, router_profile: "9router" });
|
const lane = lanesLib.createLane({ title: "old", cwd, router_profile: "9router" });
|
||||||
clearLaneRouters();
|
clearLaneProxies();
|
||||||
const data = JSON.parse(fs.readFileSync(file, "utf8"));
|
const data = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||||
assert.deepEqual(data.env, { MY_VAR: "1" });
|
assert.deepEqual(data.env, { MY_VAR: "1" });
|
||||||
assert.deepEqual(data.permissions, { allow: ["Bash(ls)"] });
|
assert.deepEqual(data.permissions, { allow: ["Bash(ls)"] });
|
||||||
@@ -247,7 +247,7 @@ describe("clearLaneRouters", () => {
|
|||||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||||
fs.writeFileSync(file, "{ not json");
|
fs.writeFileSync(file, "{ not json");
|
||||||
const lane = lanesLib.createLane({ title: "broken", cwd, router_profile: "9router" });
|
const lane = lanesLib.createLane({ title: "broken", cwd, router_profile: "9router" });
|
||||||
clearLaneRouters();
|
clearLaneProxies();
|
||||||
assert.equal(fs.readFileSync(file, "utf8"), "{ not json");
|
assert.equal(fs.readFileSync(file, "utf8"), "{ not json");
|
||||||
assert.equal(
|
assert.equal(
|
||||||
db.prepare("SELECT router_profile FROM lanes WHERE id = ?").get(lane.id).router_profile,
|
db.prepare("SELECT router_profile FROM lanes WHERE id = ?").get(lane.id).router_profile,
|
||||||
@@ -213,7 +213,7 @@ describe("pty-run", () => {
|
|||||||
});
|
});
|
||||||
pty.spawnRun({ laneId: 12, cwd: "/tmp/repo", tool: "codex" });
|
pty.spawnRun({ laneId: 12, cwd: "/tmp/repo", tool: "codex" });
|
||||||
// Through argv, never as a shell assignment typed into the pane: this is
|
// Through argv, never as a shell assignment typed into the pane: this is
|
||||||
// also the path an AI Router's API key takes, and a typed line would be
|
// also the path an AI Proxy's API key takes, and a typed line would be
|
||||||
// rendered to every attached browser and land in the shell history.
|
// rendered to every attached browser and land in the shell history.
|
||||||
const at = respawn.indexOf("-e");
|
const at = respawn.indexOf("-e");
|
||||||
assert.ok(at > -1, "expected a tmux -e environment flag");
|
assert.ok(at > -1, "expected a tmux -e environment flag");
|
||||||
@@ -309,7 +309,7 @@ describe("pty-run", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("spawnRun starts a session on ITS router: Claude by --settings, Codex by -p", () => {
|
it("spawnRun starts a session on ITS proxy: Claude by --settings, Codex by -p", () => {
|
||||||
let newSessionArgv = null;
|
let newSessionArgv = null;
|
||||||
tmux.__setExecImpl((args) => {
|
tmux.__setExecImpl((args) => {
|
||||||
if (args[0] === "has-session") {
|
if (args[0] === "has-session") {
|
||||||
@@ -320,7 +320,7 @@ describe("pty-run", () => {
|
|||||||
if (args[0] === "new-session") newSessionArgv = args;
|
if (args[0] === "new-session") newSessionArgv = args;
|
||||||
return "";
|
return "";
|
||||||
});
|
});
|
||||||
// The router store and the files a spawn writes live under these homes.
|
// The proxy store and the files a spawn writes live under these homes.
|
||||||
const saved = ["CLAUDE_HOME", "CODEX_HOME", "DASHBOARD_DATA_DIR"].map((k) => [
|
const saved = ["CLAUDE_HOME", "CODEX_HOME", "DASHBOARD_DATA_DIR"].map((k) => [
|
||||||
k,
|
k,
|
||||||
process.env[k],
|
process.env[k],
|
||||||
@@ -329,30 +329,30 @@ describe("pty-run", () => {
|
|||||||
process.env.CODEX_HOME = path.join(TMP, "codex");
|
process.env.CODEX_HOME = path.join(TMP, "codex");
|
||||||
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
|
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
|
||||||
try {
|
try {
|
||||||
const { name } = require("../lib/ai-routers").createRouter({
|
const { name } = require("../lib/ai-proxies").createProxy({
|
||||||
label: "Router",
|
label: "Proxy",
|
||||||
baseUrl: "http://localhost:20128/v1",
|
baseUrl: "http://localhost:20128/v1",
|
||||||
apiKey: "sk-secret-value",
|
apiKey: "sk-secret-value",
|
||||||
model: "M",
|
model: "M",
|
||||||
});
|
});
|
||||||
pty.spawnRun({ laneId: 51, cwd: "/tmp/repo", router: name, initialPrompt: "hi" });
|
pty.spawnRun({ laneId: 51, cwd: "/tmp/repo", proxy: name, initialPrompt: "hi" });
|
||||||
const flag = newSessionArgv.find((a) => a.startsWith("--settings="));
|
const flag = newSessionArgv.find((a) => a.startsWith("--settings="));
|
||||||
assert.equal(flag, `--settings=${path.join(TMP, "data", "router-settings", `${name}.json`)}`);
|
assert.equal(flag, `--settings=${path.join(TMP, "data", "proxy-settings", `${name}.json`)}`);
|
||||||
assert.ok(!newSessionArgv.join(" ").includes("sk-secret-value"));
|
assert.ok(!newSessionArgv.join(" ").includes("sk-secret-value"));
|
||||||
assert.equal(require("../lib/dashboard-runs").getRun("agenthub-lane-51").router, name);
|
assert.equal(require("../lib/dashboard-runs").getRun("agenthub-lane-51").proxy, name);
|
||||||
|
|
||||||
pty.spawnRun({ laneId: 52, cwd: "/tmp/repo", tool: "codex", router: name });
|
pty.spawnRun({ laneId: 52, cwd: "/tmp/repo", tool: "codex", proxy: name });
|
||||||
const at = newSessionArgv.indexOf("-p");
|
const at = newSessionArgv.indexOf("-p");
|
||||||
assert.deepEqual(newSessionArgv.slice(at, at + 2), ["-p", name]);
|
assert.deepEqual(newSessionArgv.slice(at, at + 2), ["-p", name]);
|
||||||
// The key rides tmux's -e, never the command line.
|
// The key rides tmux's -e, never the command line.
|
||||||
assert.ok(newSessionArgv.includes("AGENTHUB_ROUTER_KEY=sk-secret-value"));
|
assert.ok(newSessionArgv.includes("AGENTHUB_PROXY_KEY=sk-secret-value"));
|
||||||
|
|
||||||
pty.spawnRun({ laneId: 53, cwd: "/tmp/repo" });
|
pty.spawnRun({ laneId: 53, cwd: "/tmp/repo" });
|
||||||
assert.ok(!newSessionArgv.some((a) => a.startsWith("--settings=")));
|
assert.ok(!newSessionArgv.some((a) => a.startsWith("--settings=")));
|
||||||
assert.equal(require("../lib/dashboard-runs").getRun("agenthub-lane-53").router, null);
|
assert.equal(require("../lib/dashboard-runs").getRun("agenthub-lane-53").proxy, null);
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => pty.spawnRun({ laneId: 54, cwd: "/tmp/repo", router: "nope" }),
|
() => pty.spawnRun({ laneId: 54, cwd: "/tmp/repo", proxy: "nope" }),
|
||||||
(e) => e.code === "EBADROUTER"
|
(e) => e.code === "EBADPROXY"
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
for (const [k, v] of saved) {
|
for (const [k, v] of saved) {
|
||||||
|
|||||||
@@ -28,6 +28,19 @@ describe("tmux wrapper", () => {
|
|||||||
assert.equal(tmux.hasSession("agenthub-lane-1"), false);
|
assert.equal(tmux.hasSession("agenthub-lane-1"), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("newSession names a command that exited at once instead of tmux's set-option error", () => {
|
||||||
|
tmux.__setExecImpl((args) => {
|
||||||
|
if (args[0] === "new-session") return "";
|
||||||
|
throw Object.assign(new Error("Command failed: tmux " + args.join(" ")), { status: 1 });
|
||||||
|
});
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
tmux.newSession({ name: "agenthub-lane-4", cwd: "/tmp/repo", argv: ["claude", "-p", "x"] }),
|
||||||
|
(err) =>
|
||||||
|
err.code === "EPANEEXITED" && /exited as soon as it started: claude$/.test(err.message)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("newSession builds the correct argv", () => {
|
it("newSession builds the correct argv", () => {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
tmux.__setExecImpl((args) => {
|
tmux.__setExecImpl((args) => {
|
||||||
|
|||||||
+14
-6
@@ -648,7 +648,9 @@ try {
|
|||||||
} catch {
|
} catch {
|
||||||
db.prepare("ALTER TABLE dashboard_runs ADD COLUMN tool TEXT NOT NULL DEFAULT 'claude'").run();
|
db.prepare("ALTER TABLE dashboard_runs ADD COLUMN tool TEXT NOT NULL DEFAULT 'claude'").run();
|
||||||
}
|
}
|
||||||
// Which AI Router profile this lane runs against (see server/lib/ai-routers.js).
|
// Which AI Proxy (then called AI Router) profile this lane ran against. Legacy:
|
||||||
|
// a proxy is a session's choice now, and proxy-apply.js's clearLaneProxies
|
||||||
|
// empties this column at every start.
|
||||||
// Nullable, and null means the same thing as 'native': the vendor's own API.
|
// Nullable, and null means the same thing as 'native': the vendor's own API.
|
||||||
// Set at creation only — like `tool`, it is absent from lanes.js's PATCHABLE,
|
// Set at creation only — like `tool`, it is absent from lanes.js's PATCHABLE,
|
||||||
// because the router is already written into the lane's config on disk and a
|
// because the router is already written into the lane's config on disk and a
|
||||||
@@ -687,13 +689,19 @@ try {
|
|||||||
db.prepare("ALTER TABLE dashboard_runs ADD COLUMN worktree INTEGER NOT NULL DEFAULT 0").run();
|
db.prepare("ALTER TABLE dashboard_runs ADD COLUMN worktree INTEGER NOT NULL DEFAULT 0").run();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Migrate: add `router` to dashboard_runs — the AI Router profile the run was
|
// Migrate: add `proxy` to dashboard_runs — the AI Proxy profile the run was
|
||||||
// started on (NULL = native). A router is chosen per session now, and a resume
|
// started on (NULL = native). A proxy is chosen per session, and a resume that
|
||||||
// that does not name one goes back to the router its session last ran on.
|
// does not name one goes back to the proxy its session last ran on. The column
|
||||||
|
// was `router` before the AI Router → AI Proxy rename; it is renamed in place.
|
||||||
try {
|
try {
|
||||||
db.prepare("SELECT router FROM dashboard_runs LIMIT 1").get();
|
db.prepare("SELECT proxy FROM dashboard_runs LIMIT 1").get();
|
||||||
} catch {
|
} catch {
|
||||||
db.prepare("ALTER TABLE dashboard_runs ADD COLUMN router TEXT").run();
|
try {
|
||||||
|
db.prepare("SELECT router FROM dashboard_runs LIMIT 1").get();
|
||||||
|
db.prepare("ALTER TABLE dashboard_runs RENAME COLUMN router TO proxy").run();
|
||||||
|
} catch {
|
||||||
|
db.prepare("ALTER TABLE dashboard_runs ADD COLUMN proxy TEXT").run();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Migrate: add stage-detection columns to lanes. Inference is never evidence —
|
// Migrate: add stage-detection columns to lanes. Inference is never evidence —
|
||||||
|
|||||||
+6
-5
@@ -82,7 +82,7 @@ const importRouter = require("./routes/import");
|
|||||||
const updatesRouter = require("./routes/updates");
|
const updatesRouter = require("./routes/updates");
|
||||||
const ccConfigRouter = require("./routes/cc-config");
|
const ccConfigRouter = require("./routes/cc-config");
|
||||||
const codexConfigRouter = require("./routes/codex-config");
|
const codexConfigRouter = require("./routes/codex-config");
|
||||||
const aiRoutersRouter = require("./routes/ai-routers");
|
const aiProxiesRouter = require("./routes/ai-proxies");
|
||||||
const gitCredentialsRouter = require("./routes/git-credentials");
|
const gitCredentialsRouter = require("./routes/git-credentials");
|
||||||
const aiToolsRouter = require("./routes/ai-tools");
|
const aiToolsRouter = require("./routes/ai-tools");
|
||||||
const runRouter = require("./routes/run");
|
const runRouter = require("./routes/run");
|
||||||
@@ -133,7 +133,8 @@ function createApp() {
|
|||||||
app.use("/api/updates", updatesRouter);
|
app.use("/api/updates", updatesRouter);
|
||||||
app.use("/api/cc-config", ccConfigRouter);
|
app.use("/api/cc-config", ccConfigRouter);
|
||||||
app.use("/api/codex-config", codexConfigRouter);
|
app.use("/api/codex-config", codexConfigRouter);
|
||||||
app.use("/api/ai-routers", aiRoutersRouter);
|
// `/api/ai-routers` is the pre-rename path, kept for a client older than it.
|
||||||
|
app.use(["/api/ai-proxies", "/api/ai-routers"], aiProxiesRouter);
|
||||||
app.use("/api/git-credentials", gitCredentialsRouter);
|
app.use("/api/git-credentials", gitCredentialsRouter);
|
||||||
app.use("/api/ai-tools", aiToolsRouter);
|
app.use("/api/ai-tools", aiToolsRouter);
|
||||||
app.use("/api/run", runRouter);
|
app.use("/api/run", runRouter);
|
||||||
@@ -680,12 +681,12 @@ function startBackgroundServices() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("dashboard-runs reconciliation failed:", err.message);
|
console.warn("dashboard-runs reconciliation failed:", err.message);
|
||||||
}
|
}
|
||||||
// A router is chosen per session now: take the old per-workspace one out of
|
// A proxy is chosen per session now: take the old per-workspace one out of
|
||||||
// each workspace's settings.local.json (a no-op once done).
|
// each workspace's settings.local.json (a no-op once done).
|
||||||
try {
|
try {
|
||||||
require("./lib/router-apply").clearLaneRouters();
|
require("./lib/proxy-apply").clearLaneProxies();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("clearing workspace routers failed:", err.message);
|
console.warn("clearing workspace proxies failed:", err.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +1,35 @@
|
|||||||
/**
|
/**
|
||||||
* @file ai-routers.js
|
* @file ai-proxies.js
|
||||||
* @description Named AI Router profiles — an endpoint (9Router, OpenRouter, a
|
* @description Named AI Proxy profiles — an endpoint (9Router, OpenRouter, a
|
||||||
* self-hosted gateway) plus the model names to use against it, saved once and
|
* self-hosted gateway) plus the model names to use against it, saved once and
|
||||||
* attached to a lane when that lane is created.
|
* chosen by a new session (`proxy-apply.js`).
|
||||||
*
|
*
|
||||||
* A profile is exactly what the two CLIs need to talk to a router and nothing
|
* A profile is exactly what the two CLIs need to talk to a proxy and nothing
|
||||||
* more: `baseUrl`, `apiKey`, a default `model`, and one slot per tier mirroring
|
* more: `baseUrl`, `apiKey`, a default `model`, and one slot per tier mirroring
|
||||||
* the env keys Claude Code reads. Per-tier slots because a router may expose a
|
* the env keys Claude Code reads. Per-tier slots because a proxy may expose a
|
||||||
* different id per tier — and the `model` field fills every one of them,
|
* different id per tier — and the `model` field fills every one of them,
|
||||||
* because the common case is a single symbolic name for everything.
|
* because the common case is a single symbolic name for everything.
|
||||||
*
|
*
|
||||||
* Profiles are stored one JSON file per profile under
|
* `baseUrl` is the proxy's ROOT, without `/v1`: Claude Code appends
|
||||||
* `<CLAUDE_HOME>/agenthub-routers/` (dir 0700, files 0600), the same shape and
|
* `/v1/messages` to it, and the Codex layer appends `/v1` itself. A `/v1`
|
||||||
* the same reasoning as `cc-profiles.js`: they hold a live API key, so **no
|
* typed (or stored by an older build) is dropped by `normalizeBaseUrl`.
|
||||||
* route ever returns key material** — `listRouters` reports `hasKey` and
|
|
||||||
* nothing else about it.
|
|
||||||
*
|
*
|
||||||
* `native` is a built-in profile rather than a branch in the caller: it carries
|
* Profiles are stored one JSON file per profile under
|
||||||
* empty connection fields and the vendor's own model names, so "switch back to
|
* `<CLAUDE_HOME>/agenthub-proxies/` (dir 0700, files 0600; the pre-rename
|
||||||
* native" is the same single write path as "switch to a router". It cannot be
|
* `agenthub-routers/` is moved there on first read, see `proxiesDir`), the same
|
||||||
|
* shape and the same reasoning as `cc-profiles.js`: they hold a live API key,
|
||||||
|
* so **no route ever returns key material** — `listProxies` reports `hasKey`
|
||||||
|
* and nothing else about it.
|
||||||
|
*
|
||||||
|
* `native` is a built-in profile rather than a branch in the caller: it has no
|
||||||
|
* endpoint and no model of its own (the session's model picker decides), so
|
||||||
|
* "use the vendor's own API" is a choice like any profile. It cannot be
|
||||||
* edited, removed, or shadowed by a saved profile.
|
* edited, removed, or shadowed by a saved profile.
|
||||||
*
|
*
|
||||||
* `testRouter` is the one thing here that talks to the network: the smallest
|
* `testProxy` is the one thing here that talks to the network: the smallest
|
||||||
* real request a claude lane would make, so "saved" and "actually works" stop
|
* real request each CLI would make, so "saved" and "actually works" stop
|
||||||
* being the same claim. It is deliberately NOT a generic proxy — see the note
|
* being the same claim. It is deliberately NOT a generic forwarder — see the
|
||||||
* on its stored-key fallback.
|
* note on its stored-key fallback.
|
||||||
*
|
*
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
@@ -50,10 +55,8 @@ const MODEL_SLOTS = ["opus", "sonnet", "haiku", "smallFast", "subagent"];
|
|||||||
const BUILTIN_NAME = "native";
|
const BUILTIN_NAME = "native";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The vendor's own defaults, used when a lane is set back to native. Empty
|
* The vendor's own API. Never written anywhere (`resolveProxy` answers null for
|
||||||
* connection fields rather than absent ones: the native reference settings.json
|
* it), so it carries no model ids — the session's model picker picks.
|
||||||
* sets both to "", and an absent key would let a user-level router base URL
|
|
||||||
* leak into a lane that asked for native.
|
|
||||||
*/
|
*/
|
||||||
const NATIVE = Object.freeze({
|
const NATIVE = Object.freeze({
|
||||||
name: BUILTIN_NAME,
|
name: BUILTIN_NAME,
|
||||||
@@ -61,14 +64,8 @@ const NATIVE = Object.freeze({
|
|||||||
builtin: true,
|
builtin: true,
|
||||||
baseUrl: "",
|
baseUrl: "",
|
||||||
apiKey: "",
|
apiKey: "",
|
||||||
model: "claude-sonnet-5",
|
model: "",
|
||||||
models: Object.freeze({
|
models: Object.freeze({}),
|
||||||
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",
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function err(code, message) {
|
function err(code, message) {
|
||||||
@@ -77,15 +74,34 @@ function err(code, message) {
|
|||||||
return e;
|
return e;
|
||||||
}
|
}
|
||||||
|
|
||||||
function routersDir() {
|
/**
|
||||||
return path.join(getClaudeHome(), "agenthub-routers");
|
* The profile store. The first read after the rename moves the old
|
||||||
|
* `agenthub-routers/` here and leaves a symlink at the old name, so an older
|
||||||
|
* build on the same CLAUDE_HOME still finds its profiles.
|
||||||
|
*/
|
||||||
|
function proxiesDir() {
|
||||||
|
const dir = path.join(getClaudeHome(), "agenthub-proxies");
|
||||||
|
if (fs.existsSync(dir)) return dir;
|
||||||
|
const legacy = path.join(getClaudeHome(), "agenthub-routers");
|
||||||
|
try {
|
||||||
|
if (fs.lstatSync(legacy).isSymbolicLink()) return dir;
|
||||||
|
fs.renameSync(legacy, dir);
|
||||||
|
} catch {
|
||||||
|
return dir; // no legacy store (or it could not be moved — read it next time)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
fs.symlinkSync("agenthub-proxies", legacy, "dir");
|
||||||
|
} catch {
|
||||||
|
/* best effort — the profiles themselves are already in place */
|
||||||
|
}
|
||||||
|
return dir;
|
||||||
}
|
}
|
||||||
|
|
||||||
function routerFile(name) {
|
function proxyFile(name) {
|
||||||
if (typeof name !== "string" || !NAME_RE.test(name)) {
|
if (typeof name !== "string" || !NAME_RE.test(name)) {
|
||||||
throw err("EBADNAME", "name must be 1-40 chars of letters, digits, dot, dash or underscore");
|
throw err("EBADNAME", "name must be 1-40 chars of letters, digits, dot, dash or underscore");
|
||||||
}
|
}
|
||||||
return path.join(routersDir(), `${name}.json`);
|
return path.join(proxiesDir(), `${name}.json`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function readJson(file) {
|
function readJson(file) {
|
||||||
@@ -119,7 +135,7 @@ function slugify(text) {
|
|||||||
.replace(/[^a-z0-9]+/g, "-")
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
.replace(/^-+|-+$/g, "")
|
.replace(/^-+|-+$/g, "")
|
||||||
.slice(0, 32);
|
.slice(0, 32);
|
||||||
return NAME_RE.test(base) ? base : "router";
|
return NAME_RE.test(base) ? base : "proxy";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `base`, or `base-2`, `base-3`… — the first that is neither taken nor built in. */
|
/** `base`, or `base-2`, `base-3`… — the first that is neither taken nor built in. */
|
||||||
@@ -127,7 +143,7 @@ function freeName(base) {
|
|||||||
let name = base;
|
let name = base;
|
||||||
for (
|
for (
|
||||||
let n = 2;
|
let n = 2;
|
||||||
name === BUILTIN_NAME || fs.existsSync(path.join(routersDir(), `${name}.json`));
|
name === BUILTIN_NAME || fs.existsSync(path.join(proxiesDir(), `${name}.json`));
|
||||||
) {
|
) {
|
||||||
name = `${base}-${n}`;
|
name = `${base}-${n}`;
|
||||||
n += 1;
|
n += 1;
|
||||||
@@ -147,9 +163,10 @@ function normalizeLabel(label) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A router endpoint is an http(s) URL and nothing else. A `file:` or
|
* A proxy endpoint is an http(s) URL and nothing else. A `file:` or
|
||||||
* `javascript:` base URL is never a router — it is a mistake, or an attempt to
|
* `javascript:` base URL is never a proxy — it is a mistake, or an attempt to
|
||||||
* point a CLI somewhere it should not go.
|
* point a CLI somewhere it should not go. A trailing `/v1` is dropped: the
|
||||||
|
* root is stored, and each CLI's path is added where it is used.
|
||||||
*/
|
*/
|
||||||
function normalizeBaseUrl(baseUrl) {
|
function normalizeBaseUrl(baseUrl) {
|
||||||
const raw = typeof baseUrl === "string" ? baseUrl.trim() : "";
|
const raw = typeof baseUrl === "string" ? baseUrl.trim() : "";
|
||||||
@@ -163,10 +180,10 @@ function normalizeBaseUrl(baseUrl) {
|
|||||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||||
throw err("EBADURL", "baseUrl must be http:// or https://");
|
throw err("EBADURL", "baseUrl must be http:// or https://");
|
||||||
}
|
}
|
||||||
return raw.replace(/\/+$/, "");
|
return raw.replace(/\/+$/, "").replace(/\/v1$/, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A model name is free text — a router names its own models — but not blank. */
|
/** A model name is free text — a proxy names its own models — but not blank. */
|
||||||
function normalizeModel(model) {
|
function normalizeModel(model) {
|
||||||
const clean = typeof model === "string" ? model.trim() : "";
|
const clean = typeof model === "string" ? model.trim() : "";
|
||||||
if (!clean || clean.length > 200 || /[\u0000-\u001f\u007f]/.test(clean)) {
|
if (!clean || clean.length > 200 || /[\u0000-\u001f\u007f]/.test(clean)) {
|
||||||
@@ -190,10 +207,10 @@ function normalizeModels(model, models) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A known Claude model id that Claude Code should treat the router's own model
|
* A known Claude model id that Claude Code should treat the proxy's own model
|
||||||
* names as (its `modelPicker` `behavesAs`, issue #56). Optional; `""` clears it.
|
* names as (its `modelPicker` `behavesAs`, issue #56). Optional; `""` clears it.
|
||||||
* The user picks it, never AgentHub: it also sets the context window Claude
|
* The user picks it, never AgentHub: it also sets the context window Claude
|
||||||
* Code compacts at, and a router may route a name to a non-Claude model.
|
* Code compacts at, and a proxy may route a name to a non-Claude model.
|
||||||
*/
|
*/
|
||||||
function normalizeBehavesAs(value) {
|
function normalizeBehavesAs(value) {
|
||||||
const clean = typeof value === "string" ? value.trim() : null;
|
const clean = typeof value === "string" ? value.trim() : null;
|
||||||
@@ -210,10 +227,17 @@ function assertNotBuiltin(name) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Read one stored profile (key included), or null when it does not exist. */
|
/**
|
||||||
function readRouter(name) {
|
* Read one stored profile (key included), or null when it does not exist. A
|
||||||
const stored = readJson(routerFile(name));
|
* base URL an older build stored with `/v1` comes back as the root.
|
||||||
return stored && typeof stored === "object" ? stored : null;
|
*/
|
||||||
|
function readProxy(name) {
|
||||||
|
const stored = readJson(proxyFile(name));
|
||||||
|
if (!stored || typeof stored !== "object") return null;
|
||||||
|
if (typeof stored.baseUrl === "string") {
|
||||||
|
stored.baseUrl = stored.baseUrl.replace(/\/+$/, "").replace(/\/v1$/, "");
|
||||||
|
}
|
||||||
|
return stored;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The browser-safe view: everything except the key, plus whether one is set. */
|
/** The browser-safe view: everything except the key, plus whether one is set. */
|
||||||
@@ -226,11 +250,11 @@ function publicView(profile) {
|
|||||||
* Every saved profile, with the built-in native one first.
|
* Every saved profile, with the built-in native one first.
|
||||||
* @returns `{ items }` — key material is never included.
|
* @returns `{ items }` — key material is never included.
|
||||||
*/
|
*/
|
||||||
function listRouters() {
|
function listProxies() {
|
||||||
let names = [];
|
let names = [];
|
||||||
try {
|
try {
|
||||||
names = fs
|
names = fs
|
||||||
.readdirSync(routersDir())
|
.readdirSync(proxiesDir())
|
||||||
.filter((f) => f.endsWith(".json"))
|
.filter((f) => f.endsWith(".json"))
|
||||||
.map((f) => f.slice(0, -5));
|
.map((f) => f.slice(0, -5));
|
||||||
} catch {
|
} catch {
|
||||||
@@ -242,7 +266,7 @@ function listRouters() {
|
|||||||
if (!NAME_RE.test(name) || name === BUILTIN_NAME) continue;
|
if (!NAME_RE.test(name) || name === BUILTIN_NAME) continue;
|
||||||
let stored;
|
let stored;
|
||||||
try {
|
try {
|
||||||
stored = readRouter(name);
|
stored = readProxy(name);
|
||||||
} catch {
|
} catch {
|
||||||
continue; // a corrupt profile file is skipped, not fatal for the list
|
continue; // a corrupt profile file is skipped, not fatal for the list
|
||||||
}
|
}
|
||||||
@@ -254,12 +278,12 @@ function listRouters() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* One profile INCLUDING its key — for the server's own writers only. No route
|
* One profile INCLUDING its key — for the server's own writers only. No route
|
||||||
* may return this shape; `listRouters` is what the browser sees.
|
* may return this shape; `listProxies` is what the browser sees.
|
||||||
*/
|
*/
|
||||||
function getRouter(name) {
|
function getProxy(name) {
|
||||||
if (name === BUILTIN_NAME) return { ...NATIVE, models: { ...NATIVE.models } };
|
if (name === BUILTIN_NAME) return { ...NATIVE, models: { ...NATIVE.models } };
|
||||||
const stored = readRouter(name);
|
const stored = readProxy(name);
|
||||||
if (!stored) throw err("ENOTFOUND", `no router profile named '${name}'`);
|
if (!stored) throw err("ENOTFOUND", `no proxy profile named '${name}'`);
|
||||||
return { ...stored, name, builtin: false };
|
return { ...stored, name, builtin: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,7 +292,7 @@ function getRouter(name) {
|
|||||||
* models?:Record<string,string>, behavesAs?:string}} input
|
* models?:Record<string,string>, behavesAs?:string}} input
|
||||||
* @returns the browser-safe view of what was saved
|
* @returns the browser-safe view of what was saved
|
||||||
*/
|
*/
|
||||||
function createRouter(input = {}) {
|
function createProxy(input = {}) {
|
||||||
const label = normalizeLabel(input.label);
|
const label = normalizeLabel(input.label);
|
||||||
const slug = slugify(label);
|
const slug = slugify(label);
|
||||||
assertNotBuiltin(slug);
|
assertNotBuiltin(slug);
|
||||||
@@ -285,19 +309,20 @@ function createRouter(input = {}) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const name = freeName(slug);
|
const name = freeName(slug);
|
||||||
writeJson(routerFile(name), profile);
|
writeJson(proxyFile(name), profile);
|
||||||
return publicView({ ...profile, name, builtin: false });
|
return publicView({ ...profile, name, builtin: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merge changes into a saved profile. The `name` never changes — a lane stores
|
* Merge changes into a saved profile. The `name` never changes — a session's
|
||||||
* it, and renaming would orphan that lane. An omitted `apiKey` keeps the stored
|
* run row stores it for a resume, and renaming would orphan that. An omitted
|
||||||
* one: the edit form cannot show a key, so it cannot send one back either.
|
* `apiKey` keeps the stored one: the edit form cannot show a key, so it cannot
|
||||||
|
* send one back either.
|
||||||
*/
|
*/
|
||||||
function updateRouter(name, changes = {}) {
|
function updateProxy(name, changes = {}) {
|
||||||
assertNotBuiltin(name);
|
assertNotBuiltin(name);
|
||||||
const stored = readRouter(name);
|
const stored = readProxy(name);
|
||||||
if (!stored) throw err("ENOTFOUND", `no router profile named '${name}'`);
|
if (!stored) throw err("ENOTFOUND", `no proxy profile named '${name}'`);
|
||||||
|
|
||||||
const model = changes.model === undefined ? stored.model : normalizeModel(changes.model);
|
const model = changes.model === undefined ? stored.model : normalizeModel(changes.model);
|
||||||
const next = {
|
const next = {
|
||||||
@@ -320,14 +345,14 @@ function updateRouter(name, changes = {}) {
|
|||||||
savedAt: new Date().toISOString(),
|
savedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
writeJson(routerFile(name), next);
|
writeJson(proxyFile(name), next);
|
||||||
return publicView({ ...next, name, builtin: false });
|
return publicView({ ...next, name, builtin: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeRouter(name) {
|
function removeProxy(name) {
|
||||||
assertNotBuiltin(name);
|
assertNotBuiltin(name);
|
||||||
const file = routerFile(name);
|
const file = proxyFile(name);
|
||||||
if (!fs.existsSync(file)) throw err("ENOTFOUND", `no router profile named '${name}'`);
|
if (!fs.existsSync(file)) throw err("ENOTFOUND", `no proxy profile named '${name}'`);
|
||||||
fs.rmSync(file);
|
fs.rmSync(file);
|
||||||
return { removed: name };
|
return { removed: name };
|
||||||
}
|
}
|
||||||
@@ -335,59 +360,21 @@ function removeRouter(name) {
|
|||||||
/** How long a connection test waits before calling the endpoint dead. */
|
/** How long a connection test waits before calling the endpoint dead. */
|
||||||
const TEST_TIMEOUT_MS = 15_000;
|
const TEST_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
/** Enough of a failing router's own words to be actionable, not enough to flood. */
|
/** Enough of a failing proxy's own words to be actionable, not enough to flood. */
|
||||||
const TEST_BODY_MAX = 300;
|
const TEST_BODY_MAX = 300;
|
||||||
|
|
||||||
/**
|
/** POST `body` to `url`; the outcome is a result, never a throw. */
|
||||||
* Probe a router endpoint with the smallest real request a claude lane makes:
|
async function probe(url, apiKey, headers, body, model) {
|
||||||
* one Anthropic-style message capped at a single output token.
|
|
||||||
*
|
|
||||||
* Deliberately the CLAUDE wire shape and not Codex's `responses` one. A profile
|
|
||||||
* is not tool-specific, so a test has to pick; `/v1/messages` is what a claude
|
|
||||||
* lane's eight env keys point at, and it is the path a router has to serve for
|
|
||||||
* this feature to work at all.
|
|
||||||
*
|
|
||||||
* **The stored key is only ever sent to the endpoint it was stored against.**
|
|
||||||
* A blank `apiKey` falls back to the saved profile named by `name` — but only
|
|
||||||
* when the submitted `baseUrl` still matches the saved one. Without that check
|
|
||||||
* this route would be a way to make the server POST a saved API key to any URL
|
|
||||||
* a browser names, which is exactly the leak `listRouters` exists to prevent.
|
|
||||||
*
|
|
||||||
* A router that answers is a success even when it answers with an error: the
|
|
||||||
* outcome is reported (`ok`, `status`, `message`), never thrown. Only bad INPUT
|
|
||||||
* throws.
|
|
||||||
*
|
|
||||||
* @param {{name?:string, baseUrl:string, apiKey?:string, model:string}} input
|
|
||||||
* @returns {Promise<{ok:boolean, status:number|null, message:string}>}
|
|
||||||
*/
|
|
||||||
async function testRouter(input = {}) {
|
|
||||||
const baseUrl = normalizeBaseUrl(input.baseUrl);
|
|
||||||
const model = normalizeModel(input.model);
|
|
||||||
|
|
||||||
let apiKey = typeof input.apiKey === "string" ? input.apiKey.trim() : "";
|
|
||||||
if (!apiKey && input.name) {
|
|
||||||
const stored = readRouter(input.name);
|
|
||||||
if (stored && normalizeBaseUrl(stored.baseUrl || "") === baseUrl) apiKey = stored.apiKey || "";
|
|
||||||
}
|
|
||||||
if (!apiKey) throw err("EBADKEY", "enter the API key to test this endpoint");
|
|
||||||
|
|
||||||
let res;
|
let res;
|
||||||
try {
|
try {
|
||||||
res = await fetch(`${baseUrl}/v1/messages`, {
|
res = await fetch(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"content-type": "application/json",
|
"content-type": "application/json",
|
||||||
// Both, because routers disagree on which one they read, and it is the
|
|
||||||
// same key going to the same endpoint either way.
|
|
||||||
"x-api-key": apiKey,
|
|
||||||
authorization: `Bearer ${apiKey}`,
|
authorization: `Bearer ${apiKey}`,
|
||||||
"anthropic-version": "2023-06-01",
|
...headers,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(body),
|
||||||
model,
|
|
||||||
max_tokens: 1,
|
|
||||||
messages: [{ role: "user", content: "ping" }],
|
|
||||||
}),
|
|
||||||
signal: AbortSignal.timeout(TEST_TIMEOUT_MS),
|
signal: AbortSignal.timeout(TEST_TIMEOUT_MS),
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -403,22 +390,74 @@ async function testRouter(input = {}) {
|
|||||||
message: e.name === "TimeoutError" ? "timed out" : (e.cause && e.cause.message) || e.message,
|
message: e.name === "TimeoutError" ? "timed out" : (e.cause && e.cause.message) || e.message,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
const body = await res.text().catch(() => "");
|
|
||||||
return {
|
return {
|
||||||
ok: res.ok,
|
ok: res.ok,
|
||||||
status: res.status,
|
status: res.status,
|
||||||
message: res.ok ? model : body.slice(0, TEST_BODY_MAX) || res.statusText,
|
message: res.ok ? model : text.slice(0, TEST_BODY_MAX) || res.statusText,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Probe a proxy endpoint with the smallest real request each CLI makes, in
|
||||||
|
* parallel: Claude Code's Anthropic message (`/v1/messages`, one output token)
|
||||||
|
* and Codex's OpenAI Responses call (`/v1/responses`, the 16-token minimum). A
|
||||||
|
* profile is not tool-specific, so a proxy that serves only one wire shape
|
||||||
|
* shows up as one green line and one red.
|
||||||
|
*
|
||||||
|
* **The stored key is only ever sent to the endpoint it was stored against.**
|
||||||
|
* A blank `apiKey` falls back to the saved profile named by `name` — but only
|
||||||
|
* when the submitted `baseUrl` still matches the saved one. Without that check
|
||||||
|
* this route would be a way to make the server POST a saved API key to any URL
|
||||||
|
* a browser names, which is exactly the leak `listProxies` exists to prevent.
|
||||||
|
*
|
||||||
|
* A proxy that answers is a success even when it answers with an error: each
|
||||||
|
* outcome is reported (`ok`, `status`, `message`), never thrown. Only bad
|
||||||
|
* INPUT throws.
|
||||||
|
*
|
||||||
|
* @param {{name?:string, baseUrl:string, apiKey?:string, model:string}} input
|
||||||
|
* @returns {Promise<{claude: TestResult, codex: TestResult}>} where TestResult
|
||||||
|
* is `{ok:boolean, status:number|null, message:string}`
|
||||||
|
*/
|
||||||
|
async function testProxy(input = {}) {
|
||||||
|
const baseUrl = normalizeBaseUrl(input.baseUrl);
|
||||||
|
const model = normalizeModel(input.model);
|
||||||
|
|
||||||
|
let apiKey = typeof input.apiKey === "string" ? input.apiKey.trim() : "";
|
||||||
|
if (!apiKey && input.name) {
|
||||||
|
const stored = readProxy(input.name);
|
||||||
|
if (stored && normalizeBaseUrl(stored.baseUrl || "") === baseUrl) apiKey = stored.apiKey || "";
|
||||||
|
}
|
||||||
|
if (!apiKey) throw err("EBADKEY", "enter the API key to test this endpoint");
|
||||||
|
|
||||||
|
const [claude, codex] = await Promise.all([
|
||||||
|
probe(
|
||||||
|
`${baseUrl}/v1/messages`,
|
||||||
|
apiKey,
|
||||||
|
// `x-api-key` beside the Bearer header, because proxies disagree on which
|
||||||
|
// one they read, and it is the same key going to the same endpoint.
|
||||||
|
{ "x-api-key": apiKey, "anthropic-version": "2023-06-01" },
|
||||||
|
{ model, max_tokens: 1, messages: [{ role: "user", content: "ping" }] },
|
||||||
|
model
|
||||||
|
),
|
||||||
|
probe(
|
||||||
|
`${baseUrl}/v1/responses`,
|
||||||
|
apiKey,
|
||||||
|
{},
|
||||||
|
{ model, input: "ping", max_output_tokens: 16 },
|
||||||
|
model
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
return { claude, codex };
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
listRouters,
|
listProxies,
|
||||||
getRouter,
|
getProxy,
|
||||||
createRouter,
|
createProxy,
|
||||||
updateRouter,
|
updateProxy,
|
||||||
removeRouter,
|
removeProxy,
|
||||||
testRouter,
|
testProxy,
|
||||||
MODEL_SLOTS,
|
MODEL_SLOTS,
|
||||||
BUILTIN_NAME,
|
BUILTIN_NAME,
|
||||||
NATIVE,
|
NATIVE,
|
||||||
@@ -21,7 +21,7 @@ const CMD_RE = /^[A-Za-z0-9._+-]+$/;
|
|||||||
const ENV_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
const ENV_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||||
const LAUNCHERS = new Set(["claude", "codex"]);
|
const LAUNCHERS = new Set(["claude", "codex"]);
|
||||||
const PROMPT_MODES = new Set(["argv", "flag", "after-start"]);
|
const PROMPT_MODES = new Set(["argv", "flag", "after-start"]);
|
||||||
const ROUTER_CAPABLE = new Set(["claude", "codex"]);
|
const PROXY_CAPABLE = new Set(["claude", "codex"]);
|
||||||
const PKG_RE = /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
|
const PKG_RE = /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
|
||||||
// Files shipped in a release (built-in, or synced from the newest release) are
|
// Files shipped in a release (built-in, or synced from the newest release) are
|
||||||
// trusted to name a launcher and an install/update; a custom file is not.
|
// trusted to name a launcher and an install/update; a custom file is not.
|
||||||
@@ -417,7 +417,7 @@ function view() {
|
|||||||
prompt: t.prompt ? { mode: t.prompt.mode } : null,
|
prompt: t.prompt ? { mode: t.prompt.mode } : null,
|
||||||
resume: !!t.resume,
|
resume: !!t.resume,
|
||||||
bypass: !!(t.bypass || t.bypassEnv),
|
bypass: !!(t.bypass || t.bypassEnv),
|
||||||
routerCapable: ROUTER_CAPABLE.has(t.id),
|
proxyCapable: PROXY_CAPABLE.has(t.id),
|
||||||
// claude/codex take a model through their own argv builders.
|
// claude/codex take a model through their own argv builders.
|
||||||
modelFlag: Boolean(t.model || t.launcher),
|
modelFlag: Boolean(t.model || t.launcher),
|
||||||
defaultModel: state.models[t.id] || null,
|
defaultModel: state.models[t.id] || null,
|
||||||
@@ -458,7 +458,7 @@ module.exports = {
|
|||||||
which,
|
which,
|
||||||
spawnTarget,
|
spawnTarget,
|
||||||
siteOf,
|
siteOf,
|
||||||
ROUTER_CAPABLE,
|
PROXY_CAPABLE,
|
||||||
CONFIG_SECRET_RE,
|
CONFIG_SECRET_RE,
|
||||||
__setCatalogDirs,
|
__setCatalogDirs,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -572,7 +572,7 @@ function patchSettings(args) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A patch that changes nothing writes nothing — and therefore backs up
|
// A patch that changes nothing writes nothing — and therefore backs up
|
||||||
// nothing. Re-applying the same AI Router profile to a lane is the common
|
// nothing. Re-applying the same AI Proxy profile to a lane is the common
|
||||||
// case (every re-attach sends the same eight env keys), and an unconditional
|
// case (every re-attach sends the same eight env keys), and an unconditional
|
||||||
// backup turned that into a `.bak` per apply piling up inside the user's
|
// backup turned that into a `.bak` per apply piling up inside the user's
|
||||||
// project. `changed` still lists the keys the caller asked for: they ARE the
|
// project. `changed` still lists the keys the caller asked for: they ARE the
|
||||||
|
|||||||
@@ -32,11 +32,11 @@ const insertStmt = db.prepare(`
|
|||||||
INSERT OR REPLACE INTO dashboard_runs (
|
INSERT OR REPLACE INTO dashboard_runs (
|
||||||
id, session_id, mode, cwd, model, permission_mode, effort,
|
id, session_id, mode, cwd, model, permission_mode, effort,
|
||||||
resume_session_id, prompt_preview, status, exit_code, started_at, ended_at,
|
resume_session_id, prompt_preview, status, exit_code, started_at, ended_at,
|
||||||
lane_id, tool, worktree, router
|
lane_id, tool, worktree, proxy
|
||||||
) VALUES (
|
) VALUES (
|
||||||
@id, @session_id, @mode, @cwd, @model, @permission_mode, @effort,
|
@id, @session_id, @mode, @cwd, @model, @permission_mode, @effort,
|
||||||
@resume_session_id, @prompt_preview, @status, @exit_code, @started_at, @ended_at,
|
@resume_session_id, @prompt_preview, @status, @exit_code, @started_at, @ended_at,
|
||||||
@lane_id, @tool, @worktree, @router
|
@lane_id, @tool, @worktree, @proxy
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ const updateStmt = db.prepare(`
|
|||||||
|
|
||||||
const RUN_COLUMNS = `id, session_id, mode, cwd, model, permission_mode, effort,
|
const RUN_COLUMNS = `id, session_id, mode, cwd, model, permission_mode, effort,
|
||||||
resume_session_id, prompt_preview, status, exit_code,
|
resume_session_id, prompt_preview, status, exit_code,
|
||||||
started_at, ended_at, lane_id, tool, worktree, router`;
|
started_at, ended_at, lane_id, tool, worktree, proxy`;
|
||||||
|
|
||||||
const listStmt = db.prepare(`
|
const listStmt = db.prepare(`
|
||||||
SELECT ${RUN_COLUMNS}
|
SELECT ${RUN_COLUMNS}
|
||||||
@@ -123,7 +123,7 @@ function recordRun(handle) {
|
|||||||
lane_id: typeof handle.laneId === "number" ? handle.laneId : null,
|
lane_id: typeof handle.laneId === "number" ? handle.laneId : null,
|
||||||
tool: typeof handle.tool === "string" && handle.tool ? handle.tool : "claude",
|
tool: typeof handle.tool === "string" && handle.tool ? handle.tool : "claude",
|
||||||
worktree: handle.worktree ? 1 : 0,
|
worktree: handle.worktree ? 1 : 0,
|
||||||
router: handle.router || null,
|
proxy: handle.proxy || null,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Best-effort by design (a live run must never fail because sqlite did),
|
// Best-effort by design (a live run must never fail because sqlite did),
|
||||||
@@ -181,19 +181,19 @@ function getRun(id) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionRouterStmt = db.prepare(`
|
const sessionProxyStmt = db.prepare(`
|
||||||
SELECT router FROM dashboard_runs
|
SELECT proxy FROM dashboard_runs
|
||||||
WHERE session_id = @sessionId OR resume_session_id = @sessionId
|
WHERE session_id = @sessionId OR resume_session_id = @sessionId
|
||||||
ORDER BY started_at DESC LIMIT 1
|
ORDER BY started_at DESC LIMIT 1
|
||||||
`);
|
`);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The AI Router the newest run holding `sessionId` started on (null = native,
|
* The AI Proxy the newest run holding `sessionId` started on (null = native,
|
||||||
* or no run known) — what a resume that names no router goes back to.
|
* or no run known) — what a resume that names no proxy goes back to.
|
||||||
*/
|
*/
|
||||||
function routerForSession(sessionId) {
|
function proxyForSession(sessionId) {
|
||||||
try {
|
try {
|
||||||
return sessionRouterStmt.get({ sessionId })?.router || null;
|
return sessionProxyStmt.get({ sessionId })?.proxy || null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -308,7 +308,7 @@ module.exports = {
|
|||||||
getRun,
|
getRun,
|
||||||
forgetShellRun,
|
forgetShellRun,
|
||||||
reconcileOrphans,
|
reconcileOrphans,
|
||||||
routerForSession,
|
proxyForSession,
|
||||||
laneForSession,
|
laneForSession,
|
||||||
claimToolSessions,
|
claimToolSessions,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -233,7 +233,10 @@ function importExportBundle(db, bundle) {
|
|||||||
// Session-independent, config-like tables: restore by natural PK, never
|
// Session-independent, config-like tables: restore by natural PK, never
|
||||||
// overwriting a row the target machine already has.
|
// overwriting a row the target machine already has.
|
||||||
for (const r of Array.isArray(bundle.dashboard_runs) ? bundle.dashboard_runs : []) {
|
for (const r of Array.isArray(bundle.dashboard_runs) ? bundle.dashboard_runs : []) {
|
||||||
if (r && r.id && insert.dashboard_runs(r).changes > 0) counters.dashboard_runs++;
|
// `router` is the pre-rename name of `proxy`, in a bundle an older build exported.
|
||||||
|
const row =
|
||||||
|
r && r.router !== undefined && r.proxy === undefined ? { ...r, proxy: r.router } : r;
|
||||||
|
if (row && row.id && insert.dashboard_runs(row).changes > 0) counters.dashboard_runs++;
|
||||||
}
|
}
|
||||||
for (const r of Array.isArray(bundle.alert_rules) ? bundle.alert_rules : []) {
|
for (const r of Array.isArray(bundle.alert_rules) ? bundle.alert_rules : []) {
|
||||||
if (r && r.id && insert.alert_rules(r).changes > 0) counters.alert_rules++;
|
if (r && r.id && insert.alert_rules(r).changes > 0) counters.alert_rules++;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
*
|
*
|
||||||
* One JSON file under `<CLAUDE_HOME>/agenthub-git-credentials/` (dir 0700,
|
* One JSON file under `<CLAUDE_HOME>/agenthub-git-credentials/` (dir 0700,
|
||||||
* file 0600, written through a temp file), one record per protocol + host,
|
* file 0600, written through a temp file), one record per protocol + host,
|
||||||
* shared by everyone using this AgentHub. Like the AI Router store, **no route
|
* shared by everyone using this AgentHub. Like the AI Proxy store, **no route
|
||||||
* ever returns a password** — `list` reports `hasToken`.
|
* ever returns a password** — `list` reports `hasToken`.
|
||||||
*
|
*
|
||||||
* `gitConfigFor` is how a credential reaches git: `-c` args that make OUR
|
* `gitConfigFor` is how a credential reaches git: `-c` args that make OUR
|
||||||
|
|||||||
@@ -77,8 +77,8 @@ const VALID_RANGES = new Set(["work", "branch", "commit"]);
|
|||||||
* pathspec. `git` is called with an argv array, so this is belt-and-braces. */
|
* pathspec. `git` is called with an argv array, so this is belt-and-braces. */
|
||||||
const SAFE_REV = /^[A-Za-z0-9][A-Za-z0-9._\/-]{0,200}$/;
|
const SAFE_REV = /^[A-Za-z0-9][A-Za-z0-9._\/-]{0,200}$/;
|
||||||
/** The file whose CONTENT this module never returns, at any rev and at ANY
|
/** The file whose CONTENT this module never returns, at any rev and at ANY
|
||||||
* depth: `settings.local.json` is where router-apply.js writes a lane's AI
|
* depth: `settings.local.json` is where AgentHub once wrote a lane's AI
|
||||||
* Router API key, and key material never leaves the server. Any depth,
|
* Proxy API key, and key material never leaves the server. Any depth,
|
||||||
* because a lane at the repo root sees a subdirectory lane's key as
|
* because a lane at the repo root sees a subdirectory lane's key as
|
||||||
* `sub/.claude/settings.local.json`. Lower-cased: a case-insensitive
|
* `sub/.claude/settings.local.json`. Lower-cased: a case-insensitive
|
||||||
* filesystem (macOS) opens it under any casing. */
|
* filesystem (macOS) opens it under any casing. */
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
* The guards are the READ path's own (git-diff.js), so the editor can never
|
* The guards are the READ path's own (git-diff.js), so the editor can never
|
||||||
* write where the viewer could not read: a lexical `lanePath`, a realpath
|
* write where the viewer could not read: a lexical `lanePath`, a realpath
|
||||||
* confined to the lane (no `..`, no symlink out), and no `settings.local.json`
|
* confined to the lane (no `..`, no symlink out), and no `settings.local.json`
|
||||||
* — the lane router's key file — at any depth or through any symlink. On top:
|
* — where a lane's proxy key once lived — at any depth or through any symlink. On top:
|
||||||
* only an EXISTING, textual file within the viewer's size cap, and only over
|
* only an EXISTING, textual file within the viewer's size cap, and only over
|
||||||
* the exact bytes the editor loaded (`baseSha`, from `readFileAt`), so a save
|
* the exact bytes the editor loaded (`baseSha`, from `readFileAt`), so a save
|
||||||
* never silently overwrites what the agent wrote in the meantime (ESTALE).
|
* never silently overwrites what the agent wrote in the meantime (ESTALE).
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* @file router-apply.js
|
* @file proxy-apply.js
|
||||||
* @description Starts ONE session on an AI Router profile (see `ai-routers.js`).
|
* @description Starts ONE session on an AI Proxy profile (see `ai-proxies.js`).
|
||||||
* A router is chosen per new session, not per workspace, so nothing here writes
|
* A proxy is chosen per new session, not per workspace, so nothing here writes
|
||||||
* into the user's project.
|
* into the user's project.
|
||||||
*
|
*
|
||||||
* The two CLIs are configured in different places, and the difference is
|
* The two CLIs are configured in different places, and the difference is
|
||||||
@@ -9,24 +9,27 @@
|
|||||||
*
|
*
|
||||||
* - **Claude Code** gets `--settings=<file>`: a settings file holding the
|
* - **Claude Code** gets `--settings=<file>`: a settings file holding the
|
||||||
* profile's eight `env` keys (plus `modelPicker` rows when the profile
|
* profile's eight `env` keys (plus `modelPicker` rows when the profile
|
||||||
* sets `behavesAs`), `<data dir>/router-settings/<name>.json`
|
* sets `behavesAs`), `<data dir>/proxy-settings/<name>.json`
|
||||||
* (0600). The command-line layer outranks the project-local and user
|
* (0600). The command-line layer outranks the project-local and user
|
||||||
* settings (verified on 2.1.286), so two sessions of one workspace can run
|
* settings (verified on 2.1.286), so two sessions of one workspace can run
|
||||||
* on two routers, and the key stays out of argv — only the path is there.
|
* on two proxies, and the key stays out of argv — only the path is there.
|
||||||
*
|
*
|
||||||
* - **Codex** has no project-level config at all. What it does have is
|
* - **Codex** has no project-level config at all. What it does have is
|
||||||
* `-p <name>`, which layers `$CODEX_HOME/<name>.config.toml` on top of the
|
* `-p <name>`, which layers `$CODEX_HOME/<name>.config.toml` on top of the
|
||||||
* base. So a profile becomes a layer file, the base is never touched, and
|
* base. So a profile becomes a layer file, the base is never touched, and
|
||||||
* the key reaches the pane through tmux's own `-e` under the variable the
|
* the key reaches the pane through tmux's own `-e` under the variable the
|
||||||
* layer's `env_key` names, never as typed text.
|
* layer's `env_key` names, never as typed text. Its `base_url` is the
|
||||||
|
* profile's root plus `/v1` (Codex appends `/responses`); Claude Code's
|
||||||
|
* `ANTHROPIC_BASE_URL` is the bare root (it appends `/v1/messages`).
|
||||||
*
|
*
|
||||||
* Both files are (re)written at every spawn, so an edited profile reaches the
|
* Both files are (re)written at every spawn, so an edited profile reaches the
|
||||||
* next session and a moved CODEX_HOME needs no repair. `refreshProfileFiles`
|
* next session and a moved CODEX_HOME needs no repair. `refreshProfileFiles`
|
||||||
* rewrites them on an edit (a live Claude session reads its file again);
|
* rewrites them on an edit (a live Claude session reads its file again);
|
||||||
* `removeProfileFiles` deletes them with the profile.
|
* `removeProfileFiles` deletes them with the profile.
|
||||||
*
|
*
|
||||||
* Workspaces once carried a router of their own in `lanes.router_profile` and
|
* Workspaces once carried a proxy (then called a router) of their own in
|
||||||
* `<cwd>/.claude/settings.local.json`; `clearLaneRouters` takes that out once.
|
* `lanes.router_profile` and `<cwd>/.claude/settings.local.json`;
|
||||||
|
* `clearLaneProxies` takes that out once.
|
||||||
*
|
*
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
@@ -34,19 +37,19 @@
|
|||||||
const fs = require("node:fs");
|
const fs = require("node:fs");
|
||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
|
|
||||||
const routers = require("./ai-routers");
|
const proxies = require("./ai-proxies");
|
||||||
const { getDataDir } = require("./claude-home");
|
const { getDataDir } = require("./claude-home");
|
||||||
const { getCodexHome } = require("./codex-home");
|
const { getCodexHome } = require("./codex-home");
|
||||||
const { ROUTER_CAPABLE } = require("./ai-tools");
|
const { PROXY_CAPABLE } = require("./ai-tools");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The env var Codex is told to read the router key from. One fixed name rather
|
* The env var Codex is told to read the proxy key from. One fixed name rather
|
||||||
* than one per profile: a Codex pane runs exactly one profile, so a second name
|
* than one per profile: a Codex pane runs exactly one profile, so a second name
|
||||||
* would only create the chance of the wrong one being set.
|
* would only create the chance of the wrong one being set.
|
||||||
*/
|
*/
|
||||||
const CODEX_KEY_ENV = "AGENTHUB_ROUTER_KEY";
|
const CODEX_KEY_ENV = "AGENTHUB_PROXY_KEY";
|
||||||
|
|
||||||
/** The eight env keys a router sets for Claude Code. */
|
/** The eight env keys a proxy sets for Claude Code. */
|
||||||
function envForProfile(profile) {
|
function envForProfile(profile) {
|
||||||
return {
|
return {
|
||||||
ANTHROPIC_BASE_URL: profile.baseUrl,
|
ANTHROPIC_BASE_URL: profile.baseUrl,
|
||||||
@@ -61,7 +64,7 @@ function envForProfile(profile) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function claudeSettingsPath(name) {
|
function claudeSettingsPath(name) {
|
||||||
return path.join(getDataDir(), "router-settings", `${name}.json`);
|
return path.join(getDataDir(), "proxy-settings", `${name}.json`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function codexLayerPath(name) {
|
function codexLayerPath(name) {
|
||||||
@@ -75,7 +78,7 @@ function writeSecret(file, body) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `modelPicker` rows telling Claude Code which known model each of the router's
|
* `modelPicker` rows telling Claude Code which known model each of the proxy's
|
||||||
* own names behaves as — without one, a name like 9Router's `agent` draws an
|
* own names behaves as — without one, a name like 9Router's `agent` draws an
|
||||||
* "isn't described by this version's model catalog" warning and a 200k
|
* "isn't described by this version's model catalog" warning and a 200k
|
||||||
* compact window (issue #56). Only when the profile sets `behavesAs`: this
|
* compact window (issue #56). Only when the profile sets `behavesAs`: this
|
||||||
@@ -100,7 +103,7 @@ function writeClaudeSettings(profile) {
|
|||||||
return file;
|
return file;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** TOML string literal — router names and URLs are validated, quotes are not free. */
|
/** TOML string literal — proxy names and URLs are validated, quotes are not free. */
|
||||||
function tomlString(value) {
|
function tomlString(value) {
|
||||||
return `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
return `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
||||||
}
|
}
|
||||||
@@ -112,14 +115,14 @@ function tomlString(value) {
|
|||||||
function writeCodexLayer(profile) {
|
function writeCodexLayer(profile) {
|
||||||
const file = codexLayerPath(profile.name);
|
const file = codexLayerPath(profile.name);
|
||||||
const body =
|
const body =
|
||||||
`# Written by AgentHub — AI Router profile ${profile.label}.\n` +
|
`# Written by AgentHub — AI Proxy profile ${profile.label}.\n` +
|
||||||
`# Loaded by \`codex -p ${profile.name}\`; the base config.toml is untouched.\n` +
|
`# Loaded by \`codex -p ${profile.name}\`; the base config.toml is untouched.\n` +
|
||||||
`model = ${tomlString(profile.model)}\n` +
|
`model = ${tomlString(profile.model)}\n` +
|
||||||
`model_provider = ${tomlString(profile.name)}\n\n` +
|
`model_provider = ${tomlString(profile.name)}\n\n` +
|
||||||
`[model_providers.${profile.name}]\n` +
|
`[model_providers.${profile.name}]\n` +
|
||||||
`name = ${tomlString(profile.label)}\n` +
|
`name = ${tomlString(profile.label)}\n` +
|
||||||
`base_url = ${tomlString(profile.baseUrl)}\n` +
|
`base_url = ${tomlString(`${profile.baseUrl}/v1`)}\n` +
|
||||||
// `env_key` only when there is a key to name. A local router (the 9Router
|
// `env_key` only when there is a key to name. A local proxy (the 9Router
|
||||||
// reference config is exactly this) needs none, and naming a variable that
|
// reference config is exactly this) needs none, and naming a variable that
|
||||||
// is never set makes Codex refuse to start.
|
// is never set makes Codex refuse to start.
|
||||||
(profile.apiKey ? `env_key = ${tomlString(CODEX_KEY_ENV)}\n` : "") +
|
(profile.apiKey ? `env_key = ${tomlString(CODEX_KEY_ENV)}\n` : "") +
|
||||||
@@ -133,26 +136,26 @@ function writeCodexLayer(profile) {
|
|||||||
/**
|
/**
|
||||||
* The profile a new session asked for, checked against its tool. `null`,
|
* The profile a new session asked for, checked against its tool. `null`,
|
||||||
* `""` and `"native"` mean the vendor's own API and resolve to null.
|
* `""` and `"native"` mean the vendor's own API and resolve to null.
|
||||||
* @throws EBADROUTER for a profile the store does not have, EUNSUPPORTED for a
|
* @throws EBADPROXY for a profile the store does not have, EUNSUPPORTED for a
|
||||||
* router on a tool that has no endpoint to point at one.
|
* proxy on a tool that has no endpoint to point at one.
|
||||||
*/
|
*/
|
||||||
function resolveRouter(tool, name) {
|
function resolveProxy(tool, name) {
|
||||||
if (name == null || name === "" || name === routers.BUILTIN_NAME) return null;
|
if (name == null || name === "" || name === proxies.BUILTIN_NAME) return null;
|
||||||
if (typeof name !== "string") {
|
if (typeof name !== "string") {
|
||||||
throw Object.assign(new Error("router must be a profile name"), { code: "EBADROUTER" });
|
throw Object.assign(new Error("proxy must be a profile name"), { code: "EBADPROXY" });
|
||||||
}
|
}
|
||||||
let profile;
|
let profile;
|
||||||
try {
|
try {
|
||||||
profile = routers.getRouter(name);
|
profile = proxies.getProxy(name);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.code !== "ENOTFOUND") throw e;
|
if (e.code !== "ENOTFOUND") throw e;
|
||||||
throw Object.assign(new Error(`no AI Router profile named "${name}"`), {
|
throw Object.assign(new Error(`no AI Proxy profile named "${name}"`), {
|
||||||
code: "EBADROUTER",
|
code: "EBADPROXY",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (profile.builtin) return null;
|
if (profile.builtin) return null;
|
||||||
if (!ROUTER_CAPABLE.has(tool)) {
|
if (!PROXY_CAPABLE.has(tool)) {
|
||||||
throw Object.assign(new Error(`a ${tool} session cannot use an AI Router`), {
|
throw Object.assign(new Error(`a ${tool} session cannot use an AI Proxy`), {
|
||||||
code: "EUNSUPPORTED",
|
code: "EUNSUPPORTED",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -160,15 +163,15 @@ function resolveRouter(tool, name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The router a resume that names none goes back to: the one its session last
|
* The proxy a resume that names none goes back to: the one its session last
|
||||||
* ran on, while that profile still exists — a deleted one resumes on native.
|
* ran on, while that profile still exists — a deleted one resumes on native.
|
||||||
*/
|
*/
|
||||||
function resumeRouter(sessionId) {
|
function resumeProxy(sessionId) {
|
||||||
if (!sessionId) return null;
|
if (!sessionId) return null;
|
||||||
const name = require("./dashboard-runs").routerForSession(sessionId);
|
const name = require("./dashboard-runs").proxyForSession(sessionId);
|
||||||
if (!name) return null;
|
if (!name) return null;
|
||||||
try {
|
try {
|
||||||
routers.getRouter(name);
|
proxies.getProxy(name);
|
||||||
return name;
|
return name;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
@@ -176,11 +179,11 @@ function resumeRouter(sessionId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Argv and env one spawn needs for `profile` (from `resolveRouter`): Claude's
|
* Argv and env one spawn needs for `profile` (from `resolveProxy`): Claude's
|
||||||
* `--settings=<file>`, Codex's `-p <name>` plus the key. Writes the file.
|
* `--settings=<file>`, Codex's `-p <name>` plus the key. Writes the file.
|
||||||
* @returns {{argv: string[], env: Record<string,string>}}
|
* @returns {{argv: string[], env: Record<string,string>}}
|
||||||
*/
|
*/
|
||||||
function routerForSpawn(tool, profile) {
|
function proxyForSpawn(tool, profile) {
|
||||||
if (!profile) return { argv: [], env: {} };
|
if (!profile) return { argv: [], env: {} };
|
||||||
if (tool === "codex") {
|
if (tool === "codex") {
|
||||||
writeCodexLayer(profile);
|
writeCodexLayer(profile);
|
||||||
@@ -199,18 +202,19 @@ function routerForSpawn(tool, profile) {
|
|||||||
* do not exist yet are written at the next spawn.
|
* do not exist yet are written at the next spawn.
|
||||||
*/
|
*/
|
||||||
function refreshProfileFiles(name) {
|
function refreshProfileFiles(name) {
|
||||||
const profile = routers.getRouter(name);
|
const profile = proxies.getProxy(name);
|
||||||
if (fs.existsSync(claudeSettingsPath(name))) writeClaudeSettings(profile);
|
if (fs.existsSync(claudeSettingsPath(name))) writeClaudeSettings(profile);
|
||||||
if (fs.existsSync(codexLayerPath(name))) writeCodexLayer(profile);
|
if (fs.existsSync(codexLayerPath(name))) writeCodexLayer(profile);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* After a delete: the files hold the deleted router's key and endpoint. Left
|
* After a delete: the files hold the deleted proxy's key and endpoint. Left
|
||||||
* behind, a later profile that slugifies to the same name would also inherit
|
* behind, a later profile that slugifies to the same name would also inherit
|
||||||
* this one's `base_url` in Codex.
|
* this one's `base_url` in Codex. The pre-rename `router-settings/` copy goes too.
|
||||||
*/
|
*/
|
||||||
function removeProfileFiles(name) {
|
function removeProfileFiles(name) {
|
||||||
for (const file of [claudeSettingsPath(name), codexLayerPath(name)]) {
|
const legacy = path.join(getDataDir(), "router-settings", `${name}.json`);
|
||||||
|
for (const file of [claudeSettingsPath(name), codexLayerPath(name), legacy]) {
|
||||||
try {
|
try {
|
||||||
fs.rmSync(file, { force: true });
|
fs.rmSync(file, { force: true });
|
||||||
} catch {
|
} catch {
|
||||||
@@ -220,13 +224,13 @@ function removeProfileFiles(name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Take the old per-workspace router out, once: the eight env keys AgentHub
|
* Take the old per-workspace proxy out, once: the eight env keys AgentHub
|
||||||
* wrote into a claude workspace's `.claude/settings.local.json` (they would
|
* wrote into a claude workspace's `.claude/settings.local.json` (they would
|
||||||
* otherwise route every session there, native ones included) and the
|
* otherwise route every session there, native ones included) and the
|
||||||
* `lanes.router_profile` column. A lane whose file cannot be cleaned (not
|
* `lanes.router_profile` column. A lane whose file cannot be cleaned (not
|
||||||
* JSON, say) keeps its column and is tried again at the next start.
|
* JSON, say) keeps its column and is tried again at the next start.
|
||||||
*/
|
*/
|
||||||
function clearLaneRouters() {
|
function clearLaneProxies() {
|
||||||
const { db } = require("../db");
|
const { db } = require("../db");
|
||||||
const { patchSettings } = require("./cc-mutate");
|
const { patchSettings } = require("./cc-mutate");
|
||||||
const keys = Object.keys(envForProfile({ models: {} })).map((k) => `env.${k}`);
|
const keys = Object.keys(envForProfile({ models: {} })).map((k) => `env.${k}`);
|
||||||
@@ -240,17 +244,17 @@ function clearLaneRouters() {
|
|||||||
}
|
}
|
||||||
db.prepare("UPDATE lanes SET router_profile = NULL WHERE id = ?").run(lane.id);
|
db.prepare("UPDATE lanes SET router_profile = NULL WHERE id = ?").run(lane.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(`[router] could not clear workspace ${lane.id}'s old router: ${e.message}`);
|
console.warn(`[proxy] could not clear workspace ${lane.id}'s old proxy: ${e.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
resolveRouter,
|
resolveProxy,
|
||||||
resumeRouter,
|
resumeProxy,
|
||||||
routerForSpawn,
|
proxyForSpawn,
|
||||||
refreshProfileFiles,
|
refreshProfileFiles,
|
||||||
removeProfileFiles,
|
removeProfileFiles,
|
||||||
clearLaneRouters,
|
clearLaneProxies,
|
||||||
CODEX_KEY_ENV,
|
CODEX_KEY_ENV,
|
||||||
};
|
};
|
||||||
+17
-17
@@ -31,7 +31,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const tmux = require("./tmux");
|
const tmux = require("./tmux");
|
||||||
const { resolveRouter, routerForSpawn } = require("./router-apply");
|
const { resolveProxy, proxyForSpawn } = require("./proxy-apply");
|
||||||
const { desktopEnv } = require("./desktop-env");
|
const { desktopEnv } = require("./desktop-env");
|
||||||
const { getCodexHome } = require("./codex-home");
|
const { getCodexHome } = require("./codex-home");
|
||||||
const aiTools = require("./ai-tools");
|
const aiTools = require("./ai-tools");
|
||||||
@@ -131,7 +131,7 @@ function buildClaudeArgv({
|
|||||||
initialPrompt,
|
initialPrompt,
|
||||||
addDirs = [],
|
addDirs = [],
|
||||||
systemPromptFile = null,
|
systemPromptFile = null,
|
||||||
routerArgv = [],
|
proxyArgv = [],
|
||||||
}) {
|
}) {
|
||||||
const argv = ["claude"];
|
const argv = ["claude"];
|
||||||
// `--add-dir` is VARIADIC (`<directories...>`): it swallows every following
|
// `--add-dir` is VARIADIC (`<directories...>`): it swallows every following
|
||||||
@@ -139,8 +139,8 @@ function buildClaudeArgv({
|
|||||||
// always ends it — after the prompt it would eat the prompt as a directory.
|
// always ends it — after the prompt it would eat the prompt as a directory.
|
||||||
if (addDirs.length) argv.push("--add-dir", ...addDirs);
|
if (addDirs.length) argv.push("--add-dir", ...addDirs);
|
||||||
argv.push("--permission-mode", permissionMode || "auto");
|
argv.push("--permission-mode", permissionMode || "auto");
|
||||||
// The session's AI Router: `--settings=<file>` (router-apply.js).
|
// The session's AI Proxy: `--settings=<file>` (proxy-apply.js).
|
||||||
argv.push(...routerArgv);
|
argv.push(...proxyArgv);
|
||||||
// Claude Code makes the worktree itself (`.claude/worktrees/<name>`, branch
|
// Claude Code makes the worktree itself (`.claude/worktrees/<name>`, branch
|
||||||
// `worktree-<name>`, `.worktreeinclude`, the user's WorktreeCreate hook).
|
// `worktree-<name>`, `.worktreeinclude`, the user's WorktreeCreate hook).
|
||||||
// `=` form: `-w [name]` takes an OPTIONAL value and would otherwise swallow
|
// `=` form: `-w [name]` takes an OPTIONAL value and would otherwise swallow
|
||||||
@@ -182,7 +182,7 @@ function buildCodexArgv({
|
|||||||
resumeLast,
|
resumeLast,
|
||||||
bypass,
|
bypass,
|
||||||
initialPrompt,
|
initialPrompt,
|
||||||
routerArgv = [],
|
proxyArgv = [],
|
||||||
addDirs = [],
|
addDirs = [],
|
||||||
systemPrompt = null,
|
systemPrompt = null,
|
||||||
systemPromptFile = null,
|
systemPromptFile = null,
|
||||||
@@ -191,7 +191,7 @@ function buildCodexArgv({
|
|||||||
// Before the subcommand and before the prompt: `-p` layers
|
// Before the subcommand and before the prompt: `-p` layers
|
||||||
// `$CODEX_HOME/<name>.config.toml` over the base config, and an option that
|
// `$CODEX_HOME/<name>.config.toml` over the base config, and an option that
|
||||||
// lands after the positional prompt is not an option any more.
|
// lands after the positional prompt is not an option any more.
|
||||||
argv.push(...routerArgv);
|
argv.push(...proxyArgv);
|
||||||
if (resumeSessionId) argv.push("resume", resumeSessionId);
|
if (resumeSessionId) argv.push("resume", resumeSessionId);
|
||||||
else if (resumeLast) argv.push("resume", "--last");
|
else if (resumeLast) argv.push("resume", "--last");
|
||||||
if (model) argv.push("-m", model);
|
if (model) argv.push("-m", model);
|
||||||
@@ -266,7 +266,7 @@ function buildArgv(args, entry) {
|
|||||||
* @param {boolean} [args.bypass] the user ticked "bypass permissions" for THIS
|
* @param {boolean} [args.bypass] the user ticked "bypass permissions" for THIS
|
||||||
* run; appends the catalog's bypass args/env. Claude has no catalog bypass.
|
* run; appends the catalog's bypass args/env. Claude has no catalog bypass.
|
||||||
* @param {string} [args.initialPrompt]
|
* @param {string} [args.initialPrompt]
|
||||||
* @param {string} [args.router] AI Router profile THIS session runs on — a
|
* @param {string} [args.proxy] AI Proxy profile THIS session runs on — a
|
||||||
* name only, its key and endpoint come from the store; null/"native" = the
|
* name only, its key and endpoint come from the store; null/"native" = the
|
||||||
* vendor's own API. Claude and Codex only (`EUNSUPPORTED`).
|
* vendor's own API. Claude and Codex only (`EUNSUPPORTED`).
|
||||||
*/
|
*/
|
||||||
@@ -288,7 +288,7 @@ function spawnRun(args) {
|
|||||||
resumeLast,
|
resumeLast,
|
||||||
bypass,
|
bypass,
|
||||||
initialPrompt,
|
initialPrompt,
|
||||||
router,
|
proxy,
|
||||||
} = args || {};
|
} = args || {};
|
||||||
if (typeof laneId !== "number" || !Number.isInteger(laneId)) {
|
if (typeof laneId !== "number" || !Number.isInteger(laneId)) {
|
||||||
throw makeErr("EBADLANE", "laneId must be an integer");
|
throw makeErr("EBADLANE", "laneId must be an integer");
|
||||||
@@ -345,7 +345,7 @@ function spawnRun(args) {
|
|||||||
if (worktree === true && entry.launcher !== "claude") {
|
if (worktree === true && entry.launcher !== "claude") {
|
||||||
throw makeErr("EUNSUPPORTED", `${tool} cannot start a session in its own worktree`);
|
throw makeErr("EUNSUPPORTED", `${tool} cannot start a session in its own worktree`);
|
||||||
}
|
}
|
||||||
const routerProfile = resolveRouter(tool, router); // ENOTFOUND, EUNSUPPORTED
|
const proxyProfile = resolveProxy(tool, proxy); // ENOTFOUND, EUNSUPPORTED
|
||||||
if (forkSession === true) {
|
if (forkSession === true) {
|
||||||
if (!resumeSessionId) throw makeErr("EBADSESSION", "a fork needs resumeSessionId");
|
if (!resumeSessionId) throw makeErr("EBADSESSION", "a fork needs resumeSessionId");
|
||||||
if (entry.launcher !== "claude") {
|
if (entry.launcher !== "claude") {
|
||||||
@@ -382,7 +382,7 @@ function spawnRun(args) {
|
|||||||
laneId,
|
laneId,
|
||||||
tool
|
tool
|
||||||
);
|
);
|
||||||
const routerSpawn = routerForSpawn(tool, routerProfile);
|
const proxySpawn = proxyForSpawn(tool, proxyProfile);
|
||||||
// A new Claude session gets its id from us, so the run knows the session it
|
// A new Claude session gets its id from us, so the run knows the session it
|
||||||
// holds from the start — a hook that lost AGENTHUB_RUN_ID (an older hook
|
// holds from the start — a hook that lost AGENTHUB_RUN_ID (an older hook
|
||||||
// handler) still finds its pane by it (routes/hooks.js → bindRunSession).
|
// handler) still finds its pane by it (routes/hooks.js → bindRunSession).
|
||||||
@@ -406,7 +406,7 @@ function spawnRun(args) {
|
|||||||
resumeLast,
|
resumeLast,
|
||||||
bypass: bypass === true,
|
bypass: bypass === true,
|
||||||
initialPrompt,
|
initialPrompt,
|
||||||
routerArgv: routerSpawn.argv,
|
proxyArgv: proxySpawn.argv,
|
||||||
},
|
},
|
||||||
entry
|
entry
|
||||||
);
|
);
|
||||||
@@ -432,7 +432,7 @@ function spawnRun(args) {
|
|||||||
effort: effort || null,
|
effort: effort || null,
|
||||||
resumeSessionId: resumeSessionId || null,
|
resumeSessionId: resumeSessionId || null,
|
||||||
worktree: worktree === true || inWorktree === true,
|
worktree: worktree === true || inWorktree === true,
|
||||||
router: routerProfile ? routerProfile.name : null,
|
proxy: proxyProfile ? proxyProfile.name : null,
|
||||||
prompt: initialPrompt || "",
|
prompt: initialPrompt || "",
|
||||||
status: "running",
|
status: "running",
|
||||||
startedAt,
|
startedAt,
|
||||||
@@ -446,9 +446,9 @@ function spawnRun(args) {
|
|||||||
// from inside it then resolves the lane exactly, instead of matching on cwd —
|
// from inside it then resolves the lane exactly, instead of matching on cwd —
|
||||||
// which is ambiguous now that one directory can back both a `claude` lane and
|
// which is ambiguous now that one directory can back both a `claude` lane and
|
||||||
// a `codex` lane (see bin/agenthub.js's resolveLaneArg).
|
// a `codex` lane (see bin/agenthub.js's resolveLaneArg).
|
||||||
// A codex session on an AI Router also needs the key its layer's `env_key`
|
// A codex session on an AI Proxy also needs the key its layer's `env_key`
|
||||||
// names. A claude one needs nothing here — its `--settings` file carries the
|
// names. A claude one needs nothing here — its `--settings` file carries the
|
||||||
// router — and a session on the vendor's own API needs nothing either.
|
// proxy — and a session on the vendor's own API needs nothing either.
|
||||||
// The graphical session's own DISPLAY/WAYLAND_DISPLAY goes in too: the
|
// The graphical session's own DISPLAY/WAYLAND_DISPLAY goes in too: the
|
||||||
// server normally runs as a systemd user service with neither, so a pane it
|
// server normally runs as a systemd user service with neither, so a pane it
|
||||||
// starts has no clipboard at all — pasting an image into `claude` there
|
// starts has no clipboard at all — pasting an image into `claude` there
|
||||||
@@ -466,14 +466,14 @@ function spawnRun(args) {
|
|||||||
...(process.env.PATH ? { PATH: process.env.PATH } : {}),
|
...(process.env.PATH ? { PATH: process.env.PATH } : {}),
|
||||||
...desktopEnv(),
|
...desktopEnv(),
|
||||||
// A codex pane must read the same home the dashboard reads — its rollout
|
// A codex pane must read the same home the dashboard reads — its rollout
|
||||||
// journals and the router layer `-p <name>` loads live there — and the
|
// journals and the proxy layer `-p <name>` loads live there — and the
|
||||||
// tmux server's own env may predate, or never have had, CODEX_HOME.
|
// tmux server's own env may predate, or never have had, CODEX_HOME.
|
||||||
...(tool === "codex" ? { CODEX_HOME: getCodexHome() } : {}),
|
...(tool === "codex" ? { CODEX_HOME: getCodexHome() } : {}),
|
||||||
// No CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN: an inline claude duplicated its
|
// No CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN: an inline claude duplicated its
|
||||||
// output whenever the pane narrowed (tmux re-wraps the full-width rules and
|
// output whenever the pane narrowed (tmux re-wraps the full-width rules and
|
||||||
// pushes lines into history its redraw cannot clear). The user's own
|
// pushes lines into history its redraw cannot clear). The user's own
|
||||||
// `"tui"` setting decides; under fullscreen the pane scrolls in Claude.
|
// `"tui"` setting decides; under fullscreen the pane scrolls in Claude.
|
||||||
...routerSpawn.env,
|
...proxySpawn.env,
|
||||||
...(bypass === true && entry.bypassEnv ? entry.bypassEnv : {}),
|
...(bypass === true && entry.bypassEnv ? entry.bypassEnv : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -571,7 +571,7 @@ function spawnShell({ laneId, slot = 0, cwd }) {
|
|||||||
effort: null,
|
effort: null,
|
||||||
resumeSessionId: null,
|
resumeSessionId: null,
|
||||||
worktree: false,
|
worktree: false,
|
||||||
router: null,
|
proxy: null,
|
||||||
prompt: "",
|
prompt: "",
|
||||||
status: "running",
|
status: "running",
|
||||||
startedAt: Date.now(),
|
startedAt: Date.now(),
|
||||||
|
|||||||
+12
-1
@@ -77,7 +77,18 @@ function envFlags(env) {
|
|||||||
*/
|
*/
|
||||||
function newSession({ name, cwd, argv, env }) {
|
function newSession({ name, cwd, argv, env }) {
|
||||||
execImpl(["new-session", "-d", "-s", name, "-c", cwd, ...envFlags(env), "--", ...argv]);
|
execImpl(["new-session", "-d", "-s", name, "-c", cwd, ...envFlags(env), "--", ...argv]);
|
||||||
enableMouse(name);
|
try {
|
||||||
|
enableMouse(name);
|
||||||
|
} catch (err) {
|
||||||
|
// The pane's command exited at once (a CLI that refuses the terminal tmux
|
||||||
|
// gives it, say) and tmux closed the session with it: say that, not the
|
||||||
|
// "Command failed: tmux set-option …" that hid the real problem.
|
||||||
|
if (hasSession(name)) throw err;
|
||||||
|
throw Object.assign(
|
||||||
|
new Error(`the session's command exited as soon as it started: ${argv[0]}`),
|
||||||
|
{ code: "EPANEEXITED" }
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Without this the pane has no scrollbar/drag scroll at all — wheel events
|
// Without this the pane has no scrollbar/drag scroll at all — wheel events
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ const schemas = {
|
|||||||
},
|
},
|
||||||
resume: { type: "boolean", description: "Offers Resume last session" },
|
resume: { type: "boolean", description: "Offers Resume last session" },
|
||||||
bypass: { type: "boolean", description: "Offers the per-run Bypass permissions switch" },
|
bypass: { type: "boolean", description: "Offers the per-run Bypass permissions switch" },
|
||||||
routerCapable: { type: "boolean", description: "Can use an AI Router profile" },
|
proxyCapable: { type: "boolean", description: "Can use an AI Proxy profile" },
|
||||||
install: {
|
install: {
|
||||||
type: "object",
|
type: "object",
|
||||||
nullable: true,
|
nullable: true,
|
||||||
|
|||||||
@@ -585,7 +585,7 @@ const paths = {
|
|||||||
},
|
},
|
||||||
403: {
|
403: {
|
||||||
description:
|
description:
|
||||||
"ESECRET — `.claude/settings.local.json` (the AI Router key) is refused at every rev, at any depth, and under any path that resolves to it (a symlinked `.claude`).",
|
"ESECRET — `.claude/settings.local.json` (where an older build kept the AI Proxy key) is refused at every rev, at any depth, and under any path that resolves to it (a symlinked `.claude`).",
|
||||||
},
|
},
|
||||||
404: { description: "Lane not found." },
|
404: { description: "Lane not found." },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -329,17 +329,6 @@ const schemas = {
|
|||||||
description:
|
description:
|
||||||
"True when the real environment, or the checkout's own `.env`, set this variable at startup: either wins over the settings file, so a saved value is overridden again at the next start.",
|
"True when the real environment, or the checkout's own `.env`, set this variable at startup: either wins over the settings file, so a saved value is overridden again at the next start.",
|
||||||
},
|
},
|
||||||
routers: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
missing: {
|
|
||||||
type: "array",
|
|
||||||
items: { type: "string" },
|
|
||||||
description:
|
|
||||||
"`<lane id> (<title>): <profile>` for every lane naming an AI Router profile the new home's store (`<CLAUDE_HOME>/agenthub-routers/`) does not have — such a lane starts on its base configuration.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1233,7 +1222,7 @@ const paths = {
|
|||||||
tags: ["Settings"],
|
tags: ["Settings"],
|
||||||
summary: "Get the active Codex home directory",
|
summary: "Get the active Codex home directory",
|
||||||
description:
|
description:
|
||||||
"Returns the Codex home directory (CODEX_HOME, else `<homedir>/.codex`) the dashboard reads Codex config and rollout journals from, writes AI Router layers into, and hands every codex lane pane through tmux `-e`.",
|
"Returns the Codex home directory (CODEX_HOME, else `<homedir>/.codex`) the dashboard reads Codex config and rollout journals from, writes AI Proxy layers into at each spawn, and hands every codex lane pane through tmux `-e`.",
|
||||||
operationId: "getCodexHome",
|
operationId: "getCodexHome",
|
||||||
responses: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
@@ -1258,7 +1247,7 @@ const paths = {
|
|||||||
tags: ["Settings"],
|
tags: ["Settings"],
|
||||||
summary: "Update the Codex home directory",
|
summary: "Update the Codex home directory",
|
||||||
description:
|
description:
|
||||||
"Same validation and persistence as PUT /api/settings/claude-home (403 EBADORIGIN from another site, settings file, EBADHOME for a path that is or contains the OS home, INVALID_PATH otherwise). On success it re-writes the AI Router layer (`<name>.config.toml`) of every codex lane on a router into the new home, reported as `routers`, because `codex -p <name>` loads it from there. A codex pane already running keeps its old home until restarted.",
|
"Same validation and persistence as PUT /api/settings/claude-home (403 EBADORIGIN from another site, settings file, EBADHOME for a path that is or contains the OS home, INVALID_PATH otherwise). Nothing is re-written here: a session's AI Proxy layer (`<name>.config.toml`) is written into the current home at its spawn. A codex pane already running keeps its old home until restarted.",
|
||||||
operationId: "updateCodexHome",
|
operationId: "updateCodexHome",
|
||||||
requestBody: {
|
requestBody: {
|
||||||
required: true,
|
required: true,
|
||||||
@@ -1280,26 +1269,17 @@ const paths = {
|
|||||||
"application/json": {
|
"application/json": {
|
||||||
schema: {
|
schema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
required: ["ok", "codex_home", "env_owned", "routers"],
|
required: ["ok", "codex_home", "env_owned"],
|
||||||
properties: {
|
properties: {
|
||||||
ok: { type: "boolean", enum: [true] },
|
ok: { type: "boolean", enum: [true] },
|
||||||
codex_home: { type: "string" },
|
codex_home: { type: "string" },
|
||||||
env_owned: { type: "boolean" },
|
env_owned: { type: "boolean" },
|
||||||
routers: {
|
|
||||||
type: "object",
|
|
||||||
required: ["updated", "failed"],
|
|
||||||
properties: {
|
|
||||||
updated: { type: "integer" },
|
|
||||||
failed: { type: "array", items: { type: "string" } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
example: {
|
example: {
|
||||||
ok: true,
|
ok: true,
|
||||||
codex_home: "/Users/son/.codex-work",
|
codex_home: "/Users/son/.codex-work",
|
||||||
env_owned: false,
|
env_owned: false,
|
||||||
routers: { updated: 1, failed: [] },
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* @file ai-routers.js (routes)
|
* @file ai-proxies.js (routes)
|
||||||
* @description CRUD for AI Router profiles — the endpoints behind the
|
* @description CRUD for AI Proxy profiles — the endpoints behind the
|
||||||
* **AI Router** tab of Settings. CHOOSING a profile is not here: a new session
|
* **AI Proxy** page of Settings, mounted at `/api/ai-proxies` and, for a
|
||||||
* names one (`router` on `POST /api/lanes/:id/start` and `POST /api/run`).
|
* client older than the rename, `/api/ai-routers`. CHOOSING a profile is not
|
||||||
|
* here: a new session names one (`proxy` on `POST /api/lanes/:id/start` and
|
||||||
|
* `POST /api/run`).
|
||||||
*
|
*
|
||||||
* An edit or a delete here does reach the sessions using it: each reads the
|
* An edit or a delete here does reach the sessions using it: each reads the
|
||||||
* profile through a file written at its spawn (`server/lib/router-apply.js`),
|
* profile through a file written at its spawn (`server/lib/proxy-apply.js`),
|
||||||
* which an edit rewrites and a delete removes.
|
* which an edit rewrites and a delete removes.
|
||||||
*
|
*
|
||||||
* No response on this router carries key material. `GET /` returns the store's
|
* No response on this router carries key material. `GET /` returns the store's
|
||||||
@@ -18,8 +20,8 @@
|
|||||||
|
|
||||||
const express = require("express");
|
const express = require("express");
|
||||||
|
|
||||||
const routers = require("../lib/ai-routers");
|
const proxies = require("../lib/ai-proxies");
|
||||||
const { refreshProfileFiles, removeProfileFiles } = require("../lib/router-apply");
|
const { refreshProfileFiles, removeProfileFiles } = require("../lib/proxy-apply");
|
||||||
const { sameOriginGuard } = require("../lib/same-origin");
|
const { sameOriginGuard } = require("../lib/same-origin");
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -40,48 +42,50 @@ function fail(res, e) {
|
|||||||
return res.status(status).json({ error: { code: e.code || "EINTERNAL", message: e.message } });
|
return res.status(status).json({ error: { code: e.code || "EINTERNAL", message: e.message } });
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/ai-routers — every profile, built-in `native` first. Never a key.
|
// GET /api/ai-proxies — every profile, built-in `native` first. Never a key.
|
||||||
router.get("/", (_req, res) => {
|
router.get("/", (_req, res) => {
|
||||||
try {
|
try {
|
||||||
res.json(routers.listRouters());
|
res.json(proxies.listProxies());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return fail(res, e);
|
return fail(res, e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/ai-routers — body: { label, baseUrl, apiKey?, model, models? }
|
// POST /api/ai-proxies — body: { label, baseUrl, apiKey?, model, models?, behavesAs? }
|
||||||
router.post("/", sameOriginGuard, (req, res) => {
|
router.post("/", sameOriginGuard, (req, res) => {
|
||||||
try {
|
try {
|
||||||
res.status(201).json(routers.createRouter(req.body || {}));
|
res.status(201).json(proxies.createProxy(req.body || {}));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return fail(res, e);
|
return fail(res, e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/ai-routers/test — does this endpoint + key + model actually answer?
|
// POST /api/ai-proxies/test — does this endpoint + key + model actually answer,
|
||||||
|
// for Claude Code (`/v1/messages`) and for Codex (`/v1/responses`)?
|
||||||
// Body: `{ baseUrl, model, apiKey?, name? }`. Registered before `/:name` for
|
// Body: `{ baseUrl, model, apiKey?, name? }`. Registered before `/:name` for
|
||||||
// clarity; no collision today, since `/:name` takes no POST.
|
// clarity; no collision today, since `/:name` takes no POST.
|
||||||
//
|
//
|
||||||
// A reachable router is a 200 here whatever it replies — `{ ok:false, status }`
|
// A reachable proxy is a 200 here whatever it replies — `{ claude, codex }`,
|
||||||
// carries the router's own words, which is the whole point. Only bad input is
|
// each `{ ok, status, message }` with the proxy's own words, which is the whole
|
||||||
// a 4xx. The response never echoes the key, including on the path where the
|
// point. Only bad input is a 4xx. The response never echoes the key, including
|
||||||
// stored one was used (see `testRouter` on why that path is narrow).
|
// on the path where the stored one was used (see `testProxy` on why that path
|
||||||
|
// is narrow).
|
||||||
router.post("/test", sameOriginGuard, async (req, res) => {
|
router.post("/test", sameOriginGuard, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
res.json(await routers.testRouter(req.body || {}));
|
res.json(await proxies.testProxy(req.body || {}));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return fail(res, e);
|
return fail(res, e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// PATCH /api/ai-routers/:name — same fields, all optional. An omitted `apiKey`
|
// PATCH /api/ai-proxies/:name — same fields, all optional. An omitted `apiKey`
|
||||||
// keeps the stored one; the edit form cannot show a key, so it cannot echo one.
|
// keeps the stored one; the edit form cannot show a key, so it cannot echo one.
|
||||||
//
|
//
|
||||||
// A session reads the profile through a file written at its spawn, so the
|
// A session reads the profile through a file written at its spawn, so the
|
||||||
// edit rewrites those files too — a rotated key reaches live Claude sessions.
|
// edit rewrites those files too — a rotated key reaches live Claude sessions.
|
||||||
router.patch("/:name", sameOriginGuard, (req, res) => {
|
router.patch("/:name", sameOriginGuard, (req, res) => {
|
||||||
try {
|
try {
|
||||||
const saved = routers.updateRouter(req.params.name, req.body || {});
|
const saved = proxies.updateProxy(req.params.name, req.body || {});
|
||||||
refreshProfileFiles(saved.name);
|
refreshProfileFiles(saved.name);
|
||||||
res.json(saved);
|
res.json(saved);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -89,10 +93,10 @@ router.patch("/:name", sameOriginGuard, (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// DELETE /api/ai-routers/:name — and the session files that copy its key.
|
// DELETE /api/ai-proxies/:name — and the session files that copy its key.
|
||||||
router.delete("/:name", sameOriginGuard, (req, res) => {
|
router.delete("/:name", sameOriginGuard, (req, res) => {
|
||||||
try {
|
try {
|
||||||
const removed = routers.removeRouter(req.params.name);
|
const removed = proxies.removeProxy(req.params.name);
|
||||||
removeProfileFiles(req.params.name);
|
removeProfileFiles(req.params.name);
|
||||||
res.json(removed);
|
res.json(removed);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* @file HTTP surface for AgentHub's own git credentials (git-credentials.js):
|
* @file HTTP surface for AgentHub's own git credentials (git-credentials.js):
|
||||||
* list, save/replace, delete. Like the AI Router routes, nothing here ever
|
* list, save/replace, delete. Like the AI Proxy routes, nothing here ever
|
||||||
* returns a password — `GET` reports `hasToken` — and every write is behind
|
* returns a password — `GET` reports `hasToken` — and every write is behind
|
||||||
* the same-origin guard. A lane's fetch/pull/push also saves one, through
|
* the same-origin guard. A lane's fetch/pull/push also saves one, through
|
||||||
* `POST /api/lanes/:id/git/:action` with `credentials.remember`.
|
* `POST /api/lanes/:id/git/:action` with `credentials.remember`.
|
||||||
|
|||||||
+10
-9
@@ -28,7 +28,7 @@ const laneFeatures = require("../lib/lane-features");
|
|||||||
const proofLib = require("../lib/proof");
|
const proofLib = require("../lib/proof");
|
||||||
const { broadcast } = require("../websocket");
|
const { broadcast } = require("../websocket");
|
||||||
const runs = require("../lib/pty-run");
|
const runs = require("../lib/pty-run");
|
||||||
const { resumeRouter } = require("../lib/router-apply");
|
const { resumeProxy } = require("../lib/proxy-apply");
|
||||||
const { sameOriginGuard } = require("./run");
|
const { sameOriginGuard } = require("./run");
|
||||||
const { writeLaneFile } = require("../lib/lane-file-write");
|
const { writeLaneFile } = require("../lib/lane-file-write");
|
||||||
const { pickPath } = require("../lib/native-dialog");
|
const { pickPath } = require("../lib/native-dialog");
|
||||||
@@ -927,7 +927,7 @@ router.get("/:id/git/file", async (req, res) => {
|
|||||||
);
|
);
|
||||||
res.json({ available: true, ...file });
|
res.json({ available: true, ...file });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// A secret file (the router's API key) is refused at every rev.
|
// A secret file (where a proxy's API key once lived) is refused at every rev.
|
||||||
if (err.code === "ESECRET") {
|
if (err.code === "ESECRET") {
|
||||||
return res.status(403).json({ error: { code: err.code, message: err.message } });
|
return res.status(403).json({ error: { code: err.code, message: err.message } });
|
||||||
}
|
}
|
||||||
@@ -2002,12 +2002,13 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
|||||||
// A NEW session in a worktree of its own (`claude --worktree`).
|
// A NEW session in a worktree of its own (`claude --worktree`).
|
||||||
worktree: body.worktree === true && !body.resumeSessionId,
|
worktree: body.worktree === true && !body.resumeSessionId,
|
||||||
tool: sessionTool || current.tool || "claude",
|
tool: sessionTool || current.tool || "claude",
|
||||||
// The SESSION's AI Router, by name; a resume that names none goes
|
// The SESSION's AI Proxy, by name; a resume that names none goes
|
||||||
// back to the one its session last ran on.
|
// back to the one its session last ran on. `router` is the
|
||||||
router:
|
// pre-rename field, still taken from an older client.
|
||||||
body.router !== undefined
|
proxy:
|
||||||
? body.router
|
(body.proxy ?? body.router) !== undefined
|
||||||
: resumeRouter(body.resumeSessionId || (body.resume ? current.session_id : null)),
|
? (body.proxy ?? body.router)
|
||||||
|
: resumeProxy(body.resumeSessionId || (body.resume ? current.session_id : null)),
|
||||||
model: body.model,
|
model: body.model,
|
||||||
permissionMode: body.permissionMode,
|
permissionMode: body.permissionMode,
|
||||||
effort: body.effort,
|
effort: body.effort,
|
||||||
@@ -2020,7 +2021,7 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
|||||||
});
|
});
|
||||||
startedRunId = handle.id;
|
startedRunId = handle.id;
|
||||||
// An extra pane's session is not the lane's run: `run_id` stays the
|
// An extra pane's session is not the lane's run: `run_id` stays the
|
||||||
// pane-0 session every other path (reset, router restart) names.
|
// pane-0 session every other path (reset, stop) names.
|
||||||
if (!slot) lanesLib.updateLane(current.id, { run_id: handle.id, status: "running" });
|
if (!slot) lanesLib.updateLane(current.id, { run_id: handle.id, status: "running" });
|
||||||
return {};
|
return {};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const tmux = require("../lib/tmux");
|
|||||||
const lanesLib = require("../lib/lanes");
|
const lanesLib = require("../lib/lanes");
|
||||||
const { sameOriginGuard } = require("../lib/same-origin");
|
const { sameOriginGuard } = require("../lib/same-origin");
|
||||||
const aiTools = require("../lib/ai-tools");
|
const aiTools = require("../lib/ai-tools");
|
||||||
const { resumeRouter } = require("../lib/router-apply");
|
const { resumeProxy } = require("../lib/proxy-apply");
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -341,12 +341,13 @@ router.post("/", (req, res) => {
|
|||||||
laneId,
|
laneId,
|
||||||
cwd,
|
cwd,
|
||||||
tool: lane?.tool || "claude",
|
tool: lane?.tool || "claude",
|
||||||
// The session's AI Router, by name (key and endpoint from the store); a
|
// The session's AI Proxy, by name (key and endpoint from the store); a
|
||||||
// resume that names none goes back to the one its session last ran on.
|
// resume that names none goes back to the one its session last ran on.
|
||||||
router:
|
// `router` is the pre-rename field, still taken from an older client.
|
||||||
body.router !== undefined
|
proxy:
|
||||||
? body.router
|
(body.proxy ?? body.router) !== undefined
|
||||||
: resumeRouter(typeof body.resumeSessionId === "string" ? body.resumeSessionId : null),
|
? (body.proxy ?? body.router)
|
||||||
|
: resumeProxy(typeof body.resumeSessionId === "string" ? body.resumeSessionId : null),
|
||||||
model: typeof body.model === "string" && body.model ? body.model : null,
|
model: typeof body.model === "string" && body.model ? body.model : null,
|
||||||
permissionMode:
|
permissionMode:
|
||||||
typeof body.permissionMode === "string" && ALLOWED_PERMISSION_MODES.has(body.permissionMode)
|
typeof body.permissionMode === "string" && ALLOWED_PERMISSION_MODES.has(body.permissionMode)
|
||||||
|
|||||||
@@ -461,7 +461,7 @@ function homeRoutes(route, key, field, get, set) {
|
|||||||
|
|
||||||
// GET/PUT /api/settings/claude-home
|
// GET/PUT /api/settings/claude-home
|
||||||
homeRoutes("/claude-home", "CLAUDE_HOME", "claude_home", getClaudeHome, setClaudeHome);
|
homeRoutes("/claude-home", "CLAUDE_HOME", "claude_home", getClaudeHome, setClaudeHome);
|
||||||
// GET/PUT /api/settings/codex-home — a session's AI Router layer is written
|
// GET/PUT /api/settings/codex-home — a session's AI Proxy layer is written
|
||||||
// into the current home at its spawn, so a move needs no repair here.
|
// into the current home at its spawn, so a move needs no repair here.
|
||||||
homeRoutes("/codex-home", "CODEX_HOME", "codex_home", getCodexHome, setCodexHome);
|
homeRoutes("/codex-home", "CODEX_HOME", "codex_home", getCodexHome, setCodexHome);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user