feat(cli): reuse saved agent setup and add reconfigure (#43392)

This commit is contained in:
tin-berri 2026-09-26 18:46:36 -07:00 • committed by GitHub
parent 7aba77197d
commit 013d5fa015
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 1391 additions and 362 deletions

View file

@ -546,7 +546,20 @@ lite configure --api-key sk-... --gateway-url https://your-proxy.example.com
Select Claude Code, Codex, or both, then choose a gateway model for each selected agent. The wizard validates the key and reads the models your key can access before changing settings. Start either configured agent normally with `claude` or `codex`; the gateway connection persists across terminals without a wrapper or exported API key
`--gateway-url` also accepts a deployment path prefix and a trailing `/v1`. `--base-url` is an alias. If omitted, setup uses `lite --base-url`, `LITELLM_PROXY_URL`, or the saved CLI URL; the wizard asks for a URL when none was provided
Your gateway, virtual key and model choice are saved separately from each agent's undo record. The command saves validated choices before applying them; if applying fails, `lite configure` retries those saved choices. Disconnecting keeps that setup so you can reconnect without repeating the wizard:
```bash
lite unconfigure
lite configure
```
`lite configure` reuses all saved setups for the current agent homes. Name an agent to reconnect only that one, such as `lite configure claude` or `lite configure codex`. Saved setup works without a terminal when the key and model are still valid
Run `lite reconfigure` to edit your choices with the saved values prefilled, or `lite reconfigure codex` to edit one agent. The agent picker selects which setups to edit; unchecked agents keep their settings. For Claude Code, choose its own default in the wizard or use `lite configure claude --default-model` to remove LiteLLM's model pin. Omitting `--model` keeps your saved choice
`lite unconfigure --forget` undoes settings it still owns and deletes the saved setups, including their saved keys. `lite unconfigure claude --forget` forgets only Claude Code. Both work after an earlier disconnect. Saved setup files have owner-only permissions and follow the same resolved config-file scope as the undo records, including `CLAUDE_CONFIG_DIR` and `CODEX_HOME`. A pending undo record remains available if an original credential could not safely be restored. If the undo record is missing, agent settings are left unchanged and the command asks you to remove any remaining gateway connection and key manually; forgetting the saved profile does not erase unowned agent settings
`--gateway-url` also accepts a deployment path prefix and a trailing `/v1`. `--base-url` is an alias. Current command-line or environment options override saved setup values. Otherwise an existing setup supplies its own gateway; first setup falls back to the saved CLI URL or prompts for one. Changing the gateway requires a key for that gateway, so an old saved key is never reused for a different destination
For a scripted setup, name the agent and model:
@ -569,9 +582,9 @@ 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 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
The key comes from `--api-key` (or `lite --api-key` / `LITELLM_PROXY_API_KEY`), or from this agent's saved setup, 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. On first setup without a model choice, 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
On first use, plain `lite configure` asks which agents to wire and which gateway model each starts on, picked from `/v1/models` with a type-to-filter prompt. Later runs validate and reapply the saved setups. 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 start` session holds a backup, and that check comes before any request
@ -587,7 +600,7 @@ Claude Opus 5 ██████████████████████
After the first response, the status line uses the latest routed model recorded by `GET /auto_router/session?session_id=...`, so it can show the tier model even when the transcript contains the router alias. If no session record is available, it falls back to Claude Code's transcript. Session records and costs are cached for five seconds under a per-user `$TMPDIR/litellm-statusline-<uid>` directory. The gateway records turns asynchronously, so the display can briefly lag a completed turn. Any virtual key may read its own sessions. The baseline is the priciest model in the router's hardest tier, the same counterfactual the auto-router's savings reports use. `lite unconfigure claude` removes the `statusLine` entry only while it still points at that script
After upgrading the CLI, rerun your original `lite configure claude` command with the same gateway, key and model choice to refresh `~/.litellm/statusline.py`. Keep any explicit `--model` value: omitting it removes the earlier model pin. Package upgrades alone do not refresh this installed copy
After upgrading the CLI, run `lite configure claude` to refresh `~/.litellm/statusline.py` using the saved setup. If your setup predates saved profiles, supply the original gateway, key and model once. Package upgrades alone do not refresh this installed copy
`lite codex` registers the same script as a Codex `Stop` hook for the launch, so after each turn Codex prints the same block as a system message. Codex asks once to trust the hook; the answer is remembered for later launches.

View file

@ -1,284 +1,43 @@
"""Persistent Claude Code and Codex gateway configuration."""
"""Commands for saved Claude Code and Codex gateway setup."""
import os
import sys
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from pathlib import Path
from types import MappingProxyType
from typing import Final
import click
from InquirerPy import inquirer
from InquirerPy.base.control import Choice
from pydantic import BaseModel
from litellm.proxy.common_utils.model_listing_utils import (
CLAUDE_CODE_CLIENT,
CLAUDE_CODE_PICKER_PATTERN,
GATEWAY_CLIENT_HEADER,
)
from .agents import codex_config_path
from .auth import CliContextObj
from .claude_settings import (
STARTING_MODEL_ROLE,
ClaudeSettingsError,
ModelChoice,
StartOn,
StaticToken,
UnconfigureOutcome,
UnpinModel,
claude_settings_path,
configure_claude_settings,
configure_state_path,
preflight_claude_settings,
settings_file_owners,
unconfigure_claude_settings,
)
from .codex_settings import (
CodexSettingsError,
configure_codex_settings,
preflight_codex_settings,
unconfigure_codex_settings,
)
from .codex_settings import CodexSettingsError, unconfigure_codex_settings
from .config import normalize_base_url
from .pi import ListedModel, ListingFailure, PiSyncError, fetch_model_listing
_LISTED_MODELS_SHOWN: Final = 20
_CLAUDE_TARGET: Final = "claude"
_CODEX_TARGET: Final = "codex"
_TARGETS: Final = ((_CLAUDE_TARGET, "Claude Code (CLI)"), (_CODEX_TARGET, "Codex (CLI)"))
_KEEP_DEFAULT_MODEL: Final = "Keep Claude Code's own default"
_CLAUDE_CODE_VIEW: Final = MappingProxyType(
{"anthropic-version": "2023-06-01", GATEWAY_CLIENT_HEADER: CLAUDE_CODE_CLIENT}
from .configure_profiles import (
TARGETS,
Target,
forget_saved_setup,
read_saved_setup,
receipt_path_for,
settings_path_for,
setup_locks,
setup_profile_path,
)
_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 start` is the mode that does."
from .configure_setup import (
MODEL_OPTION_HELP,
ConnectionSettings,
configure_targets,
interactive_configure,
pick_targets,
resolve_credential,
)
def resolve_credential(ctx: click.Context, api_key: str | None) -> StaticToken:
"""The long-lived key written into settings.json: --api-key, `lite --api-key` or LITELLM_PROXY_API_KEY.
A `lite login` credential is never written: it expires within a day, and keeping it fresh would mean
Claude Code running `lite` through `apiKeyHelper` on every credential refresh.
"""
ctx_obj: Final[CliContextObj] = ctx.obj
explicit: Final = api_key or (None if ctx_obj.get("api_key_from_token_file") else ctx_obj.get("api_key"))
if not explicit:
raise ClaudeSettingsError(
"`lite configure` needs a long-lived virtual key: pass --api-key, `lite --api-key`, or set "
"LITELLM_PROXY_API_KEY. Your `lite login` credential expires within a day, so it is not written "
"into agent settings."
)
if not explicit.strip() or any(ord(char) <= 32 or ord(char) == 127 for char in explicit):
raise ClaudeSettingsError("The virtual key must not be blank or contain whitespace or control characters.")
return StaticToken(explicit)
@dataclass(frozen=True, slots=True)
class _Listing:
models: tuple[ListedModel, ...]
@property
def ids(self) -> tuple[str, ...]:
return tuple(model.id for model in self.models)
def _preflight(target: str) -> None:
try:
if target == _CLAUDE_TARGET:
preflight_claude_settings(claude_settings_path(os.environ))
else:
preflight_codex_settings(codex_config_path(os.environ))
except (ClaudeSettingsError, CodexSettingsError) as e:
raise click.ClickException(str(e)) from e
def _start(
ctx: click.Context, base_url: str, api_key: str | None, target: str = _CLAUDE_TARGET
) -> tuple[StaticToken, _Listing]:
_preflight(target)
try:
credential: Final = resolve_credential(ctx, api_key)
except ClaudeSettingsError as e:
raise click.ClickException(str(e))
return credential, _listed_models(base_url, credential.token, target)
def _listing_error(base_url: str, error: PiSyncError, target: str) -> str:
"""The hint that fits how the listing failed: only an unreachable proxy gets the "is it running" question."""
if error.kind is ListingFailure.REJECTED:
return f"LiteLLM rejected your key (HTTP {error.status}). Pass a valid --api-key."
if error.kind is ListingFailure.UNREACHABLE:
return (
f"Could not connect. Is the proxy at {base_url} running, and is --base-url (or LITELLM_PROXY_URL) correct?"
)
if error.kind is ListingFailure.EMPTY:
name: Final = "Claude Code" if target == _CLAUDE_TARGET else "Codex"
return f"{error.message} {name} would have nothing to run; give the key access to at least one model."
return f"The proxy at {base_url} answered, so check that it is a LiteLLM proxy and is healthy."
def _listed_models(base_url: str, key: str, target: str = _CLAUDE_TARGET) -> _Listing:
listed: Final = fetch_model_listing(
base_url, key, headers=_CLAUDE_CODE_VIEW if target == _CLAUDE_TARGET else MappingProxyType({})
)
if isinstance(listed, PiSyncError):
raise click.ClickException(_listing_error(base_url, listed, target))
return _Listing(listed)
def _starting_model(model: str, listing: _Listing) -> str | None:
source: Final = next((listed.id for listed in listing.models if listed.source_model == model), None)
return source or next((listed.id for listed in listing.models if listed.id == model), None)
def _model_choice(model: str | None) -> ModelChoice:
return StartOn(model) if model is not None else UnpinModel()
def _validated_model(model: str | None, listing: _Listing, base_url: str) -> str | None:
starting: Final = _starting_model(model, listing) if model is not None else None
if model is not None and starting is None:
shown: Final = ", ".join(listing.ids[:_LISTED_MODELS_SHOWN])
raise click.ClickException(f"{model!r} is not served by {base_url} for this key. /v1/models lists: {shown}.")
return starting
def _apply_claude(base_url: str, credential: StaticToken, listing: _Listing, model: str | None) -> None:
listed: Final = listing.ids
starting: Final = _validated_model(model, listing, base_url)
settings_path: Final = claude_settings_path(os.environ)
try:
configure_claude_settings(
base_url,
credential,
_model_choice(starting),
settings_path,
configure_state_path(settings_path),
settings_file_owners(settings_path),
)
except ClaudeSettingsError as e:
raise click.ClickException(str(e))
in_picker: Final = sum(1 for listed_model in listed if CLAUDE_CODE_PICKER_PATTERN.search(listed_model))
click.echo(f"Configured Claude Code: {settings_path} now routes through {base_url}.")
click.echo("Credential: your virtual key, stored in the file as ANTHROPIC_AUTH_TOKEN.")
click.echo(
f"Starting model: {starting} ({STARTING_MODEL_ROLE}); switch any time with /model."
if starting is not None
else "Starting model: not pinned (Claude Code's default, or a model you set yourself); switch with /model, or "
"pass --model to start on a proxy model. Without a pin, a resumed session re-sends the model its transcript "
"recorded, which behind a raw-model auto-router is the tier model."
)
click.echo(
f"/model will list all {len(listed)} of the proxy's models."
if in_picker == len(listed)
else f"/model will list {in_picker} of the proxy's {len(listed)} models: Claude Code shows only ids containing "
"'claude' or 'anthropic', and this proxy does not list the rest under such names."
)
click.echo("Start `claude` from any terminal. Undo with `lite unconfigure claude`.")
if settings_path.is_symlink():
click.echo(
f"Note: {settings_path} is a symlink to {settings_path.resolve()}, so your key now lives in "
"that file; keep it out of version control.",
err=True,
)
def _pick_targets() -> tuple[str, ...]:
picked: Final = inquirer.checkbox(
message="Which agents should route through LiteLLM?",
choices=[Choice(value, name=label, enabled=True) for value, label in _TARGETS],
validate=lambda chosen: len(chosen) > 0,
invalid_message="Pick at least one.",
).execute()
return tuple(str(value) for value in picked)
def _pick_model(listed: Sequence[str]) -> str | None:
picked: Final = inquirer.fuzzy(
message="Model Claude Code starts on (type to filter; /model switches any time):",
choices=[_KEEP_DEFAULT_MODEL, *listed],
default=listed[0] if listed else _KEEP_DEFAULT_MODEL,
).execute()
return None if picked == _KEEP_DEFAULT_MODEL else str(picked)
def _pick_codex_model(listed: Sequence[str]) -> str:
choices: Final = list(listed) # mutable-ok: InquirerPy's choices parameter requires a list
return str(inquirer.fuzzy(message="Model Codex starts on (type to filter):", choices=choices).execute())
def _apply_codex(base_url: str, credential: StaticToken, listing: _Listing, model: str) -> None:
_validated_model(model, listing, base_url)
settings_path: Final = codex_config_path(os.environ)
try:
configure_codex_settings(base_url, credential.token, model, settings_path)
except CodexSettingsError as e:
raise click.ClickException(str(e)) from e
click.echo(f"Configured Codex: {settings_path} now routes through {base_url}.")
click.echo(f"Starting model: {model}. Credential: your virtual key, stored in the private provider settings.")
click.echo("Start `codex` from any terminal. Undo with `lite unconfigure codex`.")
if settings_path.is_symlink():
click.echo(f"Note: your key now lives in {settings_path.resolve()}; keep it out of version control.", err=True)
@dataclass(frozen=True, slots=True)
class _Setup:
target: str
listing: _Listing
model: str | None
def _choose_setup(
base_url: str,
target: str,
credential: StaticToken,
pick_model: Callable[[Sequence[str]], str | None],
pick_codex_model: Callable[[Sequence[str]], str],
) -> _Setup:
listing: Final = _listed_models(base_url, credential.token, target)
model: Final = (
pick_model(tuple(item.source_model or item.id for item in listing.models))
if target == _CLAUDE_TARGET
else pick_codex_model(listing.ids)
)
_validated_model(model, listing, base_url)
return _Setup(target, listing, model)
def interactive_configure(
ctx: click.Context,
pick_targets: Callable[[], tuple[str, ...]] = _pick_targets,
pick_model: Callable[[Sequence[str]], str | None] = _pick_model,
pick_codex_model: Callable[[Sequence[str]], str] = _pick_codex_model,
) -> None:
"""`lite configure` with no agent named: ask which agents to wire and which model to pin."""
targets: Final = pick_targets()
if not targets:
return
for target in targets:
_preflight(target)
try:
credential: Final = resolve_credential(ctx, None)
except ClaudeSettingsError as e:
raise click.ClickException(str(e)) from e
base_url: Final[str] = ctx.obj["base_url"]
setups: Final = tuple(
_choose_setup(base_url, target, credential, pick_model, pick_codex_model) for target in targets
)
for setup in setups:
if setup.target == _CLAUDE_TARGET:
_apply_claude(base_url, credential, setup.listing, setup.model)
elif setup.model is not None:
_apply_codex(base_url, credential, setup.listing, setup.model)
class _ConnectionOptions(BaseModel):
api_key: str | None = None
gateway_url: str | None = None
@ -286,21 +45,20 @@ class _ConnectionOptions(BaseModel):
def _connection_settings(ctx: click.Context, api_key: str | None, gateway_url: str | None) -> CliContextObj:
"""The context object a subcommand runs with: its own --api-key / --gateway-url over the group's, over `lite`'s."""
ctx_obj: Final[CliContextObj] = ctx.obj
ctx_obj: Final = ConnectionSettings.model_validate(ctx.find_object(object))
group: Final = (
_ConnectionOptions.model_validate(ctx.parent.params)
if ctx.parent is not None and ctx.parent.command.name == "configure"
if ctx.parent is not None and ctx.parent.command.name in ("configure", "reconfigure")
else _ConnectionOptions()
)
key: Final = api_key if api_key is not None else group.api_key
url: Final = gateway_url if gateway_url is not None else group.gateway_url
normalized: Final = normalize_base_url(url if url is not None else ctx_obj["base_url"])
normalized: Final = normalize_base_url(url if url is not None else ctx_obj.base_url)
connection: Final[CliContextObj] = {
**ctx_obj,
"base_url": normalized.removesuffix("/v1"),
"base_url_explicit": url is not None or ctx_obj.get("base_url_explicit", False),
"api_key": key if key is not None else ctx_obj.get("api_key"),
"api_key_from_token_file": False if key is not None else ctx_obj.get("api_key_from_token_file", False),
"base_url_explicit": url is not None or ctx_obj.base_url_explicit,
"api_key": key if key is not None else ctx_obj.api_key,
"api_key_from_token_file": False if key is not None else ctx_obj.api_key_from_token_file,
}
return connection
@ -309,108 +67,216 @@ def _connection_context(ctx: click.Context, settings: CliContextObj) -> click.Co
return click.Context(ctx.command, parent=ctx.parent, obj=settings)
@click.group(name="configure", invoke_without_command=True)
@click.option("--api-key", default=None, help="Long-lived LiteLLM virtual key to store in the selected agents.")
@click.option(
"--gateway-url", "--base-url", default=None, help="Gateway URL; defaults to `lite --base-url` / LITELLM_PROXY_URL."
)
@click.pass_context
def configure_group(ctx: click.Context, api_key: str | None, gateway_url: str | None) -> None:
"""Persistently route a coding agent through your LiteLLM proxy.
def _require_terminal(command: str) -> None:
if sys.stdin.isatty():
return
raise click.ClickException(
f"`lite {command}` asks questions, so it needs a terminal. Non-interactively, run "
f"`lite {command} claude --api-key <key> --model <model>` or "
f"`lite {command} codex --api-key <key> --model <model>`"
)
With no agent named, asks which agents to wire and which proxy model to pin.
"""
def _configure_group(ctx: click.Context, api_key: str | None, gateway_url: str | None, edit: bool) -> None:
if ctx.invoked_subcommand is not None:
return
settings: Final = _connection_settings(ctx, api_key, gateway_url)
connection: Final = _connection_context(ctx, settings)
if not sys.stdin.isatty():
raise click.ClickException(
"`lite configure` asks questions, so it needs a terminal. Non-interactively, run "
"`lite configure claude --api-key <key> --model <model>` or "
"`lite configure codex --api-key <key> --model <model>`."
with setup_locks(TARGETS):
saved_targets: Final[tuple[Target, ...]] = tuple(
target for target in TARGETS if read_saved_setup(target) is not None
)
if settings.get("base_url_explicit"):
interactive_configure(connection)
return
prompted: Final = _connection_settings(connection, None, click.prompt("Gateway URL", default=settings["base_url"]))
interactive_configure(_connection_context(connection, prompted))
if saved_targets and not edit:
configure_targets(connection, saved_targets)
return
_require_terminal("reconfigure" if edit else "configure")
selected: Final = pick_targets(saved_targets or TARGETS, edit=edit)
if not selected:
return
if edit:
configure_targets(connection, selected, interactive=True, edit_connection=True)
return
prompted: Final = (
settings
if settings.get("base_url_explicit")
else _connection_settings(connection, None, click.prompt("Gateway URL", default=settings["base_url"]))
)
configure_targets(_connection_context(connection, prompted), selected, interactive=True)
@click.group(name="unconfigure")
def unconfigure_group() -> None:
"""Undo `lite configure` for a coding agent."""
@click.group(name="configure", invoke_without_command=True)
@click.option("--api-key", default=None, help="Long-lived LiteLLM virtual key to save for the selected agents.")
@click.option("--gateway-url", "--base-url", default=None, help="Gateway URL, including any deployment path prefix.")
@click.pass_context
def configure_group(ctx: click.Context, api_key: str | None, gateway_url: str | None) -> None:
"""Apply saved setup, or choose agents and models on the first run."""
_configure_group(ctx, api_key, gateway_url, False)
@click.group(name="reconfigure", invoke_without_command=True)
@click.option("--api-key", default=None, help="Long-lived LiteLLM virtual key to save for the selected agents.")
@click.option("--gateway-url", "--base-url", default=None, help="Gateway URL, including any deployment path prefix.")
@click.pass_context
def reconfigure_group(ctx: click.Context, api_key: str | None, gateway_url: str | None) -> None:
"""Edit saved gateway, key and model choices, using current choices as defaults."""
_configure_group(ctx, api_key, gateway_url, True)
def _configure_target(
ctx: click.Context,
target: Target,
api_key: str | None,
gateway_url: str | None,
model: str | None,
default_model: bool = False,
*,
edit: bool = False,
) -> None:
settings: Final = _connection_settings(ctx, api_key, gateway_url)
interactive: Final = edit and model is None and not default_model
if interactive:
_require_terminal("reconfigure")
with setup_locks((target,)):
configure_targets(
_connection_context(ctx, settings),
(target,),
model=model,
default_model=default_model,
interactive=interactive,
edit_connection=interactive,
)
@configure_group.command(name="claude")
@click.option("--api-key", default=None, help="Long-lived LiteLLM virtual key, or reuse the saved key.")
@click.option("--gateway-url", "--base-url", default=None, help="Gateway URL, or reuse the saved gateway.")
@click.option("--model", default=None, help=MODEL_OPTION_HELP)
@click.option(
"--api-key",
"api_key",
default=None,
help="Long-lived LiteLLM virtual key written into Claude Code's settings. Defaults to the `lite --api-key` / "
"LITELLM_PROXY_API_KEY value; required, since a `lite login` credential expires within a day.",
"--default-model", is_flag=True, help="Stop pinning a starting model; let Claude Code choose its default."
)
@click.option("--model", default=None, help=_MODEL_OPTION_HELP)
@click.option("--gateway-url", "--base-url", default=None, help="Gateway URL, including any deployment path prefix.")
@click.pass_context
def configure_claude(ctx: click.Context, api_key: str | None, model: str | None, gateway_url: str | None) -> None:
"""Route every Claude Code session through your LiteLLM proxy until `lite unconfigure claude`.
Patches ~/.claude/settings.json in place: the proxy URL, your virtual key as a static token,
and gateway model discovery so /model lists the proxy's models; --model picks the one Claude
Code starts on and resumes with. Every other
setting is kept, and what changed is recorded so `lite unconfigure claude` can put it back.
Assumes the proxy is already running.
"""
settings: Final = _connection_settings(ctx, api_key, gateway_url)
credential, listing = _start(_connection_context(ctx, settings), settings["base_url"], api_key)
_apply_claude(settings["base_url"], credential, listing, model)
def configure_claude(
ctx: click.Context,
api_key: str | None,
gateway_url: str | None,
model: str | None,
default_model: bool,
) -> None:
"""Apply Claude Code's saved setup, or save the supplied settings."""
_configure_target(ctx, "claude", api_key, gateway_url, model, default_model)
@configure_group.command(name="codex")
@click.option("--api-key", default=None, help="Long-lived LiteLLM virtual key to store in Codex's user config.")
@click.option("--gateway-url", "--base-url", default=None, help="Gateway URL, including any deployment path prefix.")
@click.option("--model", required=True, help="Gateway model Codex starts on, as listed by /v1/models for your key.")
@click.option("--api-key", default=None, help="Long-lived LiteLLM virtual key, or reuse the saved key.")
@click.option("--gateway-url", "--base-url", default=None, help="Gateway URL, or reuse the saved gateway.")
@click.option("--model", default=None, help="Gateway model to start on; required only for first-time setup.")
@click.pass_context
def configure_codex(ctx: click.Context, api_key: str | None, gateway_url: str | None, model: str) -> None:
"""Route plain `codex` through the gateway until `lite unconfigure codex`."""
settings: Final = _connection_settings(ctx, api_key, gateway_url)
credential, listing = _start(_connection_context(ctx, settings), settings["base_url"], api_key, _CODEX_TARGET)
_apply_codex(settings["base_url"], credential, listing, model)
def configure_codex(ctx: click.Context, api_key: str | None, gateway_url: str | None, model: str | None) -> None:
"""Apply Codex's saved setup, or save the supplied settings."""
_configure_target(ctx, "codex", api_key, gateway_url, model)
@unconfigure_group.command(name="codex")
def unconfigure_codex() -> None:
"""Restore only Codex settings still holding what configure wrote."""
settings_path: Final = codex_config_path(os.environ)
@reconfigure_group.command(name="claude")
@click.option("--api-key", default=None, help="Long-lived LiteLLM virtual key, or reuse the saved key.")
@click.option("--gateway-url", "--base-url", default=None, help="Gateway URL, or reuse the saved gateway.")
@click.option("--model", default=None, help=MODEL_OPTION_HELP)
@click.option(
"--default-model", is_flag=True, help="Stop pinning a starting model; let Claude Code choose its default."
)
@click.pass_context
def reconfigure_claude(
ctx: click.Context,
api_key: str | None,
gateway_url: str | None,
model: str | None,
default_model: bool,
) -> None:
"""Edit Claude Code setup, or supply --model / --default-model to apply directly."""
_configure_target(ctx, "claude", api_key, gateway_url, model, default_model, edit=True)
@reconfigure_group.command(name="codex")
@click.option("--api-key", default=None, help="Long-lived LiteLLM virtual key, or reuse the saved key.")
@click.option("--gateway-url", "--base-url", default=None, help="Gateway URL, or reuse the saved gateway.")
@click.option("--model", default=None, help="Gateway model to start on; omit to open the setup wizard.")
@click.pass_context
def reconfigure_codex(ctx: click.Context, api_key: str | None, gateway_url: str | None, model: str | None) -> None:
"""Edit Codex setup, or supply --model to apply directly."""
_configure_target(ctx, "codex", api_key, gateway_url, model, edit=True)
def _disconnect(target: Target, forget: bool) -> None:
settings_path: Final = settings_path_for(target)
state_path: Final = receipt_path_for(target, settings_path)
profile: Final = setup_profile_path(target, settings_path)
try:
outcome: Final = unconfigure_codex_settings(settings_path)
except CodexSettingsError as e:
raise click.ClickException(str(e)) from e
if outcome.file_removed:
click.echo(f"Removed {settings_path}; it held only settings created by `lite configure codex`.")
elif outcome.restored:
click.echo(f"Restored in {settings_path}: {', '.join(outcome.restored)}.")
else:
click.echo(f"Nothing in {settings_path} was still ours to restore.")
if outcome.kept:
click.echo(f"Left as you changed them since: {', '.join(outcome.kept)}.")
if not state_path.exists():
click.echo(f"No {target} undo receipt at {state_path}; nothing to undo. Agent settings were not changed.")
if settings_path.exists():
click.echo(
f"Cannot confirm disconnection. Check {settings_path} and remove any remaining gateway "
"connection and key manually.",
err=True,
)
elif target == "claude":
preflight_claude_settings(settings_path)
outcome: Final = unconfigure_claude_settings(settings_path, state_path, settings_file_owners(settings_path))
_report_unconfigure(settings_path, state_path, outcome)
else:
codex_outcome: Final = unconfigure_codex_settings(settings_path)
if codex_outcome.file_removed:
click.echo(f"Removed {settings_path}; it held only settings created by `lite configure codex`.")
elif codex_outcome.restored:
click.echo(f"Restored in {settings_path}: {', '.join(codex_outcome.restored)}.")
else:
click.echo(f"Nothing in {settings_path} was still ours to restore.")
if codex_outcome.kept:
click.echo(f"Left as you changed them since: {', '.join(codex_outcome.kept)}.")
except (ClaudeSettingsError, CodexSettingsError) as error:
raise click.ClickException(str(error)) from error
if forget:
forget_saved_setup(target)
click.echo(f"Forgot saved {target} setup, including its saved key.")
elif profile.exists():
click.echo(f"Saved setup retained. Run `lite configure {target}` to apply it again.")
@click.group(name="unconfigure", invoke_without_command=True)
@click.option("--forget", is_flag=True, help="Also delete saved setups and their keys.")
@click.pass_context
def unconfigure_group(ctx: click.Context, forget: bool) -> None:
"""Disconnect agents while retaining saved setup for `lite configure`."""
if ctx.invoked_subcommand is not None:
return
with setup_locks(TARGETS):
for target in TARGETS:
_disconnect(target, forget)
class _UnconfigureOptions(BaseModel):
forget: bool = False
def _unconfigure_target(ctx: click.Context, target: Target, forget: bool) -> None:
parent: Final = _UnconfigureOptions.model_validate(ctx.parent.params) if ctx.parent else _UnconfigureOptions()
with setup_locks((target,)):
_disconnect(target, forget or parent.forget)
@unconfigure_group.command(name="claude")
def unconfigure_claude() -> None:
"""Return Claude Code's settings to what they were before `lite configure claude`.
@click.option("--forget", is_flag=True, help="Also delete the saved Claude Code setup and key.")
@click.pass_context
def unconfigure_claude(ctx: click.Context, forget: bool) -> None:
"""Restore Claude Code settings, including those applied by `lite login --config-claude`."""
_unconfigure_target(ctx, "claude", forget)
Also undoes `lite login --config-claude`. Only keys still holding what configure wrote are
put back; anything you changed since is left as it is and named in the output.
"""
settings_path: Final = claude_settings_path(os.environ)
state_path: Final = configure_state_path(settings_path)
try:
outcome: Final = unconfigure_claude_settings(settings_path, state_path, settings_file_owners(settings_path))
except ClaudeSettingsError as e:
raise click.ClickException(str(e))
_report_unconfigure(settings_path, state_path, outcome)
@unconfigure_group.command(name="codex")
@click.option("--forget", is_flag=True, help="Also delete the saved Codex setup and key.")
@click.pass_context
def unconfigure_codex(ctx: click.Context, forget: bool) -> None:
"""Restore Codex settings still holding what configure wrote."""
_unconfigure_target(ctx, "codex", forget)
def _report_unconfigure(settings_path: Path, state_path: Path, outcome: UnconfigureOutcome) -> None:
@ -434,4 +300,11 @@ def _report_unconfigure(settings_path: Path, state_path: Path, outcome: Unconfig
)
__all__ = ("configure_group", "interactive_configure", "resolve_credential", "unconfigure_group")
__all__ = (
"configure_group",
"inquirer",
"interactive_configure",
"reconfigure_group",
"resolve_credential",
"unconfigure_group",
)

View file

@ -0,0 +1,158 @@
"""Reusable agent setup, separate from the settings writers' undo receipts."""
import hashlib
import os
from collections.abc import Generator, Sequence
from contextlib import ExitStack, contextmanager
from pathlib import Path
from typing import Final, Literal, TypeAlias
import click
from filelock import FileLock, Timeout
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
from litellm.litellm_core_utils.private_json import (
commit_staged_json,
discard_staged_json,
ensure_private_dir,
stage_private_json,
)
from .agents import codex_config_path
from .claude_settings import claude_settings_path, configure_state_path
from .codex_settings import codex_configure_state_path
from .config import normalize_base_url
Target: TypeAlias = Literal["claude", "codex"]
TARGETS: Final[tuple[Target, ...]] = ("claude", "codex")
class SavedSetup(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid", strict=True)
version: Literal[1] = 1
target: Target
settings_path: str
base_url: str
api_key: str = Field(repr=False)
model: str | None
@field_validator("base_url")
@classmethod
def normalized_gateway(cls, value: str) -> str:
try:
if normalize_base_url(value).removesuffix("/v1") != value:
raise ValueError("Gateway must be normalized")
except click.UsageError as error:
raise ValueError("Invalid gateway URL") from error
return value
@field_validator("api_key")
@classmethod
def valid_key(cls, value: str) -> str:
if not value or any(ord(char) <= 32 or ord(char) == 127 for char in value):
raise ValueError("Invalid virtual key")
return value
@field_validator("model")
@classmethod
def valid_model(cls, value: str | None) -> str | None:
if value is not None and (not value or any(ord(char) < 32 or ord(char) == 127 for char in value)):
raise ValueError("Invalid model choice")
return value
def settings_path_for(target: Target) -> Path:
return claude_settings_path(os.environ) if target == "claude" else codex_config_path(os.environ)
def receipt_path_for(target: Target, settings_path: Path) -> Path:
return configure_state_path(settings_path) if target == "claude" else codex_configure_state_path(settings_path)
def setup_profile_path(target: Target, settings_path: Path) -> Path:
receipt: Final = receipt_path_for(target, settings_path)
return receipt.with_name(f"{receipt.stem}_profile.json")
def read_saved_setup(target: Target) -> SavedSetup | None:
settings_path: Final = settings_path_for(target)
path: Final = setup_profile_path(target, settings_path)
try:
payload: Final = path.read_bytes()
except FileNotFoundError:
return None
except OSError as error:
raise click.ClickException(
f"Could not read saved {target} setup at {path}; no settings were changed"
) from error
try:
saved: Final = SavedSetup.model_validate_json(payload)
if (
saved.target != target
or saved.settings_path != str(settings_path.resolve())
or (target == "codex" and saved.model is None)
):
raise ValueError("Invalid saved setup")
return saved
except (ValidationError, ValueError, click.UsageError) as error:
raise click.ClickException(
f"Saved {target} setup at {path} is invalid or unsupported. "
f"Run `lite unconfigure {target} --forget` to discard it; no settings were changed"
) from error
def _lock_path(target: Target) -> Path:
digest: Final = hashlib.sha256(f"{target}:{settings_path_for(target).resolve()}".encode()).hexdigest()
return Path.home() / ".litellm" / "setup-locks" / f"{digest}.lock"
@contextmanager
def setup_locks(targets: Sequence[Target]) -> Generator[None, None, None]:
with ExitStack() as stack:
try:
for path in tuple(_lock_path(target) for target in sorted(frozenset(targets))):
ensure_private_dir(path.parent)
stack.enter_context(FileLock(str(path), timeout=10, mode=0o600))
except (OSError, Timeout) as error:
raise click.ClickException(
"Could not lock agent setup; retry when other configure commands finish"
) from error
yield
def save_setup(saved: SavedSetup) -> None:
path: Final = setup_profile_path(saved.target, settings_path_for(saved.target))
try:
ensure_private_dir(path.parent)
staged: Final = stage_private_json(
str(path),
{ # mutable-ok: private_json serializes with json.dump, which requires a dict
"version": saved.version,
"target": saved.target,
"settings_path": saved.settings_path,
"base_url": saved.base_url,
"api_key": saved.api_key,
"model": saved.model,
},
)
except OSError as error:
raise click.ClickException(
f"Could not save {saved.target} setup; no {saved.target} settings were changed"
) from error
try:
commit_staged_json(staged, str(path))
except OSError as error:
raise click.ClickException(
f"Could not save {saved.target} setup; no {saved.target} settings were changed"
) from error
finally:
discard_staged_json(staged)
def forget_saved_setup(target: Target) -> None:
path: Final = setup_profile_path(target, settings_path_for(target))
try:
path.unlink(missing_ok=True)
except OSError as error:
raise click.ClickException(f"Could not remove saved {target} setup at {path}") from error

View file

@ -0,0 +1,439 @@
"""Persistent Claude Code and Codex gateway configuration."""
import os
import sys
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from functools import partial
from types import MappingProxyType
from typing import Final
import click
import requests
from InquirerPy import inquirer
from InquirerPy.base.control import Choice
from pydantic import BaseModel, TypeAdapter, ValidationError
from litellm.proxy.common_utils.model_listing_utils import (
CLAUDE_CODE_CLIENT,
CLAUDE_CODE_PICKER_PATTERN,
GATEWAY_CLIENT_HEADER,
)
from .agents import codex_config_path
from .claude_settings import (
STARTING_MODEL_ROLE,
ClaudeSettingsError,
ModelChoice,
StartOn,
StaticToken,
UnpinModel,
claude_settings_path,
configure_claude_settings,
configure_state_path,
preflight_claude_settings,
settings_file_owners,
)
from .codex_settings import (
CodexSettingsError,
configure_codex_settings,
preflight_codex_settings,
)
from .config import normalize_base_url
from .configure_profiles import (
TARGETS,
SavedSetup,
Target,
read_saved_setup,
save_setup,
settings_path_for,
setup_locks,
)
from .pi import ListedModel, ListingFailure, PiSyncError, fetch_model_listing
_LISTED_MODELS_SHOWN: Final = 20
_CLAUDE_TARGET: Final = "claude"
_CODEX_TARGET: Final = "codex"
_TARGETS: Final = ((_CLAUDE_TARGET, "Claude Code (CLI)"), (_CODEX_TARGET, "Codex (CLI)"))
_KEEP_DEFAULT_MODEL: Final = "Keep Claude Code's own default"
_CLAUDE_CODE_VIEW: Final = MappingProxyType(
{"anthropic-version": "2023-06-01", GATEWAY_CLIENT_HEADER: CLAUDE_CODE_CLIENT}
)
MODEL_OPTION_HELP: Final = (
f"Proxy model to set as {STARTING_MODEL_ROLE}. Must be listed on /v1/models for the key; omission keeps "
"the saved choice. Use --default-model to stop pinning a model. Nothing pins Claude "
"Code's sub-agent or background tiers; `lite autoroute start` is the mode that does."
)
_TARGET_SELECTION: Final = TypeAdapter(tuple[Target, ...])
_MODEL_SELECTION: Final = TypeAdapter(str)
class ConnectionSettings(BaseModel):
base_url: str
base_url_explicit: bool = False
api_key: str | None = None
api_key_from_token_file: bool = False
def resolve_credential(ctx: click.Context, api_key: str | None) -> StaticToken:
"""The long-lived key written into settings.json: --api-key, `lite --api-key` or LITELLM_PROXY_API_KEY.
A `lite login` credential is never written: it expires within a day, and keeping it fresh would mean
Claude Code running `lite` through `apiKeyHelper` on every credential refresh.
"""
ctx_obj: Final = ConnectionSettings.model_validate(ctx.find_object(object))
explicit: Final = api_key if api_key is not None else (None if ctx_obj.api_key_from_token_file else ctx_obj.api_key)
if explicit is None:
raise ClaudeSettingsError(
"`lite configure` needs a long-lived virtual key: pass --api-key, `lite --api-key`, or set "
"LITELLM_PROXY_API_KEY. Your `lite login` credential expires within a day, so it is not written "
"into agent settings."
)
if not explicit.strip() or any(ord(char) <= 32 or ord(char) == 127 for char in explicit):
raise ClaudeSettingsError("The virtual key must not be blank or contain whitespace or control characters.")
return StaticToken(explicit)
@dataclass(frozen=True, slots=True)
class _Listing:
models: tuple[ListedModel, ...]
@property
def ids(self) -> tuple[str, ...]:
return tuple(model.id for model in self.models)
def _preflight(target: Target) -> None:
try:
if target == _CLAUDE_TARGET:
preflight_claude_settings(claude_settings_path(os.environ))
else:
preflight_codex_settings(codex_config_path(os.environ))
except (ClaudeSettingsError, CodexSettingsError) as e:
raise click.ClickException(str(e)) from e
def _listing_error(base_url: str, error: PiSyncError, target: str) -> str:
"""The hint that fits how the listing failed: only an unreachable proxy gets the "is it running" question."""
if error.kind is ListingFailure.REJECTED:
return f"LiteLLM rejected your key (HTTP {error.status}). Pass a valid --api-key."
if error.kind is ListingFailure.UNREACHABLE:
return (
f"Could not connect. Is the proxy at {base_url} running, and is --base-url (or LITELLM_PROXY_URL) correct?"
)
if error.kind is ListingFailure.EMPTY:
name: Final = "Claude Code" if target == _CLAUDE_TARGET else "Codex"
return f"{error.message} {name} would have nothing to run; give the key access to at least one model."
return f"The proxy at {base_url} answered, so check that it is a LiteLLM proxy and is healthy."
def _fetch_models(base_url: str, key: str, target: Target) -> tuple[ListedModel, ...] | PiSyncError:
return fetch_model_listing(
base_url,
key,
get=partial(requests.get, allow_redirects=False),
headers=_CLAUDE_CODE_VIEW if target == _CLAUDE_TARGET else MappingProxyType({}),
)
def _connection_listing(
ctx: click.Context,
base_url: str,
credential: StaticToken,
target: Target,
repair: bool,
) -> tuple[StaticToken, _Listing]:
listed: Final = _fetch_models(base_url, credential.token, target)
if not isinstance(listed, PiSyncError):
return credential, _Listing(listed)
if not repair or listed.kind is not ListingFailure.REJECTED:
raise click.ClickException(_listing_error(base_url, listed, target))
replacement: Final = click.prompt("Replacement virtual key", hide_input=True, show_default=False)
try:
refreshed: Final = resolve_credential(ctx, replacement)
except ClaudeSettingsError as error:
raise click.ClickException(str(error)) from error
retried: Final = _fetch_models(base_url, refreshed.token, target)
if isinstance(retried, PiSyncError):
raise click.ClickException(_listing_error(base_url, retried, target))
return refreshed, _Listing(retried)
def _starting_model(model: str, listing: _Listing) -> str | None:
source: Final = next((listed.id for listed in listing.models if listed.source_model == model), None)
return source or next((listed.id for listed in listing.models if listed.id == model), None)
def _model_choice(model: str | None) -> ModelChoice:
return StartOn(model) if model is not None else UnpinModel()
def _validated_model(model: str | None, listing: _Listing, base_url: str) -> str | None:
starting: Final = _starting_model(model, listing) if model is not None else None
if model is not None and starting is None:
shown: Final = ", ".join(listing.ids[:_LISTED_MODELS_SHOWN])
raise click.ClickException(f"{model!r} is not served by {base_url} for this key. /v1/models lists: {shown}.")
return starting
def _apply_claude(base_url: str, credential: StaticToken, listing: _Listing, model: str | None) -> None:
listed: Final = listing.ids
starting: Final = _validated_model(model, listing, base_url)
settings_path: Final = claude_settings_path(os.environ)
try:
configure_claude_settings(
base_url,
credential,
_model_choice(starting),
settings_path,
configure_state_path(settings_path),
settings_file_owners(settings_path),
)
except ClaudeSettingsError as e:
raise click.ClickException(str(e))
in_picker: Final = sum(1 for listed_model in listed if CLAUDE_CODE_PICKER_PATTERN.search(listed_model))
click.echo(f"Configured Claude Code: {settings_path} now routes through {base_url}.")
click.echo("Credential: your virtual key, stored in the file as ANTHROPIC_AUTH_TOKEN.")
click.echo(
f"Starting model: {starting} ({STARTING_MODEL_ROLE}); switch any time with /model."
if starting is not None
else "Starting model: not pinned (Claude Code's default, or a model you set yourself); switch with /model, or "
"pass --model to start on a proxy model. Without a pin, a resumed session re-sends the model its transcript "
"recorded, which behind a raw-model auto-router is the tier model."
)
click.echo(
f"/model will list all {len(listed)} of the proxy's models."
if in_picker == len(listed)
else f"/model will list {in_picker} of the proxy's {len(listed)} models: Claude Code shows only ids containing "
"'claude' or 'anthropic', and this proxy does not list the rest under such names."
)
click.echo("Start `claude` from any terminal. Undo with `lite unconfigure claude`.")
if settings_path.is_symlink():
click.echo(
f"Note: {settings_path} is a symlink to {settings_path.resolve()}, so your key now lives in "
"that file; keep it out of version control.",
err=True,
)
def _has_targets(chosen: Sequence[object]) -> bool:
return bool(chosen)
def pick_targets(defaults: tuple[Target, ...] = ("claude", "codex"), *, edit: bool = False) -> tuple[Target, ...]:
choices: Final = [ # mutable-ok: InquirerPy requires a list
Choice(value, name=label, enabled=value in defaults) for value, label in _TARGETS
]
picked: Final = _TARGET_SELECTION.validate_python(
inquirer.checkbox(
message="Which agents should be edited? Unselected agents keep their current setup"
if edit
else "Which agents should route through LiteLLM?",
choices=choices,
validate=_has_targets,
invalid_message="Pick at least one.",
).execute()
)
return tuple(target for target in TARGETS if target in picked)
def _pick_model(listed: Sequence[str], default: str | None = None) -> str | None:
choices: Final = [_KEEP_DEFAULT_MODEL, *listed] # mutable-ok: InquirerPy requires a list
picked: Final = _MODEL_SELECTION.validate_python(
inquirer.fuzzy(
message="Model Claude Code starts on (type to filter; /model switches any time):",
choices=choices,
default=default if default in listed else _KEEP_DEFAULT_MODEL,
).execute()
)
return None if picked == _KEEP_DEFAULT_MODEL else picked
def _pick_codex_model(listed: Sequence[str], default: str | None = None) -> str:
choices: Final = list(listed) # mutable-ok: InquirerPy's choices parameter requires a list
return _MODEL_SELECTION.validate_python(
inquirer.fuzzy(
message="Model Codex starts on (type to filter):",
choices=choices,
default=default if default in listed else listed[0],
).execute()
)
def _apply_codex(base_url: str, credential: StaticToken, listing: _Listing, model: str) -> None:
_validated_model(model, listing, base_url)
settings_path: Final = codex_config_path(os.environ)
try:
configure_codex_settings(base_url, credential.token, model, settings_path)
except CodexSettingsError as e:
raise click.ClickException(str(e)) from e
click.echo(f"Configured Codex: {settings_path} now routes through {base_url}.")
click.echo(f"Starting model: {model}. Credential: your virtual key, stored in the private provider settings.")
click.echo("Start `codex` from any terminal. Undo with `lite unconfigure codex`.")
if settings_path.is_symlink():
click.echo(f"Note: your key now lives in {settings_path.resolve()}; keep it out of version control.", err=True)
@dataclass(frozen=True, slots=True)
class PreparedSetup:
saved: SavedSetup
listing: _Listing
def _connection(ctx: click.Context, saved: SavedSetup | None) -> tuple[str, StaticToken]:
settings: Final = ConnectionSettings.model_validate(ctx.find_object(object))
base_url: Final = settings.base_url if saved is None or settings.base_url_explicit else saved.base_url
supplied_key: Final = None if settings.api_key_from_token_file else settings.api_key
reusable_key: Final = saved.api_key if saved is not None and saved.base_url == base_url else None
try:
credential: Final = resolve_credential(ctx, supplied_key if supplied_key is not None else reusable_key)
except ClaudeSettingsError as error:
if saved is not None and saved.base_url != base_url and supplied_key is None:
raise click.ClickException(
"The gateway changed. Pass --api-key for the new gateway; the saved key was not used"
) from error
raise click.ClickException(str(error)) from error
return base_url, credential
def _prompt_connection(ctx: click.Context, target: Target, saved: SavedSetup | None) -> tuple[str, StaticToken]:
settings: Final = ConnectionSettings.model_validate(ctx.find_object(object))
default_url: Final = settings.base_url if saved is None or settings.base_url_explicit else saved.base_url
base_url: Final = normalize_base_url(
click.prompt(f"{target.capitalize()} gateway URL", default=default_url)
).removesuffix("/v1")
supplied_key: Final = None if settings.api_key_from_token_file else settings.api_key
kept_key: Final = (
supplied_key
if supplied_key is not None
else (saved.api_key if saved is not None and saved.base_url == base_url else None)
)
entered: Final = click.prompt(
"Virtual key (press Enter to keep the current key)" if kept_key is not None else "Virtual key",
default="" if kept_key is not None else None,
show_default=False,
hide_input=True,
)
key: Final[str | None] = entered or kept_key
try:
return base_url, resolve_credential(ctx, key)
except ClaudeSettingsError as error:
raise click.ClickException(str(error)) from error
def _prepare(
ctx: click.Context,
target: Target,
saved: SavedSetup | None,
model: str | None,
default_model: bool,
*,
interactive: bool = False,
edit_connection: bool = False,
pick_model: Callable[[Sequence[str]], str | None] | None = None,
pick_codex_model: Callable[[Sequence[str]], str] | None = None,
) -> PreparedSetup:
default: Final = None if default_model else (model if model is not None else (saved.model if saved else None))
if target == "codex" and default is None and not interactive:
raise click.UsageError("Missing option '--model'. First-time Codex setup needs a starting model")
base_url, credential = _prompt_connection(ctx, target, saved) if edit_connection else _connection(ctx, saved)
repair: Final = saved is not None and not interactive and sys.stdin.isatty()
active_credential, listing = _connection_listing(ctx, base_url, credential, target, repair)
repair_model: Final = repair and default is not None and _starting_model(default, listing) is None
source_names: Final = tuple(item.source_model or item.id for item in listing.models)
chosen: Final = (
(pick_model(source_names) if pick_model is not None else _pick_model(source_names, default))
if (interactive or repair_model) and target == "claude"
else (
pick_codex_model(listing.ids) if pick_codex_model is not None else _pick_codex_model(listing.ids, default)
)
if interactive or repair_model
else default
)
if target == "codex" and chosen is None:
raise click.ClickException("First-time Codex setup needs --model. Run `lite configure` for the model picker")
validated: Final = _validated_model(chosen, listing, base_url)
saved_model: Final = (
next(item.source_model or item.id for item in listing.models if item.id == validated)
if target == "claude" and validated is not None
else chosen
)
try:
profile: Final = SavedSetup(
target=target,
settings_path=str(settings_path_for(target).resolve()),
base_url=base_url,
api_key=active_credential.token,
model=saved_model,
)
except ValidationError as error:
raise click.ClickException("Invalid gateway setup; no settings were changed") from error
return PreparedSetup(profile, listing)
def _apply(setup: PreparedSetup) -> None:
saved: Final = setup.saved
save_setup(saved)
try:
if saved.target == "claude":
_apply_claude(saved.base_url, StaticToken(saved.api_key), setup.listing, saved.model)
elif saved.model is not None:
_apply_codex(saved.base_url, StaticToken(saved.api_key), setup.listing, saved.model)
except click.ClickException as error:
raise click.ClickException(
f"{error.format_message()} {saved.target.capitalize()} setup was saved. "
f"Run `lite configure {saved.target}` to retry applying it"
) from error
click.echo(
f"Setup saved. Edit with `lite reconfigure {saved.target}`; "
f"remove saved settings and key with `lite unconfigure {saved.target} --forget`."
)
def configure_targets(
ctx: click.Context,
targets: tuple[Target, ...],
*,
model: str | None = None,
default_model: bool = False,
interactive: bool = False,
edit_connection: bool = False,
pick_model: Callable[[Sequence[str]], str | None] | None = None,
pick_codex_model: Callable[[Sequence[str]], str] | None = None,
) -> None:
if model is not None and default_model:
raise click.UsageError("--model and --default-model cannot be used together")
for target in targets:
_preflight(target)
setups: Final = tuple(
_prepare(
ctx,
target,
read_saved_setup(target),
model,
default_model,
interactive=interactive,
edit_connection=edit_connection,
pick_model=pick_model,
pick_codex_model=pick_codex_model,
)
for target in targets
)
for setup in setups:
_apply(setup)
def interactive_configure(
ctx: click.Context,
pick_targets: Callable[[], tuple[str, ...]] = pick_targets,
pick_model: Callable[[Sequence[str]], str | None] | None = None,
pick_codex_model: Callable[[Sequence[str]], str] | None = None,
) -> None:
"""Configure selected agents, retaining injectable pickers for embedders."""
selected: Final = pick_targets()
targets: Final[tuple[Target, ...]] = tuple(target for target in TARGETS if target in selected)
if not targets:
return
with setup_locks(targets):
configure_targets(ctx, targets, interactive=True, pick_model=pick_model, pick_codex_model=pick_codex_model)

View file

@ -21,7 +21,7 @@ from .commands.auth import (
from .commands.autoroute.commands import autoroute_group
from .commands.chat import chat
from .commands.config import config_commands, get_config_value, hidden_command_names
from .commands.configure import configure_group, unconfigure_group
from .commands.configure import configure_group, reconfigure_group, unconfigure_group
from .commands.credentials import credentials
from .commands.debug import debug
from .commands.encryption import encryption
@ -103,7 +103,8 @@ def cli(ctx: click.Context, show_version: bool, base_url: str | None, api_key: s
# If no API key provided via flag or environment variable, try to load from saved token.
# Pass base_url so we only use the stored key when it was issued for this server.
api_key_from_token_file: Final = api_key is None and ctx.invoked_subcommand not in ("configure", "unconfigure")
setup_command: Final = ctx.invoked_subcommand in ("configure", "reconfigure", "unconfigure")
api_key_from_token_file: Final = api_key is None and not setup_command
resolved_api_key: Final = (
get_stored_api_key(expected_base_url=base_url, vault=context_secret_vault(ctx))
if api_key_from_token_file
@ -119,7 +120,7 @@ def cli(ctx: click.Context, show_version: bool, base_url: str | None, api_key: s
# "user said localhost:4000 on purpose" so they can fall back to
# whatever server the stored token was actually issued for. A base_url
# saved via `lite config set` counts as the user saying it.
ctx.obj["base_url_explicit"] = base_url_provided or bool(stored_base_url)
ctx.obj["base_url_explicit"] = base_url_provided or (bool(stored_base_url) and not setup_command)
if show_version:
print_version(base_url, resolved_api_key)
@ -174,6 +175,7 @@ cli.add_command(autoroute_group, name="autoroute")
cli.add_command(config_commands)
# Add configure/unconfigure (persistently wire a coding agent to the proxy with a virtual key)
cli.add_command(configure_group)
cli.add_command(reconfigure_group)
cli.add_command(unconfigure_group)

View file

@ -5,6 +5,7 @@ import stat
import time
from pathlib import Path
from types import SimpleNamespace
from typing import Final, Literal
import click
import pytest
@ -12,6 +13,8 @@ import requests
import responses
import tomlkit
from click.testing import CliRunner
from InquirerPy.base.control import Choice
from pydantic import JsonValue, TypeAdapter
from litellm.proxy.client.cli import cli
from litellm.proxy.client.cli.commands import claude_settings as claude_settings_module
@ -393,7 +396,7 @@ class TestConfigureAgents:
)
assert (settings_path.read_bytes(), codex_path.read_bytes()) == before
assert not state_path.exists()
assert not (codex_path.parent / ".litellm").exists()
assert not tuple((codex_path.parent / ".litellm").glob("*.json"))
@responses.activate
def test_both_configs_are_preflighted_before_fetching_models_or_writing(
@ -433,7 +436,7 @@ class TestConfigureAgents:
assert VALID_KEY not in str(caught.value)
assert len(responses.calls) == 0
assert not paths[0].exists() and not paths[1].exists()
assert not codex_path.exists() and not (codex_path.parent / ".litellm").exists()
assert not codex_path.exists() and not tuple((codex_path.parent / ".litellm").glob("*.json"))
@responses.activate
def test_claude_only_configuration_does_not_require_codex(
@ -509,7 +512,7 @@ class TestConfigureAgents:
assert not paths[0].exists() and not codex_path.exists()
@responses.activate
def test_configure_and_unconfigure_do_not_read_a_stored_login(
def test_configure_reconfigure_and_unconfigure_do_not_read_a_stored_login(
self, runner, paths, codex_path, tmp_path, secret_vault_factory, fake_codex_version
):
_mock_agent_models()
@ -528,12 +531,17 @@ class TestConfigureAgents:
obj={"secret_vault": vault},
)
assert configured.exit_code == 0, configured.output
reconfigured: Final = runner.invoke(
cli, ["reconfigure", "codex", "--model", "auto"], obj={"secret_vault": vault}
)
assert reconfigured.exit_code == 0, reconfigured.output
fake_codex_version(None, 0)
undone = runner.invoke(cli, ["unconfigure", "codex"], obj={"secret_vault": vault})
assert undone.exit_code == 0, undone.output
assert vault.reads == 0 and vault.writes == [] and vault.erases == 0
assert not codex_path.exists() and not paths[0].exists()
assert "Removed" in undone.output and "sk-login" not in missing.output + configured.output + undone.output
assert "Removed" in undone.output
assert "sk-login" not in missing.output + configured.output + reconfigured.output + undone.output
class TestUnconfigureClaude:
@ -599,9 +607,14 @@ class TestUnconfigureClaude:
assert str(state_path) in result.output and state_path.exists()
assert "sk-ant" not in result.output
def test_refuses_while_lite_up_holds_a_backup(self, runner, paths, lite_up_backup):
result = runner.invoke(cli, ["unconfigure", "claude"])
assert result.exit_code != 0 and "lite down" in result.output
def test_disconnected_unconfigure_does_not_touch_lite_up_backup(
self, runner: CliRunner, paths: tuple[Path, Path], lite_up_backup: Path
) -> None:
result: Final = runner.invoke(cli, ["unconfigure", "claude"])
assert result.exit_code == 0, result.output
assert "nothing to undo" in result.output
assert "Agent settings were not changed" in result.output
assert lite_up_backup.read_text() == "{}"
@responses.activate
def test_a_config_dir_is_configured_and_undone_apart_from_the_default_file(
@ -625,12 +638,12 @@ class TestUnconfigureClaude:
assert undone.exit_code == 0, undone.output
assert json.loads((work_dir / "settings.json").read_text()) == original
assert not default_settings.exists() and not default_state.exists()
assert runner.invoke(cli, ["unconfigure", "claude"]).exit_code != 0, "the receipt is gone with the undo"
assert runner.invoke(cli, ["unconfigure", "claude"]).exit_code == 0
def test_without_a_receipt_it_fails_loudly(self, runner, paths):
def test_without_a_receipt_it_reports_nothing_to_undo(self, runner, paths):
result = runner.invoke(cli, ["unconfigure", "claude"])
assert result.exit_code != 0
assert "nothing to undo" in result.output
assert result.exit_code == 0, result.output
assert "nothing to undo" in result.output.lower()
class TestClaudeCodeView:
@ -702,3 +715,534 @@ class TestClaudeCodeView:
result = _configure(runner, "--api-key", VALID_KEY)
assert result.exit_code == 0, result.output
assert "/model will list 1 of the proxy's 2 models: Claude Code shows only ids containing" in result.output
def _saved_profile_path(target: Literal["claude", "codex"], settings_path: Path) -> Path:
from litellm.proxy.client.cli.commands.configure_profiles import setup_profile_path
return setup_profile_path(target, settings_path)
def _configure_saved_agent(runner: CliRunner, target: Literal["claude", "codex"]) -> None:
result: Final = runner.invoke(
cli,
["configure", "--gateway-url", PROXY, "--api-key", VALID_KEY, target, "--model", "auto"],
)
assert result.exit_code == 0, result.output
def _agent_document(settings_path: Path) -> dict[str, JsonValue]:
adapter: Final = TypeAdapter(dict[str, JsonValue])
if settings_path.suffix == ".json":
return adapter.validate_json(settings_path.read_text())
return adapter.validate_python(tomlkit.parse(settings_path.read_text()).unwrap())
def _prompt_answer(answer: str | tuple[str, ...]) -> SimpleNamespace:
def execute() -> str | tuple[str, ...]:
return answer
return SimpleNamespace(execute=execute)
class TestSavedAgentSetup:
@responses.activate
@pytest.mark.parametrize("target", ["claude", "codex"])
@pytest.mark.parametrize("resume", [("configure",), None], ids=["all", "target"])
def test_disconnect_then_configure_reuses_connection_and_model_without_prompts(
self,
runner: CliRunner,
paths: tuple[Path, Path],
codex_path: Path,
target: Literal["claude", "codex"],
resume: tuple[str, ...] | None,
) -> None:
_mock_agent_models()
settings_path: Final = paths[0] if target == "claude" else codex_path
_configure_saved_agent(runner, target)
configured: Final = _agent_document(settings_path)
undone: Final = runner.invoke(cli, ["unconfigure", target])
assert undone.exit_code == 0, undone.output
assert not settings_path.exists()
resumed: Final = runner.invoke(cli, list(resume or ("configure", target)))
assert resumed.exit_code == 0, resumed.output
assert _agent_document(settings_path) == configured
assert "lite configure" in undone.output and "saved" in undone.output.lower()
repeated: Final = runner.invoke(cli, ["configure", target])
assert repeated.exit_code == 0, repeated.output
assert _agent_document(settings_path) == configured
restored: Final = runner.invoke(cli, ["unconfigure", target])
assert restored.exit_code == 0, restored.output
assert not settings_path.exists()
assert VALID_KEY not in resumed.output + repeated.output + restored.output
@responses.activate
def test_resume_both_agents_captures_the_settings_changed_while_disconnected(
self, runner: CliRunner, paths: tuple[Path, Path], codex_path: Path
) -> None:
_mock_agent_models()
_configure_saved_agent(runner, "claude")
codex_url: Final = "https://codex-gateway.test/prefix"
responses.get(
f"{codex_url}/v1/models",
json={"data": [{"id": "auto"}]},
match=[responses.matchers.header_matcher({"Authorization": "Bearer sk-codex"})],
)
codex_setup: Final = runner.invoke(
cli,
["configure", "codex", "--gateway-url", codex_url, "--api-key", "sk-codex", "--model", "auto"],
)
assert codex_setup.exit_code == 0, codex_setup.output
undone: Final = runner.invoke(cli, ["unconfigure"])
assert undone.exit_code == 0, undone.output
paths[0].write_text('{"theme": "light", "model": "personal-claude"}')
codex_path.write_text('model = "personal-codex"\napproval_policy = "on-request"\n')
resumed: Final = runner.invoke(cli, ["configure"])
assert resumed.exit_code == 0, resumed.output
assert json.loads(paths[0].read_text())["model"] == "claude-router-6175746f"
assert tomlkit.parse(codex_path.read_text())["model"] == "auto"
assert responses.calls[-1].request.url == f"{codex_url}/v1/models"
restored: Final = runner.invoke(cli, ["unconfigure"])
assert restored.exit_code == 0, restored.output
assert json.loads(paths[0].read_text()) == {"theme": "light", "model": "personal-claude"}
assert tomlkit.parse(codex_path.read_text()) == {
"model": "personal-codex", "approval_policy": "on-request"
}
@responses.activate
@pytest.mark.parametrize("disconnected", [False, True], ids=["active", "disconnected"])
@pytest.mark.parametrize(
"forget, forgotten",
[
(("unconfigure", "--forget", "claude"), ("claude",)),
(("unconfigure", "codex", "--forget"), ("codex",)),
(("unconfigure", "--forget"), ("claude", "codex")),
],
ids=["group-option-target", "leaf-option", "all"],
)
def test_forget_removes_only_selected_saved_setups_even_after_disconnect(
self,
runner: CliRunner,
paths: tuple[Path, Path],
codex_path: Path,
disconnected: bool,
forget: tuple[str, ...],
forgotten: tuple[str, ...],
) -> None:
_mock_agent_models()
_configure_saved_agent(runner, "claude")
_configure_saved_agent(runner, "codex")
if disconnected:
undone: Final = runner.invoke(cli, ["unconfigure"])
assert undone.exit_code == 0, undone.output
result: Final = runner.invoke(cli, list(forget))
assert result.exit_code == 0, result.output
for target, settings_path in (("claude", paths[0]), ("codex", codex_path)):
assert _saved_profile_path(target, settings_path).exists() == (target not in forgotten)
resumed: Final = runner.invoke(cli, ["configure", target])
assert (resumed.exit_code == 0) == (target not in forgotten), resumed.output
assert settings_path.exists() == (target not in forgotten)
@responses.activate
@pytest.mark.parametrize("target", ["claude", "codex"])
@pytest.mark.parametrize("source", ["leaf", "global", "environment"])
def test_saved_key_never_follows_a_gateway_override_without_a_replacement(
self,
runner: CliRunner,
paths: tuple[Path, Path],
codex_path: Path,
monkeypatch: pytest.MonkeyPatch,
target: Literal["claude", "codex"],
source: str,
) -> None:
_mock_agent_models()
_configure_saved_agent(runner, target)
undone: Final = runner.invoke(cli, ["unconfigure", target])
assert undone.exit_code == 0, undone.output
replacement_url: Final = "https://replacement.test/gateway"
if source == "environment":
monkeypatch.setenv("LITELLM_PROXY_URL", replacement_url)
args: Final = (
["--base-url", replacement_url, "configure", target]
if source == "global"
else ["configure", target, "--gateway-url", replacement_url]
if source == "leaf"
else ["configure", target]
)
refused: Final = runner.invoke(cli, args)
assert refused.exit_code != 0, refused.output
assert "--api-key" in refused.output and VALID_KEY not in refused.output
assert len(responses.calls) == 1
assert not paths[0].exists() and not codex_path.exists()
responses.get(
f"{replacement_url}/v1/models",
json={"data": [{"id": "auto"}]},
match=[responses.matchers.header_matcher({"Authorization": "Bearer sk-replacement"})],
)
replaced: Final = runner.invoke(cli, [*args, "--api-key", "sk-replacement"])
assert replaced.exit_code == 0, replaced.output
assert len(responses.calls) == 2
assert responses.calls[-1].request.url == f"{replacement_url}/v1/models"
assert VALID_KEY not in replaced.output and "sk-replacement" not in replaced.output
@responses.activate
@pytest.mark.parametrize("target", ["claude", "codex"])
def test_saved_setup_is_private_and_scoped_to_the_resolved_agent_home(
self,
runner: CliRunner,
paths: tuple[Path, Path],
codex_path: Path,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
target: Literal["claude", "codex"],
) -> None:
_mock_agent_models()
_configure_saved_agent(runner, target)
settings_path: Final = paths[0] if target == "claude" else codex_path
profile_path: Final = _saved_profile_path(target, settings_path)
assert stat.S_IMODE(profile_path.stat().st_mode) == 0o600
assert stat.S_IMODE(profile_path.parent.stat().st_mode) & 0o077 == 0
undone: Final = runner.invoke(cli, ["unconfigure", target])
assert undone.exit_code == 0, undone.output
alternate_home: Final = tmp_path / f"other-{target}"
alternate_settings: Final = alternate_home / settings_path.name
environment: Final = "CLAUDE_CONFIG_DIR" if target == "claude" else "CODEX_HOME"
monkeypatch.setenv(environment, str(alternate_home))
missing: Final = runner.invoke(cli, ["configure", target])
assert missing.exit_code != 0, missing.output
assert not alternate_settings.exists() and len(responses.calls) == 1
assert profile_path.exists()
stored_url: Final = runner.invoke(cli, ["config", "set", "base_url", "https://other-default.test"])
assert stored_url.exit_code == 0, stored_url.output
monkeypatch.setenv(environment, str(settings_path.parent))
resumed: Final = runner.invoke(cli, ["configure", target])
assert resumed.exit_code == 0, resumed.output
assert settings_path.exists() and not alternate_settings.exists()
@responses.activate
@pytest.mark.parametrize("target", ["claude", "codex"])
@pytest.mark.parametrize("fault", ["json", "version", "target", "path"])
def test_invalid_saved_setup_fails_without_network_or_secret_output_and_can_be_forgotten(
self,
runner: CliRunner,
paths: tuple[Path, Path],
codex_path: Path,
target: Literal["claude", "codex"],
fault: str,
) -> None:
_mock_agent_models()
_configure_saved_agent(runner, target)
settings_path: Final = paths[0] if target == "claude" else codex_path
profile_path: Final = _saved_profile_path(target, settings_path)
profile: Final = TypeAdapter(dict[str, JsonValue]).validate_json(profile_path.read_text())
corrupted: Final = (
"{ " + VALID_KEY
if fault == "json"
else json.dumps({**profile, "version": 999})
if fault == "version"
else json.dumps({**profile, "target": "codex" if target == "claude" else "claude"})
if fault == "target"
else json.dumps({**profile, "settings_path": str(settings_path.parent / "another-file")})
)
undone: Final = runner.invoke(cli, ["unconfigure", target])
assert undone.exit_code == 0, undone.output
profile_path.write_text(corrupted)
failed: Final = runner.invoke(cli, ["configure", target])
assert failed.exit_code != 0, failed.output
assert "saved" in failed.output.lower() and "--forget" in failed.output
assert VALID_KEY not in failed.output
assert not settings_path.exists() and len(responses.calls) == 1
forgotten: Final = runner.invoke(cli, ["unconfigure", "--forget", target])
assert forgotten.exit_code == 0, forgotten.output
assert not profile_path.exists() and not settings_path.exists()
@responses.activate
@pytest.mark.parametrize("target", ["claude", "codex"])
def test_reconfigure_prefills_saved_choices_and_changes_only_the_selected_agent(
self,
runner: CliRunner,
paths: tuple[Path, Path],
codex_path: Path,
monkeypatch: pytest.MonkeyPatch,
target: Literal["claude", "codex"],
) -> None:
_mock_agent_models()
_configure_saved_agent(runner, "claude")
_configure_saved_agent(runner, "codex")
untouched: Final = codex_path if target == "claude" else paths[0]
before: Final = untouched.read_bytes()
responses.replace(
responses.GET, f"{PROXY}/v1/models", json={"data": [{"id": "auto"}, {"id": "replacement"}]}
)
def checkbox(**kwargs: object) -> SimpleNamespace:
choices: Final = kwargs["choices"]
assert isinstance(choices, list) and len(choices) == 2
for choice in choices:
assert isinstance(choice, Choice) and choice.enabled
return _prompt_answer((target,))
def fuzzy(**kwargs: object) -> SimpleNamespace:
assert kwargs["default"] == "auto"
return _prompt_answer("replacement")
monkeypatch.setattr(configure_module.inquirer, "checkbox", checkbox)
monkeypatch.setattr(configure_module.inquirer, "fuzzy", fuzzy)
changed: Final = runner.invoke(cli, ["reconfigure"], input=_TerminalInput(b"\n\n"))
assert changed.exit_code == 0, changed.output
assert PROXY in changed.output and VALID_KEY not in changed.output
assert untouched.read_bytes() == before
undone: Final = runner.invoke(cli, ["unconfigure", target])
assert undone.exit_code == 0, undone.output
resumed: Final = runner.invoke(cli, ["configure", target])
assert resumed.exit_code == 0, resumed.output
if target == "claude":
assert json.loads(paths[0].read_text())["model"] == "replacement"
else:
assert tomlkit.parse(codex_path.read_text())["model"] == "replacement"
assert untouched.read_bytes() == before
@responses.activate
def test_reconfigure_cancel_preserves_every_agents_settings_and_saved_choices(
self,
runner: CliRunner,
paths: tuple[Path, Path],
codex_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_mock_agent_models()
_configure_saved_agent(runner, "claude")
_configure_saved_agent(runner, "codex")
files: Final = (
paths[0], codex_path, _saved_profile_path("claude", paths[0]), _saved_profile_path("codex", codex_path)
)
before: Final = tuple(path.read_bytes() for path in files)
def checkbox(**kwargs: object) -> SimpleNamespace:
return _prompt_answer(("claude", "codex"))
def fuzzy(**kwargs: object) -> SimpleNamespace:
assert tuple(path.read_bytes() for path in files) == before
if "Codex" in str(kwargs["message"]):
raise KeyboardInterrupt()
return _prompt_answer("Keep Claude Code's own default")
monkeypatch.setattr(configure_module.inquirer, "checkbox", checkbox)
monkeypatch.setattr(configure_module.inquirer, "fuzzy", fuzzy)
cancelled: Final = runner.invoke(cli, ["reconfigure"], input=_TerminalInput(b"\n\n\n\n"))
assert cancelled.exit_code != 0, cancelled.output
assert "Aborted" in cancelled.output
assert tuple(path.read_bytes() for path in files) == before
@responses.activate
def test_explicit_default_model_unpins_claude_and_remains_the_saved_choice(
self, runner: CliRunner, paths: tuple[Path, Path]
) -> None:
_mock_agent_models()
_configure_saved_agent(runner, "claude")
changed: Final = runner.invoke(cli, ["reconfigure", "claude", "--default-model"])
assert changed.exit_code == 0, changed.output
assert "model" not in json.loads(paths[0].read_text())
undone: Final = runner.invoke(cli, ["unconfigure", "claude"])
assert undone.exit_code == 0, undone.output
resumed: Final = runner.invoke(cli, ["configure", "claude"])
assert resumed.exit_code == 0, resumed.output
assert "model" not in json.loads(paths[0].read_text())
@responses.activate
@pytest.mark.parametrize("target", ["claude", "codex"])
@pytest.mark.parametrize("fault", ["key", "model"])
def test_terminal_resume_repairs_only_the_rejected_saved_choice(
self,
runner: CliRunner,
paths: tuple[Path, Path],
codex_path: Path,
monkeypatch: pytest.MonkeyPatch,
target: Literal["claude", "codex"],
fault: str,
) -> None:
_mock_agent_models()
_configure_saved_agent(runner, target)
settings_path: Final = paths[0] if target == "claude" else codex_path
profile_path: Final = _saved_profile_path(target, settings_path)
before: Final = profile_path.read_bytes()
undone: Final = runner.invoke(cli, ["unconfigure", target])
assert undone.exit_code == 0, undone.output
responses.reset()
if fault == "key":
responses.get(
f"{PROXY}/v1/models", status=401,
match=[responses.matchers.header_matcher({"Authorization": f"Bearer {VALID_KEY}"})],
)
responses.get(
f"{PROXY}/v1/models",
json={"data": [{"id": "auto" if fault == "key" else "replacement"}]},
match=[responses.matchers.header_matcher({
"Authorization": "Bearer sk-repaired" if fault == "key" else f"Bearer {VALID_KEY}"
})],
)
failed: Final = runner.invoke(cli, ["configure", target])
assert failed.exit_code != 0, failed.output
assert not settings_path.exists() and profile_path.read_bytes() == before
assert len(responses.calls) == 1
def checkbox(**kwargs: object) -> SimpleNamespace:
raise AssertionError("Saved resume must not ask which agents to configure")
def fuzzy(**kwargs: object) -> SimpleNamespace:
assert fault == "model", "A rejected key must not discard the saved model"
return _prompt_answer("replacement")
monkeypatch.setattr(configure_module.inquirer, "checkbox", checkbox)
monkeypatch.setattr(configure_module.inquirer, "fuzzy", fuzzy)
resumed: Final = runner.invoke(
cli, ["configure"], input=_TerminalInput(b"sk-repaired\n" if fault == "key" else b"")
)
assert resumed.exit_code == 0, resumed.output
assert "gateway URL" not in resumed.output
assert VALID_KEY not in resumed.output and "sk-repaired" not in resumed.output
assert settings_path.exists()
saved: Final = TypeAdapter(dict[str, JsonValue]).validate_json(profile_path.read_text())
assert saved["api_key"] == ("sk-repaired" if fault == "key" else VALID_KEY)
assert saved["model"] == ("auto" if fault == "key" else "replacement")
@responses.activate
@pytest.mark.parametrize("target", ["claude", "codex"])
@pytest.mark.parametrize("lost_receipt", [False, True], ids=["malformed-settings", "lost-receipt"])
def test_forget_without_receipt_preserves_agent_settings_and_reports_unknown_connection(
self,
runner: CliRunner,
paths: tuple[Path, Path],
codex_path: Path,
target: Literal["claude", "codex"],
lost_receipt: bool,
) -> None:
from litellm.proxy.client.cli.commands.configure_profiles import receipt_path_for
_mock_agent_models()
_configure_saved_agent(runner, target)
settings_path: Final = paths[0] if target == "claude" else codex_path
profile_path: Final = _saved_profile_path(target, settings_path)
if lost_receipt:
receipt_path_for(target, settings_path).unlink()
else:
undone: Final = runner.invoke(cli, ["unconfigure", target])
assert undone.exit_code == 0, undone.output
settings_path.write_text("[invalid")
before: Final = settings_path.read_bytes()
forgotten: Final = runner.invoke(cli, ["unconfigure", target, "--forget"])
assert forgotten.exit_code == 0, forgotten.output
assert not profile_path.exists()
assert settings_path.read_bytes() == before
assert "Cannot confirm disconnection" in forgotten.output
assert "gateway connection and key manually" in forgotten.output
assert str(settings_path) in forgotten.output
assert "already disconnected" not in forgotten.output and VALID_KEY not in forgotten.output
assert len(responses.calls) == 1
@responses.activate
@pytest.mark.parametrize("target", ["claude", "codex"])
@pytest.mark.parametrize("failure", ["stage_private_json", "commit_staged_json", "apply"])
def test_failed_setup_write_preserves_saved_intent_and_plain_configure_retries_it(
self,
runner: CliRunner,
paths: tuple[Path, Path],
codex_path: Path,
monkeypatch: pytest.MonkeyPatch,
target: Literal["claude", "codex"],
failure: str,
) -> None:
from litellm.proxy.client.cli.commands import configure_profiles, configure_setup
_mock_agent_models()
_configure_saved_agent(runner, target)
settings_path: Final = paths[0] if target == "claude" else codex_path
profile_path: Final = _saved_profile_path(target, settings_path)
receipt_path: Final = configure_profiles.receipt_path_for(target, settings_path)
before: Final = (settings_path.read_bytes(), profile_path.read_bytes(), receipt_path.read_bytes())
original_settings: Final = _agent_document(settings_path)
original_profile: Final = TypeAdapter(dict[str, JsonValue]).validate_json(profile_path.read_text())
replacement_url: Final = "https://replacement.test/gateway"
replacement_key: Final = "sk-replacement"
responses.get(
f"{replacement_url}/v1/models",
json={"data": [{"id": "replacement"}]},
match=[responses.matchers.header_matcher({"Authorization": f"Bearer {replacement_key}"})],
)
def fail_write(*args: object, **kwargs: object) -> str:
raise OSError(f"simulated disk error {VALID_KEY}")
def fail_apply(*args: object, **kwargs: object) -> None:
error: Final = (
configure_setup.ClaudeSettingsError if target == "claude" else configure_setup.CodexSettingsError
)
raise error("simulated agent settings write failure")
with monkeypatch.context() as patch:
if failure == "apply":
patch.setattr(configure_setup, f"configure_{target}_settings", fail_apply)
else:
patch.setattr(configure_profiles, failure, fail_write)
failed: Final = runner.invoke(
cli,
[
"reconfigure", target, "--gateway-url", replacement_url,
"--api-key", replacement_key, "--model", "replacement",
],
)
assert failed.exit_code != 0, failed.output
assert VALID_KEY not in failed.output and replacement_key not in failed.output
assert (settings_path.read_bytes(), receipt_path.read_bytes()) == (before[0], before[2])
saved: Final = TypeAdapter(dict[str, JsonValue]).validate_json(profile_path.read_text())
if failure == "apply":
assert saved == {
**original_profile, "base_url": replacement_url, "api_key": replacement_key, "model": "replacement"
}
assert "simulated agent settings write failure" in failed.output
assert "setup was saved" in failed.output and f"lite configure {target}" in failed.output
else:
assert "could not save" in failed.output.lower()
assert profile_path.read_bytes() == before[1]
retried: Final = runner.invoke(cli, ["configure", target])
assert retried.exit_code == 0, retried.output
written: Final = _agent_document(settings_path)
if failure != "apply":
assert written == original_settings
elif target == "claude":
environment: Final = written["env"]
assert isinstance(environment, dict)
assert (environment["ANTHROPIC_BASE_URL"], environment["ANTHROPIC_AUTH_TOKEN"], written["model"]) == (
replacement_url, replacement_key, "replacement"
)
else:
providers: Final = written["model_providers"]
assert isinstance(providers, dict)
provider: Final = providers["litellm"]
assert isinstance(provider, dict)
headers: Final = provider["http_headers"]
assert isinstance(headers, dict)
assert (provider["base_url"], headers["Authorization"], written["model"]) == (
f"{replacement_url}/v1", f"Bearer {replacement_key}", "replacement"
)
@responses.activate
def test_contended_setup_lock_blocks_requests_and_agent_writes(
self, runner: CliRunner, paths: tuple[Path, Path]
) -> None:
from litellm.proxy.client.cli.commands.configure_profiles import setup_locks
_mock_agent_models()
with setup_locks(("claude",)):
blocked: Final = runner.invoke(
cli,
["configure", "claude", "--gateway-url", PROXY, "--api-key", VALID_KEY, "--model", "auto"],
)
assert blocked.exit_code != 0, blocked.output
assert "Could not lock agent setup" in blocked.output
assert len(responses.calls) == 0
assert not paths[0].exists() and not paths[1].exists()
assert not _saved_profile_path("claude", paths[0]).exists()