/** * @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=`: a settings file holding the * profile's eight `env` keys (plus `modelPicker` rows when the profile * sets `behavesAs`), `/router-settings/.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 `, which layers `$CODEX_HOME/.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 * `/.claude/settings.local.json`; `clearLaneRouters` takes that out once. * * @author Nguyễn Ngọc Trí Vĩ */ 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 } /** * `modelPicker` rows telling Claude Code which known model each of the router's * 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 * compact window (issue #56). Only when the profile sets `behavesAs`: this * layer's `modelPicker` replaces the user's own for the session. A bare * `claude-*` id is skipped, since Claude Code already knows it. */ function modelPicker(profile) { if (!profile.behavesAs) return {}; const names = new Set([profile.model, ...Object.values(profile.models || {})]); const options = [...names] .filter((m) => m && !m.startsWith("claude-")) .map((model) => ({ model, behavesAs: profile.behavesAs })); return options.length ? { modelPicker: { options } } : {}; } 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, ...modelPicker(profile) }, 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/.config.toml`, the layer `codex -p ` 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=`, Codex's `-p ` plus the key. Writes the file. * @returns {{argv: string[], env: Record}} */ 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, };