/** * @file ai-proxies.js * @description Named AI Proxy profiles — an endpoint (9Router, OpenRouter, a * self-hosted gateway) plus the model names to use against it, saved once and * chosen by a new session (`proxy-apply.js`). * * 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 * 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, * because the common case is a single symbolic name for everything. * * `baseUrl` is the proxy's ROOT, without `/v1`: Claude Code appends * `/v1/messages` to it, and the Codex layer appends `/v1` itself. A `/v1` * typed (or stored by an older build) is dropped by `normalizeBaseUrl`. * * Profiles are stored one JSON file per profile under * `/agenthub-proxies/` (dir 0700, files 0600; the pre-rename * `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. * * **Shared and personal.** The store's top level holds the SHARED profiles — * the host's in local mode, the admin's in server mode. Each user may also keep * PERSONAL ones under `users//`, named `u_`: `slugify` * never emits `_`, so a personal name can never equal a shared one, and the * name alone (stored in `dashboard_runs.proxy`) says whose it is. Who may see, * write or start a session on which is decided by the routes and by * `proxy-apply.resolveProxy`, from `ownerOfName`. * * `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. * * `testProxy` is the one thing here that talks to the network: the smallest * real request each CLI would make, so "saved" and "actually works" stop * being the same claim. It is deliberately NOT a generic forwarder — see the * note on its stored-key fallback. * * @author Nguyễn Ngọc Trí Vĩ */ const fs = require("node:fs"); const path = require("node:path"); const { getClaudeHome } = require("./claude-home"); /** * A profile's `name` is its filename, so the charset is deliberately narrow. * What the user reads and edits is `label`, which accepts spaces and diacritics * precisely because it never touches a path. Same split as cc-profiles.js. */ const NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,39}$/; const LABEL_MIN = 3; const LABEL_MAX = 60; /** The per-tier model overrides Claude Code reads, in the order the UI shows them. */ const MODEL_SLOTS = ["opus", "sonnet", "haiku", "smallFast", "subagent"]; const BUILTIN_NAME = "native"; /** * The vendor's own API. Never written anywhere (`resolveProxy` answers null for * it), so it carries no model ids — the session's model picker picks. */ const NATIVE = Object.freeze({ name: BUILTIN_NAME, label: "Native", builtin: true, baseUrl: "", apiKey: "", model: "", models: Object.freeze({}), }); function err(code, message) { const e = new Error(message); e.code = code; return e; } /** * 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; } /** A personal profile's name: `u_`. */ const PERSONAL_RE = /^u([1-9][0-9]{0,8})_[a-z0-9][a-z0-9-]*$/; /** The user id a personal profile belongs to, or null for a shared one. */ function ownerOfName(name) { const m = typeof name === "string" ? PERSONAL_RE.exec(name) : null; return m ? Number(m[1]) : null; } /** Where one owner's profiles live: the top level is the shared store. */ function storeDir(owner) { return owner == null ? proxiesDir() : path.join(proxiesDir(), "users", String(owner)); } function proxyFile(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"); } return path.join(storeDir(ownerOfName(name)), `${name}.json`); } function readJson(file) { let raw; try { raw = fs.readFileSync(file, "utf8"); } catch { return null; } try { return JSON.parse(raw); } catch { throw err("EBADJSON", `${file} is not valid JSON - fix it by hand first`); } } /** Write JSON at 0600 through a temp file, so a crash never truncates the original. */ function writeJson(file, value) { fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }); const tmp = `${file}.agenthub-tmp`; fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); fs.renameSync(tmp, file); } /** Reduce any text to the narrow NAME_RE charset, for use as a filename. */ function slugify(text) { const base = String(text || "") .normalize("NFD") .replace(/[\u0300-\u036f]/g, "") // drop the combining accents NFD split off .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 32); return NAME_RE.test(base) ? base : "proxy"; } /** * `base`, or `base-2`, `base-3`… — the first that is neither taken nor built * in — prefixed `u_` for a personal profile (the slug shortened so the * whole name still fits NAME_RE's 40 characters). */ function freeName(base, owner = null) { const prefix = owner == null ? "" : `u${owner}_`; const stem = base.slice(0, 40 - prefix.length - 4).replace(/-+$/, "") || "proxy"; let name = prefix + stem; for (let n = 2; name === BUILTIN_NAME || fs.existsSync(proxyFile(name)); n += 1) { name = `${prefix}${stem}-${n}`; } return name; } function normalizeLabel(label) { const clean = typeof label === "string" ? label.trim() : ""; if (clean.length < LABEL_MIN || clean.length > LABEL_MAX || /[\u0000-\u001f\u007f]/.test(clean)) { throw err( "EBADLABEL", `label must be ${LABEL_MIN}-${LABEL_MAX} characters and no control characters` ); } return clean; } /** * A proxy endpoint is an http(s) URL and nothing else. A `file:` or * `javascript:` base URL is never a proxy — it is a mistake, or an attempt to * 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) { const raw = typeof baseUrl === "string" ? baseUrl.trim() : ""; if (!raw) throw err("EBADURL", "baseUrl is required"); let parsed; try { parsed = new URL(raw); } catch { throw err("EBADURL", `not a valid URL: ${raw}`); } if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { throw err("EBADURL", "baseUrl must be http:// or https://"); } return raw.replace(/\/+$/, "").replace(/\/v1$/, ""); } /** A model name is free text — a proxy names its own models — but not blank. */ function normalizeModel(model) { const clean = typeof model === "string" ? model.trim() : ""; if (!clean || clean.length > 200 || /[\u0000-\u001f\u007f]/.test(clean)) { throw err("EBADMODEL", "model must be 1-200 characters and no control characters"); } return clean; } /** * Fill every slot from `model`, keeping any the caller set explicitly. This * is what makes "one symbolic name for every tier" — the 9Router case — a * single field to type instead of six. */ function normalizeModels(model, models) { const given = models && typeof models === "object" ? models : {}; const out = {}; for (const slot of MODEL_SLOTS) { out[slot] = given[slot] === undefined ? model : normalizeModel(given[slot]); } return out; } /** * 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. * The user picks it, never AgentHub: it also sets the context window Claude * Code compacts at, and a proxy may route a name to a non-Claude model. */ function normalizeBehavesAs(value) { const clean = typeof value === "string" ? value.trim() : null; if (clean === "") return ""; if (!clean || !/^claude-[a-z0-9][a-z0-9.-]{0,99}$/.test(clean)) { throw err("EBADMODEL", "behavesAs must be a Claude model id, e.g. claude-opus-5-5"); } return clean; } function assertNotBuiltin(name) { if (name === BUILTIN_NAME) { throw err("EBUILTIN", `'${BUILTIN_NAME}' is the built-in profile and cannot be changed`); } } /** * Read one stored profile (key included), or null when it does not exist. A * base URL an older build stored with `/v1` comes back as the root. */ 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. */ function publicView(profile) { const { apiKey, ...rest } = profile; return { ...rest, scope: ownerOfName(profile.name) == null ? "shared" : "personal", hasKey: Boolean(apiKey), }; } /** The profile names saved in one owner's store, sorted. */ function namesIn(owner) { try { return fs .readdirSync(storeDir(owner)) .filter((f) => f.endsWith(".json")) .map((f) => f.slice(0, -5)) .filter((n) => NAME_RE.test(n) && n !== BUILTIN_NAME && ownerOfName(n) === owner) .sort(); } catch { return []; // nothing saved yet } } /** * The profiles `viewer` may choose: the built-in native one first, then the * shared ones, then the viewer's own personal ones (none for `null` — local * mode, or a request with no user). Nobody lists another user's personal * profiles, an admin included. * @param {number|null} [viewer] user id * @returns `{ items }` — key material is never included. */ function listProxies(viewer = null) { const items = [publicView(NATIVE)]; const owners = viewer == null ? [null] : [null, viewer]; for (const owner of owners) { for (const name of namesIn(owner)) { let stored; try { stored = readProxy(name); } catch { continue; // a corrupt profile file is skipped, not fatal for the list } if (!stored) continue; items.push(publicView({ ...stored, name, builtin: false })); } } return { items }; } /** * One profile INCLUDING its key — for the server's own writers only. No route * may return this shape; `listProxies` is what the browser sees. */ function getProxy(name) { if (name === BUILTIN_NAME) return { ...NATIVE, models: { ...NATIVE.models } }; const stored = readProxy(name); if (!stored) throw err("ENOTFOUND", `no proxy profile named '${name}'`); return { ...stored, name, builtin: false }; } /** * @param {{label:string, baseUrl:string, apiKey?:string, model:string, * models?:Record, behavesAs?:string}} input * @param {number|null} [owner] user id for a personal profile, null = shared * @returns the browser-safe view of what was saved */ function createProxy(input = {}, owner = null) { const label = normalizeLabel(input.label); const slug = slugify(label); assertNotBuiltin(slug); const model = normalizeModel(input.model); const profile = { label, baseUrl: normalizeBaseUrl(input.baseUrl), apiKey: typeof input.apiKey === "string" ? input.apiKey.trim() : "", model, models: normalizeModels(model, input.models), behavesAs: input.behavesAs === undefined ? "" : normalizeBehavesAs(input.behavesAs), savedAt: new Date().toISOString(), }; const name = freeName(slug, owner); writeJson(proxyFile(name), profile); return publicView({ ...profile, name, builtin: false }); } /** * Merge changes into a saved profile. The `name` never changes — a session's * run row stores it for a resume, and renaming would orphan that. An omitted * `apiKey` keeps the stored one: the edit form cannot show a key, so it cannot * send one back either. */ function updateProxy(name, changes = {}) { assertNotBuiltin(name); const stored = readProxy(name); if (!stored) throw err("ENOTFOUND", `no proxy profile named '${name}'`); const model = changes.model === undefined ? stored.model : normalizeModel(changes.model); const next = { ...stored, label: changes.label === undefined ? stored.label : normalizeLabel(changes.label), baseUrl: changes.baseUrl === undefined ? stored.baseUrl : normalizeBaseUrl(changes.baseUrl), apiKey: typeof changes.apiKey === "string" && changes.apiKey.trim() ? changes.apiKey.trim() : stored.apiKey, model, models: changes.model === undefined && changes.models === undefined ? stored.models : normalizeModels(model, changes.models ?? stored.models), behavesAs: changes.behavesAs === undefined ? (stored.behavesAs ?? "") : normalizeBehavesAs(changes.behavesAs), savedAt: new Date().toISOString(), }; writeJson(proxyFile(name), next); return publicView({ ...next, name, builtin: false }); } function removeProxy(name) { assertNotBuiltin(name); const file = proxyFile(name); if (!fs.existsSync(file)) throw err("ENOTFOUND", `no proxy profile named '${name}'`); fs.rmSync(file); return { removed: name }; } /** How long a connection test waits before calling the endpoint dead. */ const TEST_TIMEOUT_MS = 15_000; /** Enough of a failing proxy's own words to be actionable, not enough to flood. */ const TEST_BODY_MAX = 300; /** POST `body` to `url`; the outcome is a result, never a throw. */ async function probe(url, apiKey, headers, body, model) { let res; try { res = await fetch(url, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}`, ...headers, }, body: JSON.stringify(body), signal: AbortSignal.timeout(TEST_TIMEOUT_MS), // A redirect is an answer, never followed: fetch drops `authorization` // on a cross-origin hop but would carry `x-api-key` to the new host. redirect: "manual", }); } catch (e) { // Unreachable, DNS failure, TLS refusal, timeout — a real answer about the // endpoint, so it is a result and not a 500. // // `cause` first: node's fetch reports every transport failure as the same // useless "fetch failed", and hides the part the user can act on // (ECONNREFUSED, ENOTFOUND, a TLS error) one level down. return { ok: false, status: null, message: e.name === "TimeoutError" ? "timed out" : (e.cause && e.cause.message) || e.message, }; } const text = await res.text().catch(() => ""); return { ok: res.ok, status: res.status, 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 = { listProxies, getProxy, createProxy, updateProxy, removeProxy, testProxy, MODEL_SLOTS, BUILTIN_NAME, NATIVE, ownerOfName, };