Files
AgentHub/server/lib/proxy-apply.js
T
nntrivi2001 5fe5d51b1b 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
2026-10-06 22:48:09 +07:00

261 lines
10 KiB
JavaScript

/**
* @file proxy-apply.js
* @description Starts ONE session on an AI Proxy profile (see `ai-proxies.js`).
* A proxy 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 (plus `modelPicker` rows when the profile
* sets `behavesAs`), `<data dir>/proxy-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 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
* `-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. 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
* 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 proxy (then called a router) of their own in
* `lanes.router_profile` and `<cwd>/.claude/settings.local.json`;
* `clearLaneProxies` 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 proxies = require("./ai-proxies");
const { getDataDir } = require("./claude-home");
const { getCodexHome } = require("./codex-home");
const { PROXY_CAPABLE } = require("./ai-tools");
/**
* 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
* would only create the chance of the wrong one being set.
*/
const CODEX_KEY_ENV = "AGENTHUB_PROXY_KEY";
/** The eight env keys a proxy 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(), "proxy-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 proxy'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 — proxy 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 Proxy 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}/v1`)}\n` +
// `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
// 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 EBADPROXY for a profile the store does not have, EUNSUPPORTED for a
* proxy on a tool that has no endpoint to point at one.
*/
function resolveProxy(tool, name) {
if (name == null || name === "" || name === proxies.BUILTIN_NAME) return null;
if (typeof name !== "string") {
throw Object.assign(new Error("proxy must be a profile name"), { code: "EBADPROXY" });
}
let profile;
try {
profile = proxies.getProxy(name);
} catch (e) {
if (e.code !== "ENOTFOUND") throw e;
throw Object.assign(new Error(`no AI Proxy profile named "${name}"`), {
code: "EBADPROXY",
});
}
if (profile.builtin) return null;
if (!PROXY_CAPABLE.has(tool)) {
throw Object.assign(new Error(`a ${tool} session cannot use an AI Proxy`), {
code: "EUNSUPPORTED",
});
}
return profile;
}
/**
* 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.
*/
function resumeProxy(sessionId) {
if (!sessionId) return null;
const name = require("./dashboard-runs").proxyForSession(sessionId);
if (!name) return null;
try {
proxies.getProxy(name);
return name;
} catch {
return null;
}
}
/**
* Argv and env one spawn needs for `profile` (from `resolveProxy`): Claude's
* `--settings=<file>`, Codex's `-p <name>` plus the key. Writes the file.
* @returns {{argv: string[], env: Record<string,string>}}
*/
function proxyForSpawn(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 = proxies.getProxy(name);
if (fs.existsSync(claudeSettingsPath(name))) writeClaudeSettings(profile);
if (fs.existsSync(codexLayerPath(name))) writeCodexLayer(profile);
}
/**
* 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
* this one's `base_url` in Codex. The pre-rename `router-settings/` copy goes too.
*/
function removeProfileFiles(name) {
const legacy = path.join(getDataDir(), "router-settings", `${name}.json`);
for (const file of [claudeSettingsPath(name), codexLayerPath(name), legacy]) {
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 proxy 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 clearLaneProxies() {
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(`[proxy] could not clear workspace ${lane.id}'s old proxy: ${e.message}`);
}
}
}
module.exports = {
resolveProxy,
resumeProxy,
proxyForSpawn,
refreshProfileFiles,
removeProfileFiles,
clearLaneProxies,
CODEX_KEY_ENV,
};