Merge pull request #41672 from BerriAI/litellm_autoroute_start_stop

feat(cli): rename lite autoroute up/down to start/stop, keeping the old names as deprecated aliases
This commit is contained in:
Mateo Wang 2026-09-17 16:07:00 -07:00 committed by GitHub
commit 0da001901b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 189 additions and 95 deletions

View file

@ -569,15 +569,15 @@ lite --base-url https://your-proxy.example.com configure claude --api-key sk-...
claude
```
The key comes from `--api-key` (or `lite --api-key` / `LITELLM_PROXY_API_KEY`) and is written into `env.ANTHROPIC_AUTH_TOKEN`; without one the command refuses, since a `lite login` credential expires within a day and keeping it fresh would mean Claude Code running `lite` through `apiKeyHelper` on every credential refresh. The command checks the key against `GET /v1/models`, then patches `~/.claude/settings.json`: `env.ANTHROPIC_BASE_URL`, the credential, and `env.ENABLE_TOOL_SEARCH` and `env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY` when those are missing, so Claude Code's `/model` picker lists the proxy's models (under `claude-router-<UTF-8 hex of the group name>` for a group whose id contains neither `claude` nor `anthropic`, since Claude Code lists only those) and you pick between them as usual. Claude Code keeps its own default model until you switch, so that id has to exist on the proxy for the first message to go through; `--model` (or the interactive prompt below) sets the model Claude Code starts on instead, as the top-level `model` key and as `env.ANTHROPIC_MODEL`, both of which have to be on `/v1/models` for the key. The second one matters for `claude -c` and `claude --resume`: a resumed session otherwise re-sends the model its transcript recorded, which behind an auto-router with `return_raw_model_name: true` is the tier model that answered, and a key scoped to the router alias gets a 403 for it; `ANTHROPIC_MODEL` outranks the transcript on resume. Nothing forces Claude Code's sub-agent or background tiers onto a proxy model, so those built-in ids need to exist on the proxy too; `lite autoroute up` is the mode that pins every tier to one group. Claude Code treats a name it does not know as an unknown model: it prints a one-line `unrecognized_model` note, assumes a 200k context window (the proxy appends `[1m]` for a group whose configured or known input window reaches 1M) and sends no thinking parameters for it, so name the group like a Claude model id to change that. The other credential slots (`env.ANTHROPIC_API_KEY`, a stale `env.ANTHROPIC_AUTH_TOKEN` or `apiKeyHelper`) are removed so they cannot fight the one written. Every other setting is preserved and the file is written atomically with owner-only permissions; if `settings.json` is a symlink into a dotfiles repository, the key is written through to that target and the command says so, so keep it out of version control
The key comes from `--api-key` (or `lite --api-key` / `LITELLM_PROXY_API_KEY`) and is written into `env.ANTHROPIC_AUTH_TOKEN`; without one the command refuses, since a `lite login` credential expires within a day and keeping it fresh would mean Claude Code running `lite` through `apiKeyHelper` on every credential refresh. The command checks the key against `GET /v1/models`, then patches `~/.claude/settings.json`: `env.ANTHROPIC_BASE_URL`, the credential, and `env.ENABLE_TOOL_SEARCH` and `env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY` when those are missing, so Claude Code's `/model` picker lists the proxy's models (under `claude-router-<UTF-8 hex of the group name>` for a group whose id contains neither `claude` nor `anthropic`, since Claude Code lists only those) and you pick between them as usual. Claude Code keeps its own default model until you switch, so that id has to exist on the proxy for the first message to go through; `--model` (or the interactive prompt below) sets the model Claude Code starts on instead, as the top-level `model` key and as `env.ANTHROPIC_MODEL`, both of which have to be on `/v1/models` for the key. The second one matters for `claude -c` and `claude --resume`: a resumed session otherwise re-sends the model its transcript recorded, which behind an auto-router with `return_raw_model_name: true` is the tier model that answered, and a key scoped to the router alias gets a 403 for it; `ANTHROPIC_MODEL` outranks the transcript on resume. Nothing forces Claude Code's sub-agent or background tiers onto a proxy model, so those built-in ids need to exist on the proxy too; `lite autoroute start` is the mode that pins every tier to one group. Claude Code treats a name it does not know as an unknown model: it prints a one-line `unrecognized_model` note, assumes a 200k context window (the proxy appends `[1m]` for a group whose configured or known input window reaches 1M) and sends no thinking parameters for it, so name the group like a Claude model id to change that. The other credential slots (`env.ANTHROPIC_API_KEY`, a stale `env.ANTHROPIC_AUTH_TOKEN` or `apiKeyHelper`) are removed so they cannot fight the one written. Every other setting is preserved and the file is written atomically with owner-only permissions; if `settings.json` is a symlink into a dotfiles repository, the key is written through to that target and the command says so, so keep it out of version control
Plain `lite configure`, with no agent named, asks which agents to wire and which gateway model each starts on, picked from `/v1/models` with a type-to-filter prompt. All choices and selected config files are checked before the first settings write. If a later filesystem write fails, the output identifies each agent already configured and its undo command
What the command changed is recorded in `~/.litellm/claude_configure_state.json` (previous values plus fingerprints of what was written, never a second copy of the key). `lite unconfigure claude` restores each of those keys only if it still holds what `configure` wrote, so anything you changed since is left alone and named in the output; a `settings.json` or `env` object that only existed because of `configure` is removed again. Ownership moves only by a write: running `configure` again (a re-login is one) refreshes the record only for the keys its merge changed, keeps the original snapshot of a key that still holds what it wrote, and snapshots afresh a key you changed in between, so `unconfigure` brings back whatever the repeat displaced and never adopts your edit as its own. A credential (`env.ANTHROPIC_API_KEY`, `env.ANTHROPIC_AUTH_TOKEN`, `apiKeyHelper`) is put back only when the restored file points at the `ANTHROPIC_BASE_URL` it was captured next to; otherwise it stays removed, the output says which server it belonged to, and the receipt is kept so pointing the URL back and running `unconfigure` again finishes the job. It also undoes `lite login --config-claude`, which writes through the same path. Both refuse to run while a `lite up` or `lite autoroute up` session holds a backup, and that check comes before any request
What the command changed is recorded in `~/.litellm/claude_configure_state.json` (previous values plus fingerprints of what was written, never a second copy of the key). `lite unconfigure claude` restores each of those keys only if it still holds what `configure` wrote, so anything you changed since is left alone and named in the output; a `settings.json` or `env` object that only existed because of `configure` is removed again. Ownership moves only by a write: running `configure` again (a re-login is one) refreshes the record only for the keys its merge changed, keeps the original snapshot of a key that still holds what it wrote, and snapshots afresh a key you changed in between, so `unconfigure` brings back whatever the repeat displaced and never adopts your edit as its own. A credential (`env.ANTHROPIC_API_KEY`, `env.ANTHROPIC_AUTH_TOKEN`, `apiKeyHelper`) is put back only when the restored file points at the `ANTHROPIC_BASE_URL` it was captured next to; otherwise it stays removed, the output says which server it belonged to, and the receipt is kept so pointing the URL back and running `unconfigure` again finishes the job. It also undoes `lite login --config-claude`, which writes through the same path. Both refuse to run while a `lite up` or `lite autoroute start` session holds a backup, and that check comes before any request
#### Routed model and savings in the status line
`lite configure claude`, `lite login --config-claude`, `lite up` and `lite autoroute up` also install a status line (`~/.litellm/statusline.py`, registered as `statusLine` in `~/.claude/settings.json` unless you already run one) that shows which model the auto-router actually served the last turn and, once the proxy has recorded the session, what the session cost against the router's savings baseline:
`lite configure claude`, `lite login --config-claude`, `lite up` and `lite autoroute start` also install a status line (`~/.litellm/statusline.py`, registered as `statusLine` in `~/.claude/settings.json` unless you already run one) that shows which model the auto-router actually served the last turn and, once the proxy has recorded the session, what the session cost against the router's savings baseline:
```
Routed to: claude-haiku-4-5 -63% vs Claude Opus 5
@ -597,7 +597,7 @@ After upgrading the CLI, rerun your original `lite configure claude` command wit
#### Install the CLI
`lite autoroute up` builds and runs a throwaway litellm proxy locally, so unlike the rest of this CLI it needs the proxy server runtime, not just the thin `litellm[cli]` client. Install `litellm[proxy]` (which ships the `lite` command too) with a single curl command -- no existing Python tooling required, `uv` is bootstrapped automatically if missing:
`lite autoroute start` builds and runs a throwaway litellm proxy locally, so unlike the rest of this CLI it needs the proxy server runtime, not just the thin `litellm[cli]` client. Install `litellm[proxy]` (which ships the `lite` command too) with a single curl command -- no existing Python tooling required, `uv` is bootstrapped automatically if missing:
```bash
curl -fsSL https://raw.githubusercontent.com/BerriAI/litellm/main/scripts/install.sh | sh
@ -610,7 +610,7 @@ curl -fsSL https://raw.githubusercontent.com/BerriAI/litellm/<branch-or-commit>/
LITELLM_CLI_REF=<branch-or-commit> sh
```
The thin `scripts/install-cli.sh` installs only `litellm[cli]`, which is enough for `lite login`, `lite claude`, and `lite up`, but not for `lite autoroute up`; running it against a `litellm[cli]` install fails fast with a message telling you to install the proxy runtime.
The thin `scripts/install-cli.sh` installs only `litellm[cli]`, which is enough for `lite login`, `lite claude`, and `lite up`, but not for `lite autoroute start`; running it against a `litellm[cli]` install fails fast with a message telling you to install the proxy runtime.
Point the CLI at your real proxy and key before running any `lite model-groups` or `lite autoroute` command -- like every other command in this CLI, they read `LITELLM_PROXY_URL`/`LITELLM_PROXY_API_KEY` (or `--base-url`/`--api-key`), no `lite login` required:
@ -637,44 +637,46 @@ An interactive wizard. It runs the same model-group discovery as above, splits t
The wizard writes the result to `~/.litellm/autorouter/config.yaml` with `0600` permissions, since the file embeds your real proxy API key. Every model referenced anywhere in that config -- tier targets, the classifier model, the embedding model -- becomes its own `litellm_proxy/<model-name>` deployment whose `api_base` and `api_key` point back at your real proxy. That is the trick that keeps your real proxy's config untouched: every actual network call this generates, whether it is the routed completion, an LLM-classifier call, or an embedding call, forwards transparently through your real, already-running proxy with your real key.
You do not need to tell Claude Code to request `autorouter` by name yourself: `lite autoroute up` also sets the top-level `model` and `ANTHROPIC_DEFAULT_SONNET_MODEL`, `ANTHROPIC_DEFAULT_HAIKU_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL` and `ANTHROPIC_DEFAULT_FABLE_MODEL` to `autorouter` in `~/.claude/settings.json` (and `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY` to `1` when missing, like every other wiring), so every one of Claude Code's own model tiers requests it directly regardless of `/model` or whatever it defaults to otherwise. (A bare `model_name: "*"` deployment looks like the obvious way to catch any request instead, but litellm's Router looks up auto-router deployments by the literal requested model string with no wildcard resolution, so a `"*"` entry would never actually match real traffic -- these env var overrides are what makes it work.)
You do not need to tell Claude Code to request `autorouter` by name yourself: `lite autoroute start` also sets the top-level `model` and `ANTHROPIC_DEFAULT_SONNET_MODEL`, `ANTHROPIC_DEFAULT_HAIKU_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL` and `ANTHROPIC_DEFAULT_FABLE_MODEL` to `autorouter` in `~/.claude/settings.json` (and `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY` to `1` when missing, like every other wiring), so every one of Claude Code's own model tiers requests it directly regardless of `/model` or whatever it defaults to otherwise. (A bare `model_name: "*"` deployment looks like the obvious way to catch any request instead, but litellm's Router looks up auto-router deployments by the literal requested model string with no wildcard resolution, so a `"*"` entry would never actually match real traffic -- these env var overrides are what makes it work.)
You must run `configure` at least once before `up`; running `up` first fails with a clear error telling you to configure first.
You must run `configure` at least once before `start`; running `start` first fails with a clear error telling you to configure first.
#### Launch the Ephemeral Auto-Router Proxy
```bash
lite autoroute up
lite autoroute start
```
Starts a local, throwaway litellm proxy on `127.0.0.1:5483` (override with `--port`), running the config `configure` generated, with a self-issued API key baked in (your real proxy key never leaves the generated config -- it only appears there, forwarding to your real proxy). Both the port and the key are stable across runs: the key is minted once, persisted inside the generated config, and reused by every later `up` (and carried forward when you re-run `configure`), so anything you configured against one session keeps working in the next. If the port is already taken, `up` refuses with a clear error instead of silently moving to another one. It waits for the ephemeral proxy to report healthy, then patches `~/.claude/settings.json` the same way `lite up` does, except with a static `ANTHROPIC_AUTH_TOKEN` env var instead of an `apiKeyHelper`, since this key is self-issued rather than something needing SSO refresh. Any `claude` session started afterward, from any terminal, routes through the ephemeral proxy.
Starts a local, throwaway litellm proxy on `127.0.0.1:5483` (override with `--port`), running the config `configure` generated, with a self-issued API key baked in (your real proxy key never leaves the generated config -- it only appears there, forwarding to your real proxy). Both the port and the key are stable across runs: the key is minted once, persisted inside the generated config, and reused by every later `start` (and carried forward when you re-run `configure`), so anything you configured against one session keeps working in the next. If the port is already taken, `start` refuses with a clear error instead of silently moving to another one. It waits for the ephemeral proxy to report healthy, then patches `~/.claude/settings.json` the same way `lite up` does, except with a static `ANTHROPIC_AUTH_TOKEN` env var instead of an `apiKeyHelper`, since this key is self-issued rather than something needing SSO refresh. Any `claude` session started afterward, from any terminal, routes through the ephemeral proxy.
`lite autoroute up` runs in the foreground and streams the ephemeral proxy's own log file into your terminal, so you can watch its routing decisions -- which tier and model got picked for each request -- as you use Claude Code normally. Press Ctrl-C (or send SIGTERM) to stop it; this kills the child proxy process and restores your original Claude Code settings, in that order.
`lite autoroute start` runs in the foreground and streams the ephemeral proxy's own log file into your terminal, so you can watch its routing decisions -- which tier and model got picked for each request -- as you use Claude Code normally. Press Ctrl-C (or send SIGTERM) to stop it; this kills the child proxy process and restores your original Claude Code settings, in that order.
#### Recover From an Unclean Shutdown
```bash
lite autoroute down
lite autoroute stop
```
If the `lite autoroute up` process dies uncleanly -- `kill -9`, a crash -- rather than being stopped with Ctrl-C, `down` is the manual recovery path: it kills any leftover ephemeral proxy process found via a recorded pid file and restores Claude Code's settings from whatever backup is on disk.
If the `lite autoroute start` process dies uncleanly -- `kill -9`, a crash -- rather than being stopped with Ctrl-C, `stop` is the manual recovery path: it kills any leftover ephemeral proxy process found via a recorded pid file and restores Claude Code's settings from whatever backup is on disk.
#### Example
```bash
lite autoroute configure
lite autoroute up
lite autoroute start
# use Claude Code as normal in another terminal; routing decisions stream live
lite autoroute down # only needed if `up` was killed uncleanly instead of Ctrl-C'd
lite autoroute stop # only needed if `start` was killed uncleanly instead of Ctrl-C'd
```
The previous names, `lite autoroute up` and `lite autoroute down`, still work as hidden aliases of `start` and `stop`: each prints a deprecation notice on stderr and will be removed in a future release
#### Caveats
Adaptive mode's learned state does not persist across `lite autoroute up` sessions -- there is no local database, so every session starts adaptive selection cold. A Claude Code session already running before `up` started, or still running when it stops, keeps whatever settings it loaded at its own startup; like `lite up`, this is a one-time file patch and restore, not a live traffic interceptor. Only Claude Code is supported, for the same reason as `lite up`: no other supported agent (for example Cursor) has an equivalent hot-patchable config file.
Adaptive mode's learned state does not persist across `lite autoroute start` sessions -- there is no local database, so every session starts adaptive selection cold. A Claude Code session already running before `start` ran, or still running when it stops, keeps whatever settings it loaded at its own startup; like `lite up`, this is a one-time file patch and restore, not a live traffic interceptor. Only Claude Code is supported, for the same reason as `lite up`: no other supported agent (for example Cursor) has an equivalent hot-patchable config file.
A session that outlives `up` (or is still running the moment you stop it) keeps sending requests, master key included, to that now-freed loopback port until you restart it. Once the ephemeral proxy process exits, nothing stops another local account on the same machine from binding that same port and receiving those requests instead -- and since the port is a fixed, predictable default and the master key is a static value that persists across sessions (unlike `lite up`'s `apiKeyHelper`, which is re-resolved per request), whoever receives them gets a live-looking token along with the prompt content. Restart any Claude Code session before you consider the machine clean, run `lite autoroute down` promptly rather than leaving a stopped session's settings patched, and do not run `lite autoroute up` on a shared or multi-tenant host. To rotate the persisted key, delete the `master_key` line from `~/.litellm/autorouter/config.yaml`; the next `up` mints a fresh one (deleting the whole file works too, but then `configure` must be re-run first).
A session that outlives `start` (or is still running the moment you stop it) keeps sending requests, master key included, to that now-freed loopback port until you restart it. Once the ephemeral proxy process exits, nothing stops another local account on the same machine from binding that same port and receiving those requests instead -- and since the port is a fixed, predictable default and the master key is a static value that persists across sessions (unlike `lite up`'s `apiKeyHelper`, which is re-resolved per request), whoever receives them gets a live-looking token along with the prompt content. Restart any Claude Code session before you consider the machine clean, run `lite autoroute stop` promptly rather than leaving a stopped session's settings patched, and do not run `lite autoroute start` on a shared or multi-tenant host. To rotate the persisted key, delete the `master_key` line from `~/.litellm/autorouter/config.yaml`; the next `start` mints a fresh one (deleting the whole file works too, but then `configure` must be re-run first).
Do not run `lite up` and `lite autoroute up` at the same time. Each patches `~/.claude/settings.json` and keeps its own separate backup, with no coordination between them: whichever one you stop or crash out of last is the one whose backup gets restored, which can silently leave the *other* mode's settings (a static master key and a now-dead loopback URL, or a stale `apiKeyHelper`) active. Run `lite down` or `lite autoroute down` (whichever applies) before switching to the other mode.
Do not run `lite up` and `lite autoroute start` at the same time. Each patches `~/.claude/settings.json` and keeps its own separate backup, with no coordination between them: whichever one you stop or crash out of last is the one whose backup gets restored, which can silently leave the *other* mode's settings (a static master key and a now-dead loopback URL, or a stale `apiKeyHelper`) active. Run `lite down` or `lite autoroute stop` (whichever applies) before switching to the other mode.
## Environment Variables

View file

@ -51,7 +51,7 @@ def _ensure_master_key() -> str:
The generated config is the single home of the key: the proxy server authenticates against
general_settings.master_key only (a key under litellm_settings is silently ignored, which
would leave the ephemeral proxy with no real auth), and the file is written 0600 via
secure_create. Reusing that persisted value keeps the key stable across `up` runs, so a
secure_create. Reusing that persisted value keeps the key stable across `start` runs, so a
client configured against one session keeps working in the next.
"""
with open(CONFIG_PATH, "r") as f:
@ -88,15 +88,18 @@ def configure(ctx: click.Context) -> None:
run_configure_wizard(ctx)
@autoroute_group.command("up")
@click.option(
_PORT_OPTION: Final = click.option(
"--port",
type=click.IntRange(1, 65535),
default=DEFAULT_AUTOROUTE_PORT,
show_default=True,
help="Loopback port for the ephemeral proxy; stable across runs so configured clients keep working.",
)
def up(port: int) -> None:
@autoroute_group.command("start")
@_PORT_OPTION
def start(port: int) -> None:
"""Launch the ephemeral auto-router proxy and route Claude Code through it"""
if not CONFIG_PATH.exists():
raise click.ClickException("No config found. Run `lite autoroute configure` first.")
@ -104,7 +107,7 @@ def up(port: int) -> None:
missing: Final = missing_proxy_runtime_modules()
if missing:
raise click.ClickException(
"lite autoroute up launches a local litellm proxy, which needs the proxy runtime that the "
"lite autoroute start launches a local litellm proxy, which needs the proxy runtime that the "
f"thin `litellm[cli]` install does not include (missing: {', '.join(missing)}). Install the "
"proxy runtime with `uv tool install --force 'litellm[proxy]'`, or to QA a branch, "
"`curl -fsSL https://raw.githubusercontent.com/BerriAI/litellm/<branch>/scripts/install.sh | "
@ -117,14 +120,14 @@ def up(port: int) -> None:
raise click.ClickException(str(e))
if existing_pid is not None and is_running(existing_pid.pid):
raise click.ClickException(
"An ephemeral proxy is already running (lite autoroute up looks already active). "
"Run `lite autoroute down` first."
"An ephemeral proxy is already running (lite autoroute start looks already active). "
"Run `lite autoroute stop` first."
)
if AUTOROUTE_BACKUP_PATH.exists():
raise click.ClickException(
f"{AUTOROUTE_BACKUP_PATH} already exists -- `lite autoroute up` looks like it's already "
"running (or crashed without cleanup). Run `lite autoroute down` first."
f"{AUTOROUTE_BACKUP_PATH} already exists -- `lite autoroute start` looks like it's already "
"running (or crashed without cleanup). Run `lite autoroute stop` first."
)
if port == 4000:
@ -135,8 +138,8 @@ def up(port: int) -> None:
if not is_port_available(port):
raise click.ClickException(
f"Port {port} on 127.0.0.1 is already in use. If a previous `lite autoroute up` is still "
"running or crashed, run `lite autoroute down`; otherwise pick a different port with --port."
f"Port {port} on 127.0.0.1 is already in use. If a previous `lite autoroute start` is still "
"running or crashed, run `lite autoroute stop`; otherwise pick a different port with --port."
)
master_key: Final = _ensure_master_key()
@ -196,7 +199,7 @@ def up(port: int) -> None:
click.echo("\nStopped ephemeral proxy and restored Claude Code settings.")
click.echo(
f"Restart any Claude Code session still open from this session, or another local account could "
f"bind the now-free port {port} and receive its requests. Do not use `lite autoroute up` on a "
f"bind the now-free port {port} and receive its requests. Do not use `lite autoroute start` on a "
f"shared or multi-tenant host."
)
@ -214,13 +217,13 @@ def up(port: int) -> None:
_teardown()
@autoroute_group.command("down")
def down() -> None:
@autoroute_group.command("stop")
def stop() -> None:
"""Restore Claude Code settings and stop a leftover ephemeral proxy, if any"""
try:
record: PidRecord | None = read_pid_record()
except ClaudeSettingsError as e:
# down is the crash-recovery path -- a corrupt pid record must not block it; clear the
# stop is the crash-recovery path -- a corrupt pid record must not block it; clear the
# unusable record and keep going rather than leaving the user with no way to clean up.
click.echo(f"{e} Clearing it and continuing cleanup.", err=True)
record = None
@ -238,7 +241,34 @@ def down() -> None:
elif restored.existed:
click.echo(f"Restored {CLAUDE_SETTINGS_PATH} to its original contents.")
else:
click.echo(f"Removed {CLAUDE_SETTINGS_PATH} (it did not exist before `lite autoroute up`).")
click.echo(f"Removed {CLAUDE_SETTINGS_PATH} (it did not exist before `lite autoroute start`).")
AUTOROUTE_ALIAS_DEPRECATION_NOTICE: Final = (
"`lite autoroute {retired}` is deprecated and will be removed in a future release; "
"run `lite autoroute {current}` instead, it takes the same options."
)
def _warn_deprecated_alias(retired: str, current: str) -> None:
click.secho(AUTOROUTE_ALIAS_DEPRECATION_NOTICE.format(retired=retired, current=current), err=True, fg="yellow")
@autoroute_group.command("up", hidden=True)
@_PORT_OPTION
@click.pass_context
def up(ctx: click.Context, port: int) -> None:
"""Deprecated alias of `lite autoroute start`"""
_warn_deprecated_alias("up", "start")
ctx.invoke(start, port=port)
@autoroute_group.command("down", hidden=True)
@click.pass_context
def down(ctx: click.Context) -> None:
"""Deprecated alias of `lite autoroute stop`"""
_warn_deprecated_alias("down", "stop")
ctx.invoke(stop)
__all__ = ["autoroute_group"]

View file

@ -214,7 +214,7 @@ def build_generated_proxy_config(config: AutorouteConfig, master_key: str) -> di
def master_key_from_config(config: dict[str, JsonValue]) -> str | None:
"""The master key persisted in a generated config, or None when absent or blank.
Single definition of "this config already has a usable key", shared by `up` (reuse
Single definition of "this config already has a usable key", shared by `start` (reuse
instead of minting) and the configure wizard (carry the key forward on rewrite) so the
two sites can never disagree on what counts as one. Returned verbatim, never stripped:
the proxy authenticates against the exact bytes under general_settings.master_key, so a

View file

@ -43,12 +43,12 @@ _PROXY_RUNTIME_MODULES: tuple[str, ...] = ("fastapi", "uvicorn", "backoff", "orj
def missing_proxy_runtime_modules() -> tuple[str, ...]:
"""Proxy-server modules that ``lite autoroute up`` needs but the thin CLI install lacks.
"""Proxy-server modules that ``lite autoroute start`` needs but the thin CLI install lacks.
``launch_proxy`` runs the full ``litellm.proxy.proxy_cli`` server, whose dependencies live in
the ``proxy`` extra, not the ``cli`` extra that installs the ``lite`` command. On a thin
``litellm[cli]`` install the subprocess dies with a bare ``ModuleNotFoundError``; detecting the
gap here lets ``up`` fail with an actionable message instead.
gap here lets ``start`` fail with an actionable message instead.
"""
return tuple(name for name in _PROXY_RUNTIME_MODULES if importlib.util.find_spec(name) is None)

View file

@ -94,7 +94,7 @@ def _load_persisted_master_key(config_path: Path) -> str | None:
"""The master key from an existing generated config, so a rewrite carries it forward.
Lenient on a missing or corrupt file: configure is the regeneration path, so it must
succeed from any prior state; a key that cannot be read is simply not carried and `up`
succeed from any prior state; a key that cannot be read is simply not carried and `start`
mints a fresh one.
"""
if not config_path.exists():

View file

@ -1,6 +1,6 @@
"""Shared handling of Claude Code's ~/.claude/settings.json.
`lite up` and `lite autoroute up` patch this file temporarily and restore it on
`lite up` and `lite autoroute start` patch this file temporarily and restore it on
exit; `lite configure claude` patches it persistently and records how to undo it.
All of them need the same merge, and `up` already imports from `auth`, so the
shared parts live here rather than in any one command module. The credential is
@ -88,7 +88,7 @@ class SettingsFileOwner:
SETTINGS_FILE_OWNERS: Final = (
SettingsFileOwner(BACKUP_PATH, "lite up", "lite down"),
SettingsFileOwner(AUTOROUTE_BACKUP_PATH, "lite autoroute up", "lite autoroute down"),
SettingsFileOwner(AUTOROUTE_BACKUP_PATH, "lite autoroute start", "lite autoroute stop"),
)
_SETTINGS_ADAPTER: Final = TypeAdapter(dict[str, JsonValue])
@ -111,7 +111,7 @@ def _is_default_settings_file(settings_path: Path) -> bool:
def settings_file_owners(settings_path: Path) -> tuple[SettingsFileOwner, ...]:
"""The commands whose backups guard settings_path: `lite up` and `lite autoroute up` only ever manage the default file."""
"""The commands whose backups guard settings_path: `lite up` and `lite autoroute start` only ever manage the default file."""
return SETTINGS_FILE_OWNERS if _is_default_settings_file(settings_path) else ()
@ -240,7 +240,7 @@ def _env_object(settings: Mapping[str, JsonValue], path: Path) -> Mapping[str, J
def refuse_while_owned(settings_path: Path, owners: Sequence[SettingsFileOwner]) -> None:
"""Refuse while `lite up` or `lite autoroute up` holds a backup it will restore over any write; a
"""Refuse while `lite up` or `lite autoroute start` holds a backup it will restore over any write; a
purely local check, so commands run it before any login prompt or request."""
for owner in owners:
if owner.backup_path.exists():
@ -262,7 +262,7 @@ def _write_target(settings_path: Path) -> Path:
def write_claude_settings(settings_path: Path, settings: Mapping[str, JsonValue]) -> None:
"""The one way a settings document lands on disk: staged owner-only beside the target and renamed into
place, through a symlink rather than over it. Every writer (`configure`, `up`, `autoroute up` and the
place, through a symlink rather than over it. Every writer (`configure`, `up`, `autoroute start` and the
restores) may be carrying the credential, so none creates the file under the umask or truncates it."""
target: Final = _write_target(settings_path)
try:
@ -341,7 +341,7 @@ def merge_claude_settings(
an apiKeyHelper) are removed, since Claude Code given two credentials may send the wrong one.
ENABLE_TOOL_SEARCH and CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY get their defaults only when
missing. `default_model` is the top-level `model` and env.ANTHROPIC_MODEL (see StartOn);
`tier_model` is `lite autoroute up`'s knob that points every ANTHROPIC_DEFAULT_*_MODEL at one
`tier_model` is `lite autoroute start`'s knob that points every ANTHROPIC_DEFAULT_*_MODEL at one
group. Apart from those tier keys, exactly OWNED_PATHS are touched.
"""
raw_env: Final = settings.get(ENV_KEY, {})

View file

@ -56,7 +56,7 @@ _CLAUDE_CODE_VIEW: Final = MappingProxyType(
_MODEL_OPTION_HELP: Final = (
f"Proxy model to set as {STARTING_MODEL_ROLE}. Must be listed on /v1/models for the key; without it, "
"Claude Code keeps its own default and a pin an earlier configure made is let go of. Nothing pins Claude "
"Code's sub-agent or background tiers; `lite autoroute up` is the mode that does."
"Code's sub-agent or background tiers; `lite autoroute start` is the mode that does."
)

View file

@ -3,13 +3,14 @@ import socket
import stat
from typing import Optional
import pytest
import yaml
from click.testing import CliRunner
from litellm.proxy.client.cli.commands.claude_settings import ClaudeSettingsError
from litellm.proxy.client.cli.commands.autoroute import commands as commands_module
from litellm.proxy.client.cli.commands.autoroute import process as process_module
from litellm.proxy.client.cli.commands.autoroute.commands import down, up
from litellm.proxy.client.cli.commands.autoroute.commands import autoroute_group, start, stop
from litellm.proxy.client.cli.commands.autoroute.process import PidRecord, ProcessLaunchError, write_pid_record
from litellm.proxy.client.cli.commands.up import BackupRecord as ClaudeBackupRecord
from litellm.proxy.client.cli.commands.up import write_backup
@ -46,14 +47,14 @@ def _silence_signal_handling(monkeypatch):
monkeypatch.setattr(commands_module, "stream_log", lambda *a, **k: None)
class TestUpCommand:
class TestStartCommand:
def setup_method(self):
self.runner = CliRunner()
def test_refuses_when_never_configured(self, monkeypatch, tmp_path):
_patch_paths(monkeypatch, tmp_path)
result = self.runner.invoke(up)
result = self.runner.invoke(start)
assert result.exit_code != 0
assert "lite autoroute configure" in result.output
@ -66,14 +67,14 @@ class TestUpCommand:
config_path.write_text("")
monkeypatch.setattr(commands_module, "is_port_available", lambda port: True)
result = self.runner.invoke(up)
result = self.runner.invoke(start)
assert result.exit_code != 0
assert result.exception is None or isinstance(result.exception, SystemExit)
assert "lite autoroute configure" in result.output
def test_refuses_with_actionable_error_when_proxy_runtime_missing(self, monkeypatch, tmp_path):
"""`up` launches a real litellm proxy, which the thin `litellm[cli]` install cannot run.
"""`start` launches a real litellm proxy, which the thin `litellm[cli]` install cannot run.
It must fail fast with an actionable message pointing at the proxy install, before it ever
tries to launch the doomed subprocess (which would otherwise die with a bare ImportError)."""
config_path, _log_path, _settings_path, _backup_path, _pid_record_path = _patch_paths(monkeypatch, tmp_path)
@ -85,7 +86,7 @@ class TestUpCommand:
monkeypatch.setattr(commands_module, "launch_proxy", _fail_if_launched)
result = self.runner.invoke(up)
result = self.runner.invoke(start)
assert result.exit_code != 0
assert "fastapi, websockets" in result.output
@ -99,18 +100,18 @@ class TestUpCommand:
)
monkeypatch.setattr(commands_module, "is_running", lambda pid: True)
result = self.runner.invoke(up)
result = self.runner.invoke(start)
assert result.exit_code != 0
assert "already running" in result.output
assert "lite autoroute down" in result.output
assert "lite autoroute stop" in result.output
assert config_path.read_text() == yaml.safe_dump({"model_list": []})
def test_refuses_when_backup_exists_after_an_unclean_crash(self, monkeypatch, tmp_path):
"""A prior `up` that was SIGKILL'd leaves no live pid but does leave a stale backup file.
"""A prior `start` that was SIGKILL'd leaves no live pid but does leave a stale backup file.
Without this guard, a fresh `up` would overwrite that backup with the currently-patched
(not original) Claude settings, so `down`/Ctrl-C would restore the wrong content forever.
Without this guard, a fresh `start` would overwrite that backup with the currently-patched
(not original) Claude settings, so `stop`/Ctrl-C would restore the wrong content forever.
"""
config_path, _log_path, claude_settings_path, backup_path, _pid_record_path = _patch_paths(
monkeypatch, tmp_path
@ -119,11 +120,11 @@ class TestUpCommand:
claude_settings_path.write_text(json.dumps({"env": {"ANTHROPIC_AUTH_TOKEN": "stale-patched-token"}}))
write_backup(ClaudeBackupRecord(existed=True, content={"theme": "dark"}), backup_path)
result = self.runner.invoke(up)
result = self.runner.invoke(start)
assert result.exit_code != 0
assert "already exists" in result.output
assert "lite autoroute down" in result.output
assert "lite autoroute stop" in result.output
assert json.loads(backup_path.read_text())["content"] == {"theme": "dark"}
def test_happy_path_patches_settings_then_restores_everything_on_stop(self, monkeypatch, tmp_path):
@ -151,7 +152,7 @@ class TestUpCommand:
monkeypatch.setattr("threading.Event.wait", fake_wait)
result = self.runner.invoke(up)
result = self.runner.invoke(start)
assert result.exit_code == 0, result.output
assert captured["backup_existed"] is True
@ -198,7 +199,7 @@ class TestUpCommand:
monkeypatch.setattr("threading.Event.wait", fake_wait)
result = self.runner.invoke(up)
result = self.runner.invoke(start)
assert result.exit_code == 0, result.output
assert "invalid or unexpected JSON" in result.output
@ -222,7 +223,7 @@ class TestUpCommand:
monkeypatch.setattr(commands_module, "terminate", lambda pid, **k: terminate_calls.append(pid))
monkeypatch.setattr(commands_module.secrets, "token_urlsafe", lambda n: "fixed-master-key")
result = self.runner.invoke(up)
result = self.runner.invoke(start)
assert result.exit_code != 0
assert "boom" in result.output
@ -234,7 +235,7 @@ class TestUpCommand:
def test_terminates_ephemeral_proxy_when_claude_settings_is_corrupt(self, monkeypatch, tmp_path):
"""The health check can pass and the proxy can come up fine, but if
~/.claude/settings.json turns out to be corrupt, the just-started proxy must not be left
running with no pid record -- exactly the leak `lite autoroute down` exists to clean up."""
running with no pid record -- exactly the leak `lite autoroute stop` exists to clean up."""
config_path, _log_path, claude_settings_path, backup_path, pid_record_path = _patch_paths(monkeypatch, tmp_path)
config_path.write_text(yaml.safe_dump({"model_list": []}))
claude_settings_path.write_text("not json at all {{{")
@ -247,7 +248,7 @@ class TestUpCommand:
monkeypatch.setattr(commands_module, "terminate", lambda pid, **k: terminate_calls.append(pid))
monkeypatch.setattr(commands_module.secrets, "token_urlsafe", lambda n: "fixed-master-key")
result = self.runner.invoke(up)
result = self.runner.invoke(start)
assert result.exit_code != 0
assert "invalid JSON" in result.output
@ -257,7 +258,7 @@ class TestUpCommand:
def test_a_status_line_install_failure_leaves_no_backup_behind(self, monkeypatch, tmp_path):
# The install runs before the backup is written, so a failure cannot strand a backup that
# would make every later `lite configure` / `lite autoroute up` think a session still owns settings.json
# would make every later `lite configure` / `lite autoroute start` think a session still owns settings.json
config_path, _log_path, claude_settings_path, backup_path, pid_record_path = _patch_paths(monkeypatch, tmp_path)
config_path.write_text(yaml.safe_dump({"model_list": []}))
claude_settings_path.write_text(json.dumps({"theme": "dark"}))
@ -274,7 +275,7 @@ class TestUpCommand:
monkeypatch.setattr(commands_module, "install_statusline_script", boom)
monkeypatch.setattr(commands_module.secrets, "token_urlsafe", lambda n: "fixed-master-key")
result = self.runner.invoke(up)
result = self.runner.invoke(start)
assert result.exit_code != 0 and "disk full" in result.output
assert terminate_calls == [778]
@ -282,7 +283,7 @@ class TestUpCommand:
assert not backup_path.exists()
assert json.loads(claude_settings_path.read_text()) == {"theme": "dark"}
def test_up_uses_the_same_port_and_master_key_across_runs(self, monkeypatch, tmp_path):
def test_start_uses_the_same_port_and_master_key_across_runs(self, monkeypatch, tmp_path):
"""The LIT-4607/LIT-4608 regression: a client configured against one session must keep
working in the next, so consecutive runs must patch settings with an identical base URL
and auth token, and the key must be minted exactly once."""
@ -315,9 +316,9 @@ class TestUpCommand:
monkeypatch.setattr("threading.Event.wait", fake_wait)
first = self.runner.invoke(up)
first = self.runner.invoke(start)
run_index["current"] = 1
second = self.runner.invoke(up)
second = self.runner.invoke(start)
assert first.exit_code == 0, first.output
assert second.exit_code == 0, second.output
@ -326,7 +327,7 @@ class TestUpCommand:
assert captured[0]["ANTHROPIC_AUTH_TOKEN"] == captured[1]["ANTHROPIC_AUTH_TOKEN"]
assert mint_calls == [32]
def test_up_reuses_a_master_key_already_persisted_in_the_config(self, monkeypatch, tmp_path):
def test_start_reuses_a_master_key_already_persisted_in_the_config(self, monkeypatch, tmp_path):
config_path, _log_path, claude_settings_path, _backup_path, _pid_record_path = _patch_paths(
monkeypatch, tmp_path
)
@ -354,13 +355,13 @@ class TestUpCommand:
monkeypatch.setattr("threading.Event.wait", fake_wait)
result = self.runner.invoke(up)
result = self.runner.invoke(start)
assert result.exit_code == 0, result.output
assert captured["env"]["ANTHROPIC_AUTH_TOKEN"] == "persisted-key"
assert captured["config_text"] == original_config
def test_up_mints_a_fresh_key_when_the_persisted_master_key_is_blank(self, monkeypatch, tmp_path):
def test_start_mints_a_fresh_key_when_the_persisted_master_key_is_blank(self, monkeypatch, tmp_path):
config_path, _log_path, claude_settings_path, _backup_path, _pid_record_path = _patch_paths(
monkeypatch, tmp_path
)
@ -382,16 +383,22 @@ class TestUpCommand:
monkeypatch.setattr("threading.Event.wait", fake_wait)
result = self.runner.invoke(up)
result = self.runner.invoke(start)
assert result.exit_code == 0, result.output
assert captured["env"]["ANTHROPIC_AUTH_TOKEN"] == "fresh-minted-key"
written_config = yaml.safe_load(config_path.read_text())
assert written_config["general_settings"]["master_key"] == "fresh-minted-key"
def test_port_override_reaches_settings_launch_and_pid_record(self, monkeypatch, tmp_path):
"""A --port override must flow to every consumer of the port; a hardcoded default in any
one of them would leave the patched settings pointing somewhere the proxy is not."""
@pytest.mark.parametrize(
("command", "leading_args"),
[(start, []), (autoroute_group, ["start"]), (autoroute_group, ["up"])],
ids=["start", "group start", "deprecated up alias"],
)
def test_port_override_reaches_settings_launch_and_pid_record(self, monkeypatch, tmp_path, command, leading_args):
"""A --port override must flow to every consumer of the port, through the deprecated `up`
alias too; a hardcoded default in any one of them would leave the patched settings pointing
somewhere the proxy is not."""
config_path, _log_path, claude_settings_path, _backup_path, pid_record_path = _patch_paths(
monkeypatch, tmp_path
)
@ -420,16 +427,16 @@ class TestUpCommand:
monkeypatch.setattr("threading.Event.wait", fake_wait)
result = self.runner.invoke(up, ["--port", "6111"])
result = self.runner.invoke(command, [*leading_args, "--port", "6111"])
assert result.exit_code == 0, result.output
assert captured["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:6111"
assert launched_ports == [6111]
assert captured["pid_record"]["port"] == 6111
def test_up_rejects_port_4000_which_the_child_proxy_rebinds_unpredictably(self, monkeypatch, tmp_path):
def test_start_rejects_port_4000_which_the_child_proxy_rebinds_unpredictably(self, monkeypatch, tmp_path):
"""proxy_cli special-cases a busy port 4000 by silently rebinding to a random port,
which would desync base_url from the child; up must refuse 4000 outright."""
which would desync base_url from the child; start must refuse 4000 outright."""
config_path, _log_path, _settings_path, backup_path, _pid_record_path = _patch_paths(monkeypatch, tmp_path)
config_path.write_text(yaml.safe_dump({"model_list": []}))
@ -438,13 +445,13 @@ class TestUpCommand:
monkeypatch.setattr(commands_module, "launch_proxy", _fail_launch)
result = self.runner.invoke(up, ["--port", "4000"])
result = self.runner.invoke(start, ["--port", "4000"])
assert result.exit_code != 0
assert "4000" in result.output
assert not backup_path.exists()
def test_up_refuses_when_the_port_is_busy_without_touching_any_state(self, monkeypatch, tmp_path):
def test_start_refuses_when_the_port_is_busy_without_touching_any_state(self, monkeypatch, tmp_path):
"""A busy port must fail loudly before anything is minted, launched, or patched --
never silently move to another port (the pre-fix behavior this ticket removes)."""
config_path, _log_path, claude_settings_path, backup_path, _pid_record_path = _patch_paths(
@ -463,18 +470,18 @@ class TestUpCommand:
sock.bind(("127.0.0.1", 0))
sock.listen(1)
busy_port = sock.getsockname()[1]
result = self.runner.invoke(up, ["--port", str(busy_port)])
result = self.runner.invoke(start, ["--port", str(busy_port)])
assert result.exit_code != 0
assert str(busy_port) in result.output
assert "lite autoroute down" in result.output
assert "lite autoroute stop" in result.output
assert "--port" in result.output
assert config_path.read_text() == original_config
assert not backup_path.exists()
assert json.loads(claude_settings_path.read_text()) == {"theme": "dark"}
class TestDownCommand:
class TestStopCommand:
def setup_method(self):
self.runner = CliRunner()
@ -491,7 +498,7 @@ class TestDownCommand:
monkeypatch.setattr(commands_module, "is_running", lambda pid: True)
monkeypatch.setattr(commands_module, "terminate", lambda pid, **k: terminate_calls.append(pid))
result = self.runner.invoke(down)
result = self.runner.invoke(stop)
assert result.exit_code == 0, result.output
assert "Stopped leftover ephemeral proxy" in result.output
@ -501,19 +508,33 @@ class TestDownCommand:
assert not backup_path.exists()
assert json.loads(claude_settings_path.read_text()) == original_settings
def test_removes_settings_that_did_not_exist_before_start(self, monkeypatch, tmp_path):
_config_path, _log_path, claude_settings_path, backup_path, _pid_record_path = _patch_paths(
monkeypatch, tmp_path
)
write_backup(ClaudeBackupRecord(existed=False, content=None), backup_path)
claude_settings_path.write_text(json.dumps({"env": {"ANTHROPIC_AUTH_TOKEN": "fixed-master-key"}}))
result = self.runner.invoke(stop)
assert result.exit_code == 0, result.output
assert f"Removed {claude_settings_path} (it did not exist before `lite autoroute start`)." in result.output
assert not claude_settings_path.exists()
assert not backup_path.exists()
def test_is_a_clean_no_op_when_nothing_is_running_and_no_backup_exists(self, monkeypatch, tmp_path):
_config_path, _log_path, claude_settings_path, _backup_path, _pid_record_path = _patch_paths(
monkeypatch, tmp_path
)
result = self.runner.invoke(down)
result = self.runner.invoke(stop)
assert result.exit_code == 0, result.output
assert "Nothing to restore." in result.output
assert not claude_settings_path.exists()
def test_clears_a_corrupt_pid_record_and_still_restores_settings(self, monkeypatch, tmp_path):
"""down is specifically the crash-recovery path -- a pid file truncated by a mid-write
"""stop is specifically the crash-recovery path -- a pid file truncated by a mid-write
crash must not block it from clearing the record and restoring Claude settings anyway."""
_config_path, _log_path, claude_settings_path, backup_path, pid_record_path = _patch_paths(
monkeypatch, tmp_path
@ -524,7 +545,7 @@ class TestDownCommand:
write_backup(ClaudeBackupRecord(existed=True, content=original_settings), backup_path)
claude_settings_path.write_text(json.dumps({"env": {"ANTHROPIC_AUTH_TOKEN": "fixed-master-key"}}))
result = self.runner.invoke(down)
result = self.runner.invoke(stop)
assert result.exit_code == 0, result.output
assert "invalid or unexpected JSON" in result.output
@ -540,7 +561,48 @@ class TestDownCommand:
backup_path.parent.mkdir(parents=True, exist_ok=True)
backup_path.write_text("not json at all {{{")
result = self.runner.invoke(down)
result = self.runner.invoke(stop)
assert result.exit_code != 0
assert "invalid or unexpected JSON" in result.output
class TestSubcommandNames:
def test_start_and_stop_are_the_listed_commands(self):
"""`lite up` already routes an existing proxy into Claude Code, so the ephemeral proxy's
launcher and its recovery path are listed as `start` and `stop`; the old names stay callable
but are hidden from the listing."""
runner = CliRunner()
listing = runner.invoke(autoroute_group, ["--help"])
assert listing.exit_code == 0, listing.output
listed = {line.split()[0] for line in listing.output.splitlines() if line.startswith(" ")}
assert {"configure", "start", "stop"} <= listed
assert listed.isdisjoint({"up", "down"})
for name in ("start", "stop", "up", "down"):
result = runner.invoke(autoroute_group, [name, "--help"])
assert result.exit_code == 0, result.output
assert "Show this message and exit" in result.output
def test_up_warns_then_behaves_like_start(self, monkeypatch, tmp_path):
_patch_paths(monkeypatch, tmp_path)
runner = CliRunner()
result = runner.invoke(autoroute_group, ["up", "--port", "5555"])
assert result.exit_code == 1, result.output
assert "`lite autoroute up` is deprecated" in result.stderr
assert "run `lite autoroute start` instead" in result.stderr
assert "No config found. Run `lite autoroute configure` first." in result.output
def test_down_warns_then_behaves_like_stop(self, monkeypatch, tmp_path):
_patch_paths(monkeypatch, tmp_path)
runner = CliRunner()
result = runner.invoke(autoroute_group, ["down"])
assert result.exit_code == 0, result.output
assert "`lite autoroute down` is deprecated" in result.stderr
assert "run `lite autoroute stop` instead" in result.stderr
assert "Nothing to restore." in result.output

View file

@ -39,7 +39,7 @@ from litellm.proxy.client.cli.commands.claude_settings import (
def _owners(*backup_paths):
"""Stand-in owners for the real `lite up` / `lite autoroute up` registry."""
"""Stand-in owners for the real `lite up` / `lite autoroute start` registry."""
return tuple(SettingsFileOwner(path, "lite up", "lite down") for path in backup_paths)
@ -162,7 +162,7 @@ class TestConfigureClaudeSettings:
class TestConflictingOwnersOfTheSettingsFile:
"""Both `lite up` and `lite autoroute up` restore a backup when they stop.
"""Both `lite up` and `lite autoroute start` restore a backup when they stop.
Guarding only one of them leaves the other free to silently revert this
write, which is the exact hazard the guard exists to prevent.
@ -184,11 +184,11 @@ class TestConflictingOwnersOfTheSettingsFile:
settings_path = tmp_path / "claude" / "settings.json"
backup = tmp_path / "auto.json"
backup.write_text("{}")
autoroute = SettingsFileOwner(backup, "lite autoroute up", "lite autoroute down")
autoroute = SettingsFileOwner(backup, "lite autoroute start", "lite autoroute stop")
with pytest.raises(ClaudeSettingsError, match="`lite autoroute up` is currently managing"):
with pytest.raises(ClaudeSettingsError, match="`lite autoroute start` is currently managing"):
_static_configure("https://proxy.example.com", settings_path, (autoroute,))
with pytest.raises(ClaudeSettingsError, match="Run `lite autoroute down` first"):
with pytest.raises(ClaudeSettingsError, match="Run `lite autoroute stop` first"):
_static_configure("https://proxy.example.com", settings_path, (autoroute,))
def test_the_registry_matches_the_paths_the_commands_actually_use(self):
@ -197,7 +197,7 @@ class TestConflictingOwnersOfTheSettingsFile:
assert AUTOROUTE_BACKUP_PATH == AUTOROUTE_DIR / "claude_settings_backup.json"
assert {o.backup_path for o in SETTINGS_FILE_OWNERS} == {BACKUP_PATH, AUTOROUTE_BACKUP_PATH}
assert {o.stop_command for o in SETTINGS_FILE_OWNERS} == {"lite down", "lite autoroute down"}
assert {o.stop_command for o in SETTINGS_FILE_OWNERS} == {"lite down", "lite autoroute stop"}
class TestDoesNotDestroyUserOwnedStructure:
@ -297,7 +297,7 @@ class TestConfigureStatePath:
class TestMergeClaudeSettings:
"""One merge for every way Claude Code gets wired: `lite up`, `lite configure claude` and `lite autoroute up`."""
"""One merge for every way Claude Code gets wired: `lite up`, `lite configure claude` and `lite autoroute start`."""
def test_a_static_token_lands_in_env_and_the_helper_slot_is_cleared(self):
settings = {"apiKeyHelper": "/usr/local/bin/lite auth print-token", "env": {"ANTHROPIC_API_KEY": "leaked"}}
@ -337,7 +337,7 @@ class TestMergeClaudeSettings:
def test_a_tier_model_forces_every_claude_code_tier_as_autoroute_needs(self):
# Router's auto-router registry is keyed by the literal requested model string with no
# wildcard resolution, so `lite autoroute up` overrides the env var each tier reads.
# wildcard resolution, so `lite autoroute start` overrides the env var each tier reads.
settings = {"env": {"ANTHROPIC_DEFAULT_SONNET_MODEL": "claude-opus-4-8"}}
merged = merge_claude_settings(
settings, "http://127.0.0.1:4000", StaticToken("token-abc"), tier_model="autorouter"