Skip to content

fix(cli): take connect models from the server; add show, --save, Pi and Oh My Pi - #4519

Open
b1nhm1nh wants to merge 9 commits into
decolua:masterfrom
b1nhm1nh:fix/cli-connect-inherit-models
Open

b1nhm1nh wants to merge 9 commits into
decolua:masterfrom
b1nhm1nh:fix/cli-connect-inherit-models

Conversation

@b1nhm1nh

@b1nhm1nh b1nhm1nh commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Follow-up to #4497. connect wrote hardcoded model ids and ignored the models the operator had already configured on the server, so a client could end up pointed at models that server doesn't even route.

Now models always come from the server's own CLI-tools config, and flags override. Nothing is hardcoded.

This PR also adds conveniences around connect:

  • 9router show [tool…] prints each tool's current 9router config (base URL, masked key, models).
  • connect --save remembers the dashboard password for that server, so later runs skip the prompt.
  • Two more tools: pi (Pi coding agent) and omp (Oh My Pi).

Observed against a real server whose dashboard had opus=claude-opus-5-5, sonnet=gcli/grok-4.7, haiku=glm-5.3-flash[1m]:

# before — hardcoded, unrelated to the server's configuration
ANTHROPIC_DEFAULT_OPUS_MODEL=cc/claude-opus-5
ANTHROPIC_DEFAULT_SONNET_MODEL=cc/claude-sonnet-5

# after — the operator's actual choices
ANTHROPIC_DEFAULT_OPUS_MODEL=claude-opus-5-5
ANTHROPIC_DEFAULT_SONNET_MODEL=gcli/grok-4.7

Where each model comes from (first match wins)

Tool Source
claude tier flag (--opus …) → server's Claude tier → left unset
others --model → that tool's own model on the server → server's OpenCode model → tool skipped

Each tool declares its dashboard route and a serverModel(data) extractor next to its writer in connectTools.js, reading only a 9router-owned model id:

Tool Server source Only trusted when
claude each tier in /api/cli-tools/claude-settings —
codex model in the TOML model_provider = "9router"
opencode model prefixed 9router/
droid first custom:9Router* entry —
crush providers.9router.models[0].id —
cline openAiModelId provider is openai
kilo (none — its GET exposes only auth key names) —
pi providers.9router.models[0] —
omp (not needed — proxy discovery lists every server model) —

The "only trusted when" checks stop a client from copying a model that belongs to another provider (e.g. a Codex set to OpenAI's own gpt-6).

When the server has nothing for a slot

  • Claude tier: left unset, so Claude Code applies its own default. A tier written by an earlier run is removed rather than kept stale.
  • Non-Claude tool: skipped with a message, and the run exits 1. Pass --model to configure it.
  • --api-key (no login): there's no session to read the server's models, so the command says so and expects model flags.

Overriding the server's models

# One Claude tier; the others still come from the server
npx 9router connect http://<server-host>:20128 --tools claude --sonnet cc/claude-sonnet-5-5

# All non-Claude tools at once
npx 9router connect http://<server-host>:20128 --tools codex,opencode,kilo --model ocg/deepseek-flash

# Claude and other tools in one run
npx 9router connect http://<server-host>:20128 --tools claude,codex --opus cc/claude-opus-5-5 --model gpt-6

# Different model per tool: --model covers every non-Claude tool in a run, so run once per tool
npx 9router connect http://<server-host>:20128 --tools codex --model gpt-6
npx 9router connect http://<server-host>:20128 --tools opencode --model ocg/glm-5.3

New: 9router show

Prints what each CLI tool on this machine is currently set to — base URL, masked API key and models — so you can check the result of connect (or of a manual edit) without opening config files:

npx 9router show                 # every supported tool
npx 9router show claude          # one tool
npx 9router show claude --json   # machine-readable (key still masked)
✅ Claude Code
   File:     ~/.claude/settings.json
   Base URL: http://<server-host>:20128/v1
   API key:  sk-a1b…9f3c
   Models:
     fable   cc/claude-fable-5-1
     opus    cc/claude-opus-5-5
     sonnet  cc/claude-sonnet-5-5

Each tool gains a show() beside its apply/reset in connectTools.js, so the reader and writer of each format live together. Tools not pointed at 9router show as not configured; Codex, OpenCode and Cline warn when a 9router entry exists but another provider is active. A config that fails to parse is reported per tool (exit 1) while the others are still shown.

New: connect --save

After a successful login, --save writes the password to connect.env in the CLI data dir (~/.9router/ on macOS/Linux, %APPDATA%\9router\ on Windows, or $DATA_DIR), bound to that server's URL. Later runs for the same server reuse it instead of prompting.

npx 9router connect http://<server-host>:20128 --tools claude --save   # prompts once, then saves
npx 9router connect http://<server-host>:20128 --tools codex           # reuses the saved password
  • Precedence: --password / NINE_ROUTER_PASSWORD > saved > prompt.
  • A failed login with a saved password names the file to delete.
  • Opt-in only; without --save nothing is written. Delete the file to forget the password.

New: Pi and Oh My Pi

Both are set up the same way as the server's own Pi and Oh My Pi setup routes (pi-settings / omp-settings):

Tool File Model
pi (alias pi-coding-agent) ~/.pi/agent/models.json (or an existing legacy ~/.pi/models.json) --model → server's Pi model → server's OpenCode model
omp (alias oh-my-pi) ~/.omp/agent/models.yml none — discovery: { type: proxy } makes every server model appear under 9router in /model
npx 9router connect http://<server-host>:20128 --tools pi,omp
npx 9router show pi omp

Other providers and settings in those files are kept; --reset removes only the 9router entry. omp is never skipped for lack of a model. The dashboard also saves the key into omp's agent.db when it can; connect skips that, since it needs a native sqlite module the npx command doesn't ship, and models.yml is what Oh My Pi reads first. YAML is read and written with confbox, already a dependency here.

Other changes

  • Strips Claude Code's [1m] context marker before the /v1/models availability check, which was reporting a valid model as "not listed".
  • Server routes are fetched in parallel, only for the selected tools (plus OpenCode as the shared fallback). A missing, unreadable or malformed server config is treated as "nothing configured".
  • Docs: cli/README.md gains a Models section (precedence table), the override samples above, and an options table. connect --help shows the same rules with two examples.
  • Root package.json: adds enquirer, so running node cli/cli.js connect from a checkout resolves the prompt library (the published package already listed it).
  • Removed: the hardcoded model ids and --no-inherit (it only existed to choose those defaults). An intermediate commit in this PR bumped the defaults to Fable 5.1 / Opus 5.5 / Sonnet 5.5; the final commit removes them entirely.

Security notes

  • Only model ids are taken from the server responses. Those responses also contain the server's own credentials — ANTHROPIC_AUTH_TOKEN, OpenCode/Droid apiKey, Codex's Authorization header — and its loopback baseUrl (http://127.0.0.1:20128/v1). Copying either would hand this machine a credential that isn't its own and point it at the wrong host, so both are ignored: the client keeps its per-machine key from /api/keys and the remote base URL. Tests assert the server key never appears in any written file.
  • All routes used require the dashboard session that was just established; no new auth surface and no server-side changes.
  • --save stores the dashboard password in plain text. It is opt-in, bound to one server URL (a test asserts it is never offered to a different host), and written mode 600 — owner-only on macOS/Linux. On Windows the mode is a no-op: the file is protected only by the user profile's folder permissions, so anything running as that user can read it. Users who can't accept that should keep using the prompt or NINE_ROUTER_PASSWORD.
  • Pi and Oh My Pi files hold this machine's API key in plain text, like the other tools' configs: written mode 600 (a no-op on Windows) with a one-time *.bak-9router backup.
  • show is read-only and offline — it contacts no server — and never prints a full key: the masked form is applied before output, including in --json. A test asserts the full key never appears in its output.

Test plan

  • npx vitest run unit/cli-connect.test.js unit/cli-show.test.js — 37/37 pass.
  • --save: password stored mode 600 and bound to its server, reused on the next run, never offered to another host; nothing written without --save; a hand-edited unquoted value is read instead of crashing. The tests redirect both homedir() and %APPDATA% to a temp folder — an earlier version wrote into the developer's real Windows profile, fixed in eb580b2e.
  • cli-connect: Claude tiers from the server; unset tiers staying unset; a stale tier being cleared; each tool using its own server model and falling back to OpenCode's; --model overriding every tool; a tier flag beating the server; a tool with no model being skipped and exiting 1 unless --model is given; extractors ignoring other providers' models; --no-inherit rejected. Run-level cases assert the server's key never lands in a written file.
  • Pi/omp: other providers and YAML keys kept, reset removes only 9router, legacy Pi path used when it's the only one, omp configured even when the server has no models, server key never written.
  • Real LAN server, --tools pi,omp into a temp home: Pi got the server's OpenCode model, omp got proxy discovery, show pi omp matched; neither file contains the server's key or a loopback URL.
  • cli-show (7): empty home, apply→show round-trip for every tool, full key never in output, unset Claude tier not invented, Codex inactive-provider warning, --json, broken config.
  • show claude against the real ~/.claude/settings.json written by connect: base URL, masked key and all tiers shown.
  • unit/combo-presets.test.js still passes — the dashboard's src/shared/constants/cliTools.js is untouched.
  • Real LAN server, --tools claude,codex,kilo: all 4 Claude tiers taken from the server; codex and kilo (no own model there) got the server's OpenCode model.
  • Real LAN server, --tools all: scanned all 8 written files — this machine's key in each one that holds a key, the server's key in none, no loopback URL.
  • Staged diffs scanned with gitleaks: no leaks.

🤖 Generated with Claude Code

b1nhm1nh and others added 3 commits October 1, 2026 16:25
`connect` wrote hardcoded defaults (cc/claude-opus-5, …) and ignored the
model mapping the operator had already set on the server, so a client ended
up on models the server may not even route.

Read the server's own cli-tools config and reuse its model ids:
- GET /api/cli-tools/claude-settings → per-tier Claude models
- GET /api/cli-tools/opencode-settings → active model for non-Claude tools

Only model ids are taken. The server's baseUrl and apiKey in that response
belong to that host and are never copied — this machine keeps its own key
and the remote base URL. Best-effort: an unreadable/500 response falls back
to the built-in defaults.

Precedence: explicit flag > server value > built-in default, with
`--no-inherit` to skip inheriting. Also strips Claude Code's "[1m]" context
marker before the /v1/models availability check, which was warning falsely.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Non-Claude tools all took the server's OpenCode model, ignoring what the
operator configured for that specific tool on the dashboard.

Each tool now declares the dashboard route it maps to plus a
`serverModel(data)` extractor that reads only its 9router-owned model id:
- codex: `model` from the TOML, only when `model_provider = "9router"`
- opencode: `model` with the `9router/` prefix
- droid: first `custom:9Router*` entry
- crush: `providers.9router.models[0].id`
- cline: `openAiModelId`, only when the provider is `openai`
- kilo: no route — its GET exposes only auth key names, not the model

Precedence per tool: --model > that tool's own server model > the server's
OpenCode model > built-in default. Routes are fetched in parallel and only
for the selected tools (plus OpenCode as the shared fallback). A tool whose
server config is missing, unreadable or malformed just falls back.

As before, only model ids are read; the server's own apiKey / Authorization
header / base URL in those responses are never copied. Tests assert the
server key never appears in any written file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The fallback tiers still pointed at the previous generation
(cc/claude-fable-5, cc/claude-opus-5, cc/claude-sonnet-5) even though the
claude registry has shipped 5.1/5.5 ids for a while, so a server with no
cli-tools config of its own handed clients older models.

Bump the Claude fallbacks and the shared non-Claude DEFAULT_MODEL to
cc/claude-sonnet-5-5. Haiku is unchanged — 4-5-20251001 is still the
newest haiku in the registry.

Tests now read the expected value from CLAUDE_MODELS instead of
hardcoding an id, so the next bump cannot leave them stale.

Note: src/shared/constants/cliTools.js carries the same defaults for the
dashboard, but it also seeds the server-side Claude combo presets
(src/lib/comboPresets.js), so bumping it changes the `opus`/`sonnet`
alias targets. Left out of this CLI-only change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…defaults

Models now always come from the server's own cli-tools config; flags
override. There are no built-in model ids any more, so `--no-inherit`
(which only existed to pick those defaults) is removed.

When the server has nothing for a slot:
- Claude tier: left unset, so Claude Code applies its own default. A tier
  written by an earlier run is deleted rather than kept stale.
- Non-Claude tool: skipped with a message (pass --model to configure it),
  and the run exits 1 since it didn't do what was asked.

With --api-key there is no session to read the server's models, so the
command says so and expects model flags.

Docs: cli/README.md gains a Models section (precedence table), override
samples for single/all Claude tiers, all non-Claude tools, mixed runs and
per-tool runs, plus an options table. `connect --help` shows the same
rules with two examples.

Tests (23): unset tiers stay unset, a stale tier is cleared, a tool with
no model is skipped and exits 1 unless --model is given, and
`--no-inherit` is rejected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@b1nhm1nh b1nhm1nh changed the title fix(cli): inherit the server's configured models in connect fix(cli): take connect models from the server, not hardcoded defaults Oct 2, 2026
b1nhm1nh and others added 4 commits October 2, 2026 11:17
…nfig

`9router show [tool…]` reads each CLI tool's LOCAL config and prints the
base URL, the API key (masked) and the models it is set to — e.g.
`9router show claude` lists every Claude Code tier. `--json` gives the
same data machine-readably; tools can be named positionally or with
--tools, aliases included.

Each tool gains a `show()` next to its apply/reset writers in
connectTools.js, so the reader and the writer of a format stay together.
Tools not pointed at 9router are listed as "not configured", and Codex,
OpenCode and Cline warn when a 9router entry exists but another provider
is active.

Read-only and offline: it never contacts a server, and never prints a full
key — the masked form is applied before output, including in --json. A
config that fails to parse is reported per tool and the command exits 1
while still showing the others.

Docs: README "Checking the current config" section with samples; the
launcher's --help lists the command.

Tests (7): empty home, apply→show round-trip for every tool, key never in
output, unset Claude tier not invented, codex inactive warning, --json,
arg parsing, broken config.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Running `node cli/cli.js connect` from a checkout resolves modules from
the root node_modules (cli/ has none). Root package.json mirrored the
CLI's other runtime deps but not enquirer, so `connect` threw
"Cannot find module 'enquirer'". Published package unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After a successful login, --save writes the password to
~/.9router/connect.env (or $DATA_DIR/connect.env), mode 600, bound to
the server URL. Later runs for the same server reuse it instead of
prompting; it is never sent to a different host. Precedence:
--password / NINE_ROUTER_PASSWORD > saved > prompt. A failed login with
a saved password points at the file to delete.

Stored in plain text — opt-in only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…te bare values

The --save tests stubbed only DATA_DIR and homedir(), but on Windows
savedEnvPath() resolves %APPDATA%\9router, so the suite wrote a test
password into the developer's real profile and its path assertion failed.
Stub APPDATA to the temp home too, assert the path stays under it, and
make the expected path platform-aware.

loadSavedPassword() JSON-parsed every value, so a hand-edited
connect.env with an unquoted value crashed the whole command with a raw
"is not valid JSON" error. Fall back to the bare string.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@b1nhm1nh b1nhm1nh changed the title fix(cli): take connect models from the server, not hardcoded defaults fix(cli): take connect models from the server; add show and --save Oct 2, 2026
- pi: writes providers.9router to ~/.pi/agent/models.json (or an existing
  legacy ~/.pi/models.json); model from --model, the server's Pi config,
  then the server's OpenCode model.
- omp: writes providers.9router to ~/.omp/agent/models.yml with proxy
  discovery, so it needs no model id and lists every server model.
  The dashboard's best-effort agent.db mirror is skipped (needs a native
  sqlite module; models.yml is primary).
- Aliases: pi-coding-agent, oh-my-pi. Reset removes only the 9router entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@b1nhm1nh b1nhm1nh changed the title fix(cli): take connect models from the server; add show and --save fix(cli): take connect models from the server; add show, --save, Pi and Oh My Pi Oct 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant