feat(settings): add AI Router profiles and attach one per lane
A new **AI Router** tab in Settings, next to Codex, stores named router
endpoints (9Router, OpenRouter, a self-hosted gateway) as profiles: a base
URL, an API key, a default model and an optional per-tier override. The tab
only stores them; a lane CHOOSES one in the Add-lane modal, via a Native /
AI Router radio, and that choice is written into the lane's own config.
The two CLIs are configured differently, and the difference is theirs:
- a claude lane gets eight `env` keys merged into its own
`.claude/settings.local.json` (`.local` because the value holds a key);
- a codex lane gets `$CODEX_HOME/<name>.config.toml` — the layer
`codex -p <name>` loads — because Codex has NO project-level config.
Its base `config.toml` is never touched.
Native is a built-in profile rather than a code branch, so "go back to
native" is the same write path. It blanks the two connection variables
instead of removing them (an absent key at project scope lets a USER-level
router apply) and removes the model names instead of writing them (writing
them would override the user's own global model choice).
Key material never leaves the server: profiles live one file each under
`<CLAUDE_HOME>/agenthub-routers/` (0700/0600), `GET /api/ai-routers`
reports only `hasKey`, and there is deliberately no route returning one
profile in full.
Three things this had to get right, each found by review and verified
against the real tools rather than assumed:
- `wire_api = "chat"` is rejected outright by codex-cli 0.149 ("no longer
supported"), so every codex lane on a router would have failed to start.
The layer writes "responses", and writes `env_key` only when the profile
actually has a key — naming an unset variable is itself a startup error.
- The key must never be TYPED into a pane. `tmux.sendCommand` used
`send-keys` with a `KEY='…' cmd` prefix, which the dashboard streams to
every attached browser and bash appends to history; it now takes over the
idle pane with `respawn-pane -k -e`, passing env through argv the way
`newSession` always has. Verified on tmux 3.4: multi-element argv arrives
intact and the pane renders no trace of the value.
- Attaching COPIES a profile, so editing or deleting one in Settings has to
reach the lanes already using it. An edit re-applies it (a rotated key
would otherwise never reach the lane still holding the revoked one); a
delete resets those lanes to native and removes the Codex layer file.
Both report per-lane failures, which the tab surfaces.
`.claude/settings.local.json` is added to the lane's git exclude, for both
native and router lanes: `agenthub lanes reset` runs `git clean -fd` without
`-x`, so an un-excluded settings file is deleted on reset — and untracked
meant the key was committable.
DB: `lanes.router_profile`, nullable, additive. Written only by
`setRouterProfile` beside the disk write, never through `PATCHABLE`.
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
# AI Router profiles — design
|
||||
|
||||
## Goal
|
||||
|
||||
Configure named AI Router endpoints (9Router, OpenRouter, …) once in Settings,
|
||||
then pick one when creating a lane so that lane's Claude Code or Codex session
|
||||
talks to the router instead of the vendor's own API — without hand-editing a
|
||||
settings file per project, and without a second copy of the whole settings file
|
||||
drifting out of sync with the first.
|
||||
|
||||
## What the reference setup showed
|
||||
|
||||
The user today keeps two whole files, `~/.claude/settings.json` (native) and
|
||||
`~/.claude/settings2.json` (9Router), and swaps them. Diffing the two:
|
||||
|
||||
- They differ in exactly one place that matters — the `env` block, 8 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`.
|
||||
- Every other difference (`hooks`, `enabledPlugins`, `statusLine`,
|
||||
`effortLevel`, …) is drift between two hand-maintained copies. Removing that
|
||||
drift is half the point of this feature.
|
||||
- "Native" is **not** the absence of the connection variables: the native file
|
||||
sets `ANTHROPIC_BASE_URL` and `ANTHROPIC_AUTH_TOKEN` to empty strings and
|
||||
carries real `claude-*` model names.
|
||||
- The 9Router file sets all six model slots to one symbolic value (`Highest`),
|
||||
which the router resolves. Model values are therefore free text, and "one
|
||||
value for every tier" is the common case, not the exception.
|
||||
|
||||
## Decisions (from brainstorming)
|
||||
|
||||
1. **The Settings tab only stores profiles.** It does not attach anything.
|
||||
Attaching happens when a lane is created.
|
||||
2. **Native is a built-in profile, not a code branch.** Its `baseUrl`/`apiKey`
|
||||
are empty and its six model slots hold the native `claude-*` names. It cannot
|
||||
be edited or deleted. Switching native ↔ router is therefore one write path
|
||||
with no special case.
|
||||
3. **A profile carries six model slots plus an "apply to all" convenience
|
||||
field**, mirroring exactly the env keys Claude Code reads.
|
||||
4. **The API key never leaves the server.** Profiles are stored one JSON file
|
||||
each under `<CLAUDE_HOME>/agenthub-routers/` (dir `0700`, file `0600`),
|
||||
following `cc-profiles.js`. `GET` returns metadata plus `hasKey: true`,
|
||||
never the key.
|
||||
5. **A lane's router is fixed at creation**, like `lanes.tool`: a new nullable
|
||||
`lanes.router_profile` column, deliberately absent from `PATCHABLE`.
|
||||
6. **AgentHub never reads `~/.9router/db.json`.** That is another application's
|
||||
private store; parsing it would couple this dashboard to a format its owner
|
||||
is free to change. The user pastes a base URL and a key, as they do today.
|
||||
|
||||
## Attaching, per tool
|
||||
|
||||
The two tools differ because the tools themselves differ, not by choice.
|
||||
|
||||
**Claude lane** — write the profile's eight env keys into
|
||||
`<lane.cwd>/.claude/settings.local.json`, merging into any `env` already there
|
||||
and leaving every other key in the file untouched. `settings.local.json` rather
|
||||
than `settings.json` because the value written includes an API key and that file
|
||||
is the one Claude Code's own convention keeps out of git.
|
||||
|
||||
**Codex lane** — Codex has no project-level config: `codex doctor` reports one
|
||||
loaded file, `~/.codex/config.toml`. It does have
|
||||
`-p, --profile <name>`, documented as *"Layer `$CODEX_HOME/<name>.config.toml`
|
||||
on top of the base user config"*. So each router profile is written once to
|
||||
`~/.codex/<name>.config.toml` (`model_provider` plus a `[model_providers.<id>]`
|
||||
table with `base_url`, `env_key`, `wire_api`), and the lane's spawn adds
|
||||
`-p <name>`. The base `~/.codex/config.toml` is never modified. Codex reads the
|
||||
key from the environment variable named by `env_key`, so the spawn also injects
|
||||
that variable into the tmux pane — `pty-run.js` already passes an `env` map to
|
||||
`tmux new-session -e`.
|
||||
|
||||
## Surface
|
||||
|
||||
- `server/lib/ai-routers.js` — store: list/get/create/update/remove, name
|
||||
validation, `0700`/`0600`, key never returned by the list shape.
|
||||
- `server/lib/router-apply.js` — the two writers above, taking a lane row and a
|
||||
profile. Target directories come from the lane row in the DB, never from a
|
||||
request body — the same narrowing every other lane write path uses.
|
||||
- `server/routes/ai-routers.js` → `/api/ai-routers`: `GET /`, `POST /`,
|
||||
`PATCH /:name`, `DELETE /:name`, reusing `cc-profiles.js`'s error-code→status
|
||||
table.
|
||||
- `POST /api/lanes/:id/router/apply` — a fourth member of the auto-setup set the
|
||||
Add-lane modal already fires (`profileInit`, `agentsInstall`, `mcpSync`).
|
||||
- `client/src/pages/AiRouterConfig.tsx` + one line in `SettingsHub.tsx`'s
|
||||
`TOOL_TABS`, placed after Codex.
|
||||
- `AddLaneModal`: a Native / AI Router radio, and a profile select shown only
|
||||
when AI Router is chosen.
|
||||
- DB: `ALTER TABLE lanes ADD COLUMN router_profile TEXT`, nullable.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Changing an existing lane's router (create-time only, exactly like `tool`),
|
||||
attaching at user/global scope, editing Codex's base `config.toml`, and reading
|
||||
any router vendor's own configuration store.
|
||||
|
||||
## Verification
|
||||
|
||||
Server tests against a temporary `CLAUDE_HOME`/`CODEX_HOME`: store CRUD and its
|
||||
permission bits, the key never appearing in a list response, the
|
||||
`settings.local.json` merge preserving unrelated keys, and the generated
|
||||
`<name>.config.toml`. Client tests: the tab renders and creates a profile, and
|
||||
the Add-lane radio sends the chosen profile only when AI Router is selected.
|
||||
|
||||
The load-bearing assumption — a project-level `settings.local.json` `env` block
|
||||
overrides the user-level one per key — is confirmed by the user from their own
|
||||
earlier testing.
|
||||
Reference in New Issue
Block a user