c507d37baf
AI Router is a session's choice, not a workspace's: - an AI Router select beside the model picker in the workspace home and a pane's run form (RouterPicker.tsx; hidden off Claude/Codex or with only Native; last pick remembered in agenthub.session.router) - `router` on POST /api/lanes/:id/start and POST /api/run; Claude gets --settings=<data dir>/router-settings/<name>.json (0600, outranks project and user settings, key never in argv), Codex -p <name> + key via tmux -e - dashboard_runs.router; a resume naming none goes back to it - profile PATCH rewrites the session files, DELETE removes them - gone: POST /api/lanes/:id/router/apply, LaneRouterRow, the Add-workspace router radio, the claude-/codex-home `routers` answers; clearLaneRouters takes the old keys out of each workspace's settings.local.json at start Sidebar: Settings pinned to the foot, the collapse toggle beside it (stacked on the rail) instead of in the brand's corner. Icons: shipped SVG marks for claude, codex, openclaw and hermes (a black/white pair swapped by theme) in client/public/ai-tools/, used by AiToolIcon before the server's favicon.
239 lines
8.9 KiB
JavaScript
239 lines
8.9 KiB
JavaScript
/**
|
|
* @file router-apply.js
|
|
* @description Starts ONE session on an AI Router profile (see `ai-routers.js`).
|
|
* A router is chosen per new session, not per workspace, so nothing here writes
|
|
* into the user's project.
|
|
*
|
|
* The two CLIs are configured in different places, and the difference is
|
|
* theirs, not a choice made here:
|
|
*
|
|
* - **Claude Code** gets `--settings=<file>`: a settings file holding the
|
|
* profile's eight `env` keys, `<data dir>/router-settings/<name>.json`
|
|
* (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
|
|
* on two routers, 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
|
|
* `-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
|
|
* the key reaches the pane through tmux's own `-e` under the variable the
|
|
* layer's `env_key` names, never as typed text.
|
|
*
|
|
* 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`
|
|
* rewrites them on an edit (a live Claude session reads its file again);
|
|
* `removeProfileFiles` deletes them with the profile.
|
|
*
|
|
* Workspaces once carried a router of their own in `lanes.router_profile` and
|
|
* `<cwd>/.claude/settings.local.json`; `clearLaneRouters` takes that out once.
|
|
*
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
|
|
const routers = require("./ai-routers");
|
|
const { getDataDir } = require("./claude-home");
|
|
const { getCodexHome } = require("./codex-home");
|
|
const { ROUTER_CAPABLE } = require("./ai-tools");
|
|
|
|
/**
|
|
* The env var Codex is told to read the router key from. One fixed name rather
|
|
* 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.
|
|
*/
|
|
const CODEX_KEY_ENV = "AGENTHUB_ROUTER_KEY";
|
|
|
|
/** The eight env keys a router sets for Claude Code. */
|
|
function envForProfile(profile) {
|
|
return {
|
|
ANTHROPIC_BASE_URL: profile.baseUrl,
|
|
ANTHROPIC_AUTH_TOKEN: profile.apiKey,
|
|
ANTHROPIC_MODEL: profile.model,
|
|
ANTHROPIC_DEFAULT_OPUS_MODEL: profile.models.opus,
|
|
ANTHROPIC_DEFAULT_SONNET_MODEL: profile.models.sonnet,
|
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: profile.models.haiku,
|
|
ANTHROPIC_SMALL_FAST_MODEL: profile.models.smallFast,
|
|
CLAUDE_CODE_SUBAGENT_MODEL: profile.models.subagent,
|
|
};
|
|
}
|
|
|
|
function claudeSettingsPath(name) {
|
|
return path.join(getDataDir(), "router-settings", `${name}.json`);
|
|
}
|
|
|
|
function codexLayerPath(name) {
|
|
return path.join(getCodexHome(), `${name}.config.toml`);
|
|
}
|
|
|
|
function writeSecret(file, body) {
|
|
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
fs.writeFileSync(file, body, { mode: 0o600 });
|
|
fs.chmodSync(file, 0o600); // an existing file keeps its mode on write
|
|
}
|
|
|
|
function writeClaudeSettings(profile) {
|
|
const file = claudeSettingsPath(profile.name);
|
|
// Empty values are left out: a blank key in the command-line layer would
|
|
// blank the user's own setting, not leave it alone.
|
|
const env = Object.fromEntries(Object.entries(envForProfile(profile)).filter(([, v]) => v));
|
|
writeSecret(file, `${JSON.stringify({ env }, null, 2)}\n`);
|
|
return file;
|
|
}
|
|
|
|
/** TOML string literal — router names and URLs are validated, quotes are not free. */
|
|
function tomlString(value) {
|
|
return `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
}
|
|
|
|
/**
|
|
* Write `$CODEX_HOME/<name>.config.toml`, the layer `codex -p <name>` loads on
|
|
* top of the base config. The base file is never read or written here.
|
|
*/
|
|
function writeCodexLayer(profile) {
|
|
const file = codexLayerPath(profile.name);
|
|
const body =
|
|
`# Written by AgentHub — AI Router profile ${profile.label}.\n` +
|
|
`# Loaded by \`codex -p ${profile.name}\`; the base config.toml is untouched.\n` +
|
|
`model = ${tomlString(profile.model)}\n` +
|
|
`model_provider = ${tomlString(profile.name)}\n\n` +
|
|
`[model_providers.${profile.name}]\n` +
|
|
`name = ${tomlString(profile.label)}\n` +
|
|
`base_url = ${tomlString(profile.baseUrl)}\n` +
|
|
// `env_key` only when there is a key to name. A local router (the 9Router
|
|
// reference config is exactly this) needs none, and naming a variable that
|
|
// is never set makes Codex refuse to start.
|
|
(profile.apiKey ? `env_key = ${tomlString(CODEX_KEY_ENV)}\n` : "") +
|
|
// Not "chat": codex-cli 0.149 rejects it outright — `wire_api = "chat" is
|
|
// no longer supported`, a hard config error before the first request.
|
|
`wire_api = "responses"\n`;
|
|
writeSecret(file, body);
|
|
return file;
|
|
}
|
|
|
|
/**
|
|
* The profile a new session asked for, checked against its tool. `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
|
|
* router on a tool that has no endpoint to point at one.
|
|
*/
|
|
function resolveRouter(tool, name) {
|
|
if (name == null || name === "" || name === routers.BUILTIN_NAME) return null;
|
|
if (typeof name !== "string") {
|
|
throw Object.assign(new Error("router must be a profile name"), { code: "EBADROUTER" });
|
|
}
|
|
let profile;
|
|
try {
|
|
profile = routers.getRouter(name);
|
|
} catch (e) {
|
|
if (e.code !== "ENOTFOUND") throw e;
|
|
throw Object.assign(new Error(`no AI Router profile named "${name}"`), {
|
|
code: "EBADROUTER",
|
|
});
|
|
}
|
|
if (profile.builtin) return null;
|
|
if (!ROUTER_CAPABLE.has(tool)) {
|
|
throw Object.assign(new Error(`a ${tool} session cannot use an AI Router`), {
|
|
code: "EUNSUPPORTED",
|
|
});
|
|
}
|
|
return profile;
|
|
}
|
|
|
|
/**
|
|
* The router 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.
|
|
*/
|
|
function resumeRouter(sessionId) {
|
|
if (!sessionId) return null;
|
|
const name = require("./dashboard-runs").routerForSession(sessionId);
|
|
if (!name) return null;
|
|
try {
|
|
routers.getRouter(name);
|
|
return name;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Argv and env one spawn needs for `profile` (from `resolveRouter`): Claude's
|
|
* `--settings=<file>`, Codex's `-p <name>` plus the key. Writes the file.
|
|
* @returns {{argv: string[], env: Record<string,string>}}
|
|
*/
|
|
function routerForSpawn(tool, profile) {
|
|
if (!profile) return { argv: [], env: {} };
|
|
if (tool === "codex") {
|
|
writeCodexLayer(profile);
|
|
return {
|
|
argv: ["-p", profile.name],
|
|
env: profile.apiKey ? { [CODEX_KEY_ENV]: profile.apiKey } : {},
|
|
};
|
|
}
|
|
// `=` form: one argv element, never read as an option of its own.
|
|
return { argv: [`--settings=${writeClaudeSettings(profile)}`], env: {} };
|
|
}
|
|
|
|
/**
|
|
* After an edit: rewrite whichever of the profile's files exist, so a live
|
|
* Claude session and the next Codex one see the new key/endpoint. Files that
|
|
* do not exist yet are written at the next spawn.
|
|
*/
|
|
function refreshProfileFiles(name) {
|
|
const profile = routers.getRouter(name);
|
|
if (fs.existsSync(claudeSettingsPath(name))) writeClaudeSettings(profile);
|
|
if (fs.existsSync(codexLayerPath(name))) writeCodexLayer(profile);
|
|
}
|
|
|
|
/**
|
|
* After a delete: the files hold the deleted router's key and endpoint. Left
|
|
* behind, a later profile that slugifies to the same name would also inherit
|
|
* this one's `base_url` in Codex.
|
|
*/
|
|
function removeProfileFiles(name) {
|
|
for (const file of [claudeSettingsPath(name), codexLayerPath(name)]) {
|
|
try {
|
|
fs.rmSync(file, { force: true });
|
|
} catch {
|
|
/* best effort — an orphan file is a leak, not a failure to report */
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Take the old per-workspace router out, once: the eight env keys AgentHub
|
|
* wrote into a claude workspace's `.claude/settings.local.json` (they would
|
|
* otherwise route every session there, native ones included) and the
|
|
* `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.
|
|
*/
|
|
function clearLaneRouters() {
|
|
const { db } = require("../db");
|
|
const { patchSettings } = require("./cc-mutate");
|
|
const keys = Object.keys(envForProfile({ models: {} })).map((k) => `env.${k}`);
|
|
const lanes = db
|
|
.prepare("SELECT id, cwd, tool FROM lanes WHERE router_profile IS NOT NULL")
|
|
.all();
|
|
for (const lane of lanes) {
|
|
try {
|
|
if (lane.tool === "claude" && fs.existsSync(path.join(lane.cwd, ".claude"))) {
|
|
patchSettings({ scope: "project-local", cwd: lane.cwd, set: {}, unset: keys });
|
|
}
|
|
db.prepare("UPDATE lanes SET router_profile = NULL WHERE id = ?").run(lane.id);
|
|
} catch (e) {
|
|
console.warn(`[router] could not clear workspace ${lane.id}'s old router: ${e.message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
resolveRouter,
|
|
resumeRouter,
|
|
routerForSpawn,
|
|
refreshProfileFiles,
|
|
removeProfileFiles,
|
|
clearLaneRouters,
|
|
CODEX_KEY_ENV,
|
|
};
|