feat(cli): add lite configure claude and lite unconfigure claude (#40319)

Persistently route Claude Code through a LiteLLM proxy with a long-lived virtual key or the stored lite login, turn on gateway model discovery so /model lists the proxy's models, optionally pick the model Claude Code starts on, and record what changed so unconfigure restores only the keys the user has not touched since. lite login --config-claude writes through the same receipt and is undoable too. The two settings merges (lite up / --config-claude and lite autoroute) collapse into one credential-aware merge
This commit is contained in:
tin-berri 2026-09-09 18:02:23 -07:00 committed by GitHub
parent a8bb2f93b9
commit 317b29e69d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1718 additions and 250 deletions

View file

@ -508,7 +508,7 @@ The credential is short-lived by design (default 24h, configurable via `LITELLM_
### Route Every Claude Code Session Through the Proxy
`lite claude` wraps a single invocation, but `lite up` goes further: it patches `~/.claude/settings.json`, Claude Code's own config file, so that every Claude Code session started afterward -- from any terminal, launched normally with just `claude`, no wrapper needed -- routes through your LiteLLM proxy. It sets `env.ANTHROPIC_BASE_URL` to the proxy URL, `env.ENABLE_TOOL_SEARCH` to `true` and `env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY` to `1` when those keys are missing, and `apiKeyHelper` to a `lite auth print-token` invocation, drops any stray static `ANTHROPIC_API_KEY` so the helper-issued token wins, and leaves every other setting in the file untouched. It backs up the original file before patching it.
`lite claude` wraps a single invocation, but `lite up` goes further: it patches `~/.claude/settings.json`, Claude Code's own config file, so that every Claude Code session started afterward -- from any terminal, launched normally with just `claude`, no wrapper needed -- routes through your LiteLLM proxy. It sets `env.ANTHROPIC_BASE_URL` to the proxy URL, `env.ENABLE_TOOL_SEARCH` to `true` and `env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY` to `1` when those keys are missing, and `apiKeyHelper` to a `lite auth print-token` invocation, drops any stray static `ANTHROPIC_API_KEY` or `ANTHROPIC_AUTH_TOKEN` so the helper-issued token wins, and leaves every other setting in the file untouched. It backs up the original file before patching it.
Two things need to already be true: you've run `lite login` (or `lite login --pkce`, whose key the helper renews on its own), since the apiKeyHelper depends on that stored token, and the proxy is already reachable, since `lite up` does not start one for you.
@ -532,12 +532,28 @@ Cursor is not supported: it has no equivalent file-based config to hot-patch thi
lite --base-url https://your-proxy.example.com login --config-claude
```
It writes the same settings `lite up` does, `env.ANTHROPIC_BASE_URL`, `env.ENABLE_TOOL_SEARCH`, `env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY`, and `apiKeyHelper`, but persistently: there is no backup, nothing to restore, and no foreground process to keep alive. Every other key in `~/.claude/settings.json` is preserved, the file is created if it does not exist, and it is written atomically with owner-only permissions. Plain `lite login` is unchanged; nothing happens to your Claude Code config unless you pass the flag.
It writes the same settings `lite up` does, `env.ANTHROPIC_BASE_URL`, `env.ENABLE_TOOL_SEARCH`, `env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY`, and `apiKeyHelper`, but persistently: no foreground process to keep alive, and `lite unconfigure claude` restores what it changed (see below). Every other key in `~/.claude/settings.json` is preserved, the file is created if it does not exist, and it is written atomically with owner-only permissions. Plain `lite login` is unchanged; nothing happens to your Claude Code config unless you pass the flag.
Because the credential is reached through `apiKeyHelper` rather than copied into the file, a later `lite login` refreshes it with no further action: Claude Code re-runs the helper on every request and picks up whatever token the most recent login stored. Nothing secret is written to `settings.json`.
Run it again to point Claude Code at a different proxy; the base URL and the helper are both rewritten. `lite up` and `--config-claude` manage the same file, so the flag refuses to run while a `lite up` session holds a backup, and tells you to run `lite down` first, rather than writing settings that `lite up` would silently revert when it stops.
#### Configuring Claude Code Once, With a Virtual Key or Your Login
`lite configure claude` wires Claude Code up persistently and `lite unconfigure claude` puts things back. It is what `lite login --config-claude` does, plus a pinned model and an undo, and it also takes a long-lived virtual key when that is what you have:
```bash
curl -fsSL https://raw.githubusercontent.com/BerriAI/litellm/main/scripts/install.sh | sh
lite --base-url https://your-proxy.example.com configure claude --api-key sk-... --model claude-auto
claude
```
With `--api-key` (or `lite --api-key` / `LITELLM_PROXY_API_KEY`) the key is written into `env.ANTHROPIC_AUTH_TOKEN`. Without one, your `lite login` credential is used the way `--config-claude` uses it, through `apiKeyHelper`, so a later `lite login` (or a `--pkce` renewal) picks up on its own and nothing secret lands in the file; a missing or stale login is refreshed first. Either way 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 (the ones whose id contains `claude` or `anthropic`) 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, which has to be on `/v1/models` for the key. 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 and sends no thinking parameters for it, so either name the group like a Claude model id or append `[1m]` to opt into the 1M window. 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 the same things interactively: which agents to wire (Claude Code today) and which of the proxy's models to start on, picked from `/v1/models` with a type-to-filter prompt
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. Like `--config-claude`, both refuse to run while a `lite up` or `lite autoroute up` session holds a backup, and that check comes before any login prompt or request
### QA Complexity-Based Auto-Routing Against Your Real Proxy
`lite autoroute` lets you try LiteLLM's complexity-based auto-routing -- picking a cheaper or more expensive model depending on how complex a prompt looks -- against models your key already has access to on your real, running proxy, without editing that proxy's `config.yaml` and without any real request ever bypassing it. It builds a second, throwaway proxy locally that forwards every request back to your real proxy, and points Claude Code at that local proxy for the duration of the session.
@ -584,7 +600,7 @@ 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 `ANTHROPIC_DEFAULT_SONNET_MODEL`, `ANTHROPIC_DEFAULT_HAIKU_MODEL`, and `ANTHROPIC_DEFAULT_OPUS_MODEL` to `autorouter` in `~/.claude/settings.json`, 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 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 must run `configure` at least once before `up`; running `up` first fails with a clear error telling you to configure first.

View file

@ -16,6 +16,7 @@ from .cmd_quoting import quote_for_cmd
from .pi import (
LITELLM_PROXY_API_KEY_ENV,
PI_PROVIDER_NAME,
ListingFailure,
PiSyncError,
fetch_model_ids,
fetch_model_limits,
@ -165,7 +166,9 @@ def prepare_pi(
"""
ids: Final = fetch_model_ids(base_url, api_key, get=get)
if isinstance(ids, PiSyncError):
raise AgentRunError(ids.message)
raise AgentRunError(
f"{ids.message} pi would have nothing to run." if ids.kind is ListingFailure.EMPTY else ids.message
)
limits: Final = fetch_model_limits(base_url, api_key, get=get)
path: Final = models_json_path(base_env)
error: Final = sync_models_json(path, base_url, ids, limits)

View file

@ -41,9 +41,15 @@ from litellm.litellm_core_utils.cli_token_utils import (
from .claude_settings import (
CLAUDE_SETTINGS_PATH,
CONFIGURE_STATE_PATH,
SETTINGS_FILE_OWNERS,
STARTING_MODEL_ROLE,
ApiKeyHelper,
ClaudeSettingsError,
write_claude_settings,
KeepModel,
configure_claude_settings,
refuse_while_owned,
resolve_api_key_helper,
)
from .pkce_login import (
Http,
@ -778,13 +784,23 @@ def _render_and_prompt_for_team_selection(teams: list[CliTeam]) -> str | None:
def _configure_claude_code(base_url: str) -> None:
"""Point Claude Code at base_url by patching ~/.claude/settings.json."""
"""Point Claude Code at base_url by patching ~/.claude/settings.json, undoable with `lite unconfigure claude`."""
try:
write_claude_settings(base_url, CLAUDE_SETTINGS_PATH, SETTINGS_FILE_OWNERS)
configure_claude_settings(
base_url,
ApiKeyHelper(resolve_api_key_helper(base_url)),
KeepModel(),
CLAUDE_SETTINGS_PATH,
CONFIGURE_STATE_PATH,
SETTINGS_FILE_OWNERS,
)
except ClaudeSettingsError as e:
raise click.ClickException(f"Logged in, but could not configure Claude Code: {e}")
click.echo(f"\nConfigured Claude Code: {CLAUDE_SETTINGS_PATH} now routes through {base_url.rstrip('/')}.")
click.echo("Your other Claude Code settings were left untouched. Restart Claude Code to pick this up.")
click.echo(
"Your other Claude Code settings were left untouched. Restart Claude Code to pick this up. "
f"Undo with `lite unconfigure claude`; `lite configure claude --model` sets {STARTING_MODEL_ROLE}."
)
def _finish_login(base_url: str, api_key: str, config_claude: bool, stored: SecretSave) -> None:
@ -853,6 +869,11 @@ def login(ctx: click.Context, config_claude: bool, pkce: bool) -> None:
ctx_obj: Final[CliContextObj] = ctx.obj
base_url: Final = ctx_obj["base_url"]
if config_claude:
try:
refuse_while_owned(CLAUDE_SETTINGS_PATH, SETTINGS_FILE_OWNERS)
except ClaudeSettingsError as e:
raise click.ClickException(f"Cannot configure Claude Code, so not logging in: {e}")
try:
if pkce:

View file

@ -14,11 +14,13 @@ from ..claude_settings import (
AUTOROUTE_BACKUP_PATH,
CLAUDE_SETTINGS_PATH,
ClaudeSettingsError,
StaticToken,
load_json_or_empty,
merge_claude_settings,
)
from ..up import BackupRecord as ClaudeBackupRecord
from ..up import restore_claude_settings, write_backup
from .config import master_key_from_config
from .config import AUTOROUTER_MODEL_NAME, master_key_from_config
from .process import (
CONFIG_PATH,
DEFAULT_AUTOROUTE_PORT,
@ -37,7 +39,6 @@ from .process import (
terminate,
write_pid_record,
)
from .settings import merge_claude_settings_static_token
from .wizard import run_configure_wizard
_GENERATED_CONFIG_ADAPTER: Final = TypeAdapter(dict[str, JsonValue])
@ -156,7 +157,9 @@ def up(port: int) -> None:
ClaudeBackupRecord(existed=original_existed, content=original_settings if original_existed else None),
AUTOROUTE_BACKUP_PATH,
)
merged: Final = merge_claude_settings_static_token(original_settings, base_url, master_key)
merged: Final = merge_claude_settings(
original_settings, base_url, StaticToken(master_key), AUTOROUTER_MODEL_NAME, AUTOROUTER_MODEL_NAME
)
CLAUDE_SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True)
with secure_create(CLAUDE_SETTINGS_PATH) as f:
json.dump(merged, f, indent=2)

View file

@ -1,51 +0,0 @@
from typing import Final
from pydantic import JsonValue
from .config import AUTOROUTER_MODEL_NAME
ENV_KEY: Final = "env"
API_KEY_HELPER_KEY: Final = "apiKeyHelper"
ANTHROPIC_API_KEY_KEY: Final = "ANTHROPIC_API_KEY"
ANTHROPIC_AUTH_TOKEN_KEY: Final = "ANTHROPIC_AUTH_TOKEN"
ANTHROPIC_BASE_URL_KEY: Final = "ANTHROPIC_BASE_URL"
ENABLE_TOOL_SEARCH_KEY: Final = "ENABLE_TOOL_SEARCH"
ENABLE_TOOL_SEARCH_VALUE: Final = "true"
# Force every one of Claude Code's own model tiers to request the auto-router by name.
# Router's auto-router registry is keyed by the literal requested model string
# (litellm/router.py:10711-10717) with no wildcard/pattern resolution, so a bare "*"
# model_name can never work as a catch-all -- these overrides are what actually makes
# Claude Code send "autorouter" regardless of /model or its own version-specific defaults.
ANTHROPIC_DEFAULT_MODEL_ENV_KEYS: Final = (
"ANTHROPIC_DEFAULT_SONNET_MODEL",
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
"ANTHROPIC_DEFAULT_OPUS_MODEL",
)
def merge_claude_settings_static_token(
settings: dict[str, JsonValue], base_url: str, auth_token: str
) -> dict[str, JsonValue]:
"""Return a new settings dict wired to a local ephemeral proxy with a static token.
Unlike up.py's merge_claude_settings (which sets apiKeyHelper for a long-lived, real
remote proxy needing refreshable SSO tokens), this proxy is ephemeral and its key is the
locally persisted autoroute master key, so a plain env var is simpler and correct. Any
existing apiKeyHelper is cleared so it can't fight with the static token.
"""
raw_env: Final = settings.get(ENV_KEY, {})
base_env: Final = raw_env if isinstance(raw_env, dict) else {}
env: Final[dict[str, JsonValue]] = {
ENABLE_TOOL_SEARCH_KEY: ENABLE_TOOL_SEARCH_VALUE,
**base_env,
ANTHROPIC_BASE_URL_KEY: base_url.rstrip("/"),
ANTHROPIC_AUTH_TOKEN_KEY: auth_token,
**{key: AUTOROUTER_MODEL_NAME for key in ANTHROPIC_DEFAULT_MODEL_ENV_KEYS},
}
env.pop(ANTHROPIC_API_KEY_KEY, None)
merged: Final[dict[str, JsonValue]] = {**settings, ENV_KEY: env}
merged.pop(API_KEY_HELPER_KEY, None)
return merged
__all__ = ["merge_claude_settings_static_token"]

View file

@ -1,37 +1,70 @@
"""Shared handling of Claude Code's ~/.claude/settings.json.
`lite up` patches this file temporarily and restores it on exit; `lite login
--config-claude` patches it persistently. Both need the same merge and the same
apiKeyHelper command, and `up` already imports from `auth`, so the shared parts
live here rather than in either command module.
`lite up` and `lite autoroute up` patch this file temporarily and restore it on
exit; `lite login --config-claude` and `lite configure claude` patch it
persistently and record how to undo it. All of them need the same merge and the
same apiKeyHelper command, and `up` already imports from `auth`, so the shared
parts live here rather than in any one command module.
"""
import hashlib
import json
import shlex
import shutil
import sys
from collections.abc import Mapping, Sequence
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
from functools import reduce
from itertools import chain
from pathlib import Path
from typing import Final
from types import MappingProxyType
from typing import Final, TypeAlias
from pydantic import JsonValue, TypeAdapter, ValidationError
from pydantic import BaseModel, ConfigDict, JsonValue, TypeAdapter, ValidationError
from litellm.litellm_core_utils.private_json import write_private_json
from litellm.litellm_core_utils.private_json import (
commit_staged_json,
discard_staged_json,
ensure_private_dir,
stage_private_json,
)
from .cmd_quoting import quote_for_cmd
ENV_KEY: Final = "env"
API_KEY_HELPER_KEY: Final = "apiKeyHelper"
MODEL_KEY: Final = "model"
ANTHROPIC_BASE_URL_KEY: Final = "ANTHROPIC_BASE_URL"
ANTHROPIC_AUTH_TOKEN_KEY: Final = "ANTHROPIC_AUTH_TOKEN"
ANTHROPIC_API_KEY_KEY: Final = "ANTHROPIC_API_KEY"
ENABLE_TOOL_SEARCH_KEY: Final = "ENABLE_TOOL_SEARCH"
ENABLE_TOOL_SEARCH_VALUE: Final = "true"
ENABLE_GATEWAY_MODEL_DISCOVERY_KEY: Final = "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"
ENABLE_GATEWAY_MODEL_DISCOVERY_VALUE: Final = "1"
ANTHROPIC_DEFAULT_MODEL_ENV_KEYS: Final = (
"ANTHROPIC_DEFAULT_SONNET_MODEL",
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
"ANTHROPIC_DEFAULT_OPUS_MODEL",
"ANTHROPIC_DEFAULT_FABLE_MODEL",
)
OWNED_ENV_KEYS: Final = (
ENABLE_TOOL_SEARCH_KEY,
ENABLE_GATEWAY_MODEL_DISCOVERY_KEY,
ANTHROPIC_BASE_URL_KEY,
ANTHROPIC_AUTH_TOKEN_KEY,
ANTHROPIC_API_KEY_KEY,
)
OWNED_TOP_LEVEL_KEYS: Final = (API_KEY_HELPER_KEY, MODEL_KEY)
OWNED_PATHS: Final = (*(f"{ENV_KEY}.{key}" for key in OWNED_ENV_KEYS), *OWNED_TOP_LEVEL_KEYS)
_CREDENTIAL_ENV_KEYS: Final = frozenset((ANTHROPIC_API_KEY_KEY, ANTHROPIC_AUTH_TOKEN_KEY))
_CREDENTIAL_PATHS: Final = (*(f"{ENV_KEY}.{key}" for key in sorted(_CREDENTIAL_ENV_KEYS)), API_KEY_HELPER_KEY)
_BASE_URL_PATH: Final = f"{ENV_KEY}.{ANTHROPIC_BASE_URL_KEY}"
STARTING_MODEL_ROLE: Final = "the /model picker's default row, the model Claude Code starts on"
CLAUDE_SETTINGS_PATH: Final = Path.home() / ".claude" / "settings.json"
BACKUP_PATH: Final = Path.home() / ".litellm" / "claude_settings_backup.json"
AUTOROUTE_BACKUP_PATH: Final = Path.home() / ".litellm" / "autorouter" / "claude_settings_backup.json"
CONFIGURE_STATE_PATH: Final = Path.home() / ".litellm" / "claude_configure_state.json"
@dataclass(frozen=True, slots=True)
@ -55,6 +88,102 @@ class ClaudeSettingsError(Exception):
"""Raised for any user-actionable failure while reading or writing Claude Code settings."""
@dataclass(frozen=True, slots=True)
class StaticToken:
"""A long-lived virtual key, written into env.ANTHROPIC_AUTH_TOKEN."""
token: str
@dataclass(frozen=True, slots=True)
class ApiKeyHelper:
"""A `lite auth print-token` command Claude Code runs per request, so a login renews in place."""
command: str
ClaudeCredential: TypeAlias = StaticToken | ApiKeyHelper
@dataclass(frozen=True, slots=True)
class KeepModel:
"""Leave the top-level `model` as it is, the user's or an earlier configure's (a re-login)."""
@dataclass(frozen=True, slots=True)
class UnpinModel:
"""Let go of a `model` an earlier configure pinned; one the user set themselves stays."""
@dataclass(frozen=True, slots=True)
class StartOn:
"""Pin the top-level `model`, the row Claude Code starts on."""
model: str
ModelChoice: TypeAlias = KeepModel | UnpinModel | StartOn
class OwnedValue(BaseModel):
"""What one key held at a moment in time; `present=False` is an absent key, not a null one."""
model_config = ConfigDict(frozen=True)
present: bool
value: JsonValue = None
class ConfigureReceipt(BaseModel):
"""What `lite configure claude` found and what it owns, keyed by dotted path (`env.X` or a top-level key).
Ownership moves only by a write: `written` fingerprints the keys some configure changed, at the
value it wrote; a repeat configure refreshes a fingerprint only for a key its merge changed and
carries the earlier one otherwise, so a key the user edited in between stops matching and is left
alone. `previous` is what each key held before configure took it over; a repeat keeps the earlier
snapshot while the key still holds our value and snapshots afresh otherwise, so whatever the
repeat displaces is what comes back. `endpoints` is the ANTHROPIC_BASE_URL each credential slot
was captured beside, so a credential is only ever put back next to the server it was issued for.
No fingerprint is a second copy of a token.
"""
model_config = ConfigDict(frozen=True)
file_existed: bool
env_present: bool
env_was_object: bool
previous: Mapping[str, OwnedValue]
written: Mapping[str, str]
endpoints: Mapping[str, OwnedValue]
@dataclass(frozen=True, slots=True)
class WithheldCredential:
"""A credential left removed: captured beside `endpoint`, while the restored file points elsewhere."""
key: str
endpoint: str
@dataclass(frozen=True, slots=True)
class UnconfigureOutcome:
"""Keys whose value unconfigure changed back, keys the user changed since and so were left as they
are, credentials withheld (the receipt is kept for them, so a later unconfigure can finish once the
URL points back), and whether no settings file remains."""
restored: tuple[str, ...]
kept: tuple[str, ...]
withheld: tuple[WithheldCredential, ...] = ()
file_removed: bool = False
@dataclass(frozen=True, slots=True)
class _Claim:
previous: OwnedValue
written: str | None
endpoint: OwnedValue | None
def load_json_or_empty(path: Path) -> dict[str, JsonValue]:
try:
content: Final = path.read_bytes() if path.exists() else b""
@ -70,29 +199,104 @@ def load_json_or_empty(path: Path) -> dict[str, JsonValue]:
)
def merge_claude_settings(
settings: Mapping[str, JsonValue], base_url: str, api_key_helper: str
) -> dict[str, JsonValue]:
"""Return a new settings dict wired to route Claude Code through the proxy.
def _env_object(settings: Mapping[str, JsonValue], path: Path) -> Mapping[str, JsonValue]:
raw_env: Final = settings.get(ENV_KEY)
if raw_env is None:
return MappingProxyType({})
if not isinstance(raw_env, dict):
raise ClaudeSettingsError(
f'{path} has a non-object "{ENV_KEY}" value, which this would discard. Fix or remove it, then retry.'
)
return raw_env
Only env.ANTHROPIC_BASE_URL and the top-level apiKeyHelper are overridden; a
stray env.ANTHROPIC_API_KEY is dropped so it cannot outrank the helper-issued
token (same reasoning as build_agent_env in agents.py). ENABLE_TOOL_SEARCH
defaults to true because Claude Code turns tool search off when
ANTHROPIC_BASE_URL is not a first-party Anthropic host, and
CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY defaults to 1 so the /model picker
is filled from the proxy's /v1/models; existing values of both are left
alone. Every other key is preserved untouched.
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
purely local check, so commands run it before any login prompt or request."""
for owner in owners:
if owner.backup_path.exists():
raise ClaudeSettingsError(
f"`{owner.start_command}` is currently managing {settings_path} (backup at "
f"{owner.backup_path}) and will restore it when it stops. "
f"Run `{owner.stop_command}` first, then retry."
)
def _write_target(settings_path: Path) -> Path:
"""Write through a symlinked settings.json rather than replacing the link, which would silently
detach a file symlinked into a dotfiles repo."""
try:
return settings_path.resolve() if settings_path.is_symlink() else settings_path
except OSError as e:
raise ClaudeSettingsError(f"Could not resolve {settings_path}: {e}") from e
def _stage(path: Path, document: Mapping[str, object]) -> str:
try:
return stage_private_json(str(path), document)
except OSError as e:
raise ClaudeSettingsError(f"Could not write {path}: {e}") from e
def _land(
path: Path,
staged: str | None,
also_discard: Sequence[str | None] = (),
commit: Callable[[str, str], None] = commit_staged_json,
) -> None:
"""Commit a staged file to `path`, or remove `path` when nothing is staged for it. The one place a
filesystem error becomes a ClaudeSettingsError; on failure the operation's other staged files are
discarded, so no temp file holding a token is left behind."""
try:
if staged is None:
path.unlink(missing_ok=True)
else:
commit(staged, str(path))
except OSError as e:
for other in also_discard:
if other is not None:
discard_staged_json(other)
raise ClaudeSettingsError(f"Could not {'remove' if staged is None else 'write'} {path}: {e}") from e
def merge_claude_settings(
settings: Mapping[str, JsonValue],
base_url: str,
credential: ClaudeCredential,
default_model: str | None = None,
tier_model: str | None = None,
) -> Mapping[str, JsonValue]:
"""Return a new settings mapping wired to route Claude Code through the proxy.
A StaticToken lands in env.ANTHROPIC_AUTH_TOKEN, an ApiKeyHelper in the top-level apiKeyHelper;
the other credential slots are removed either way, 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`, the row Claude Code starts
on; `tier_model` is `lite autoroute up`'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, {})
base_env: Final = raw_env if isinstance(raw_env, dict) else {}
env: Final = {
ENABLE_TOOL_SEARCH_KEY: ENABLE_TOOL_SEARCH_VALUE,
ENABLE_GATEWAY_MODEL_DISCOVERY_KEY: ENABLE_GATEWAY_MODEL_DISCOVERY_VALUE,
**{key: value for key, value in base_env.items() if key != ANTHROPIC_API_KEY_KEY},
ANTHROPIC_BASE_URL_KEY: base_url.rstrip("/"),
}
return {**settings, ENV_KEY: env, API_KEY_HELPER_KEY: api_key_helper}
current_env: Final = raw_env if isinstance(raw_env, dict) else {}
env: Final = dict( # mutable-ok: JSON document handed to json.dump, which rejects a read-only mapping
chain(
(
(ENABLE_TOOL_SEARCH_KEY, ENABLE_TOOL_SEARCH_VALUE),
(ENABLE_GATEWAY_MODEL_DISCOVERY_KEY, ENABLE_GATEWAY_MODEL_DISCOVERY_VALUE),
),
((key, value) for key, value in current_env.items() if key not in _CREDENTIAL_ENV_KEYS),
((ANTHROPIC_BASE_URL_KEY, base_url.rstrip("/")),),
((ANTHROPIC_AUTH_TOKEN_KEY, credential.token),) if isinstance(credential, StaticToken) else (),
((key, tier_model) for key in ANTHROPIC_DEFAULT_MODEL_ENV_KEYS if tier_model is not None),
)
)
return dict( # mutable-ok: JSON document handed to json.dump, which rejects a read-only mapping
chain(
((key, value) for key, value in settings.items() if key not in (API_KEY_HELPER_KEY, ENV_KEY)),
((ENV_KEY, env),),
((API_KEY_HELPER_KEY, credential.command),) if isinstance(credential, ApiKeyHelper) else (),
((MODEL_KEY, default_model),) if default_model is not None else (),
)
)
def resolve_api_key_helper(base_url: str, platform: str = sys.platform) -> str:
@ -121,56 +325,255 @@ def resolve_api_key_helper(base_url: str, platform: str = sys.platform) -> str:
return " ".join(quote(token) for token in (lite_path, "--base-url", base_url, "auth", "print-token"))
def write_claude_settings(base_url: str, settings_path: Path, owners: Sequence[SettingsFileOwner]) -> None:
"""Persistently point Claude Code at base_url, preserving every unrelated setting.
def _owned(container: Mapping[str, JsonValue], key: str) -> OwnedValue:
return OwnedValue(present=key in container, value=container.get(key))
Refuses while any owner holds a backup: each restores its backup when it
stops, which would silently undo this write.
"""
for owner in owners:
if owner.backup_path.exists():
raise ClaudeSettingsError(
f"`{owner.start_command}` is currently managing {settings_path} (backup at "
f"{owner.backup_path}) and will restore it when it stops. "
f"Run `{owner.stop_command}` first, then retry."
)
normalized_base_url: Final = base_url.rstrip("/")
api_key_helper: Final = resolve_api_key_helper(normalized_base_url)
existing: Final = load_json_or_empty(settings_path)
raw_env: Final = existing.get(ENV_KEY)
if raw_env is not None and not isinstance(raw_env, dict):
raise ClaudeSettingsError(
f'{settings_path} has a non-object "{ENV_KEY}" value, which this would discard. '
"Fix or remove it, then retry."
)
merged: Final = merge_claude_settings(existing, normalized_base_url, api_key_helper)
# os.replace() swaps the symlink itself for a regular file, silently detaching a
# settings.json that is symlinked into a dotfiles repo. There is no backup to undo
# that here, unlike `lite up`, so write through to the link's target instead.
target: Final = settings_path.resolve() if settings_path.is_symlink() else settings_path
def _fingerprint(owned: OwnedValue) -> str:
return hashlib.sha256(json.dumps(owned.model_dump(mode="json"), sort_keys=True).encode()).hexdigest()
def _env(settings: Mapping[str, JsonValue]) -> Mapping[str, JsonValue]:
raw_env: Final = settings.get(ENV_KEY)
return raw_env if isinstance(raw_env, dict) else MappingProxyType({})
def _lookup(settings: Mapping[str, JsonValue], path: str) -> OwnedValue:
section, _, key = path.rpartition(".")
return _owned(_env(settings) if section else settings, key)
def _with_key(container: Mapping[str, JsonValue], key: str, owned: OwnedValue) -> Mapping[str, JsonValue]:
return dict( # mutable-ok: JSON document handed to json.dump, which rejects a read-only mapping
chain(((k, v) for k, v in container.items() if k != key), ((key, owned.value),) if owned.present else ())
)
def _with(settings: Mapping[str, JsonValue], path: str, owned: OwnedValue) -> Mapping[str, JsonValue]:
"""`settings` with the key at `path` set (or removed when `owned` is absent); nothing else changes."""
section, _, key = path.rpartition(".")
if not section:
return _with_key(settings, key, owned)
return _with_key(settings, section, OwnedValue(present=True, value=_with_key(_env(settings), key, owned)))
def _with_all(settings: Mapping[str, JsonValue], updates: Mapping[str, OwnedValue]) -> Mapping[str, JsonValue]:
return reduce(lambda acc, item: _with(acc, *item), updates.items(), settings)
def _ours(settings: Mapping[str, JsonValue], path: str, receipt: ConfigureReceipt) -> bool:
"""Whether the key still holds what a configure wrote (a key no configure ever changed is never ours)."""
return receipt.written.get(path) == _fingerprint(_lookup(settings, path))
def _claim(
path: str,
current: Mapping[str, JsonValue],
merged: Mapping[str, JsonValue],
earlier: ConfigureReceipt | None,
url_now: OwnedValue,
) -> _Claim:
"""What this configure records for one key; see ConfigureReceipt for the rules."""
before, after = _lookup(current, path), _lookup(merged, path)
carried: Final = earlier if earlier is not None and _ours(current, path, earlier) else None
return _Claim(
previous=before if carried is None else carried.previous.get(path, before),
written=_fingerprint(after) if before != after else (None if earlier is None else earlier.written.get(path)),
endpoint=None
if path not in _CREDENTIAL_PATHS
else (url_now if carried is None else carried.endpoints.get(path, url_now)),
)
def _receipt(
current: Mapping[str, JsonValue],
merged: Mapping[str, JsonValue],
earlier: ConfigureReceipt | None,
file_exists: bool,
) -> ConfigureReceipt:
url_now: Final = _lookup(current, _BASE_URL_PATH)
claims: Final = MappingProxyType({path: _claim(path, current, merged, earlier, url_now) for path in OWNED_PATHS})
return ConfigureReceipt(
file_existed=file_exists if earlier is None else earlier.file_existed,
env_present=ENV_KEY in current if earlier is None else earlier.env_present,
env_was_object=isinstance(current.get(ENV_KEY), dict) if earlier is None else earlier.env_was_object,
previous=MappingProxyType({path: claim.previous for path, claim in claims.items()}),
written=MappingProxyType({path: claim.written for path, claim in claims.items() if claim.written is not None}),
endpoints=MappingProxyType(
{path: claim.endpoint for path, claim in claims.items() if claim.endpoint is not None}
),
)
def read_configure_receipt(state_path: Path) -> ConfigureReceipt | None:
if not state_path.exists():
return None
try:
write_private_json(str(target), merged)
return ConfigureReceipt.model_validate_json(state_path.read_bytes())
except (OSError, ValidationError) as e:
raise ClaudeSettingsError(
f"{state_path} is not a readable `lite configure claude` receipt ({e}). "
"Remove it and edit Claude Code's settings by hand if they still point at the proxy."
) from e
def configure_claude_settings(
base_url: str,
credential: ClaudeCredential,
model: ModelChoice,
settings_path: Path,
state_path: Path,
owners: Sequence[SettingsFileOwner],
commit: Callable[[str, str], None] = commit_staged_json,
) -> None:
"""Persistently route Claude Code through base_url, recording how to undo it.
Both files are staged before either is committed, so a full disk or a read-only directory fails
before anything changes. The two commits are still two renames: a receipt rename that fails
discards the staged settings, and a settings rename that fails after the receipt landed puts the
earlier receipt back (or removes the new one), so the receipt on disk never describes settings
that were not written. `model`: StartOn pins the starting model, UnpinModel lets go of a pin an
earlier configure made (never of the user's own), KeepModel leaves it alone (a re-login).
"""
refuse_while_owned(settings_path, owners)
current: Final = load_json_or_empty(settings_path)
_env_object(current, settings_path)
earlier: Final = read_configure_receipt(state_path)
existing: Final = (
_with(current, MODEL_KEY, earlier.previous[MODEL_KEY])
if isinstance(model, UnpinModel) and earlier is not None and _ours(current, MODEL_KEY, earlier)
else current
)
merged: Final = merge_claude_settings(
existing, base_url, credential, model.model if isinstance(model, StartOn) else None
)
receipt: Final = _receipt(current, merged, earlier, settings_path.exists())
target: Final = _write_target(settings_path)
try:
ensure_private_dir(state_path.parent)
except OSError as e:
raise ClaudeSettingsError(f"Could not write {target}: {e}") from e
raise ClaudeSettingsError(f"Could not write {state_path}: {e}") from e
staged_receipt: Final = _stage(state_path, receipt.model_dump(mode="json"))
try:
staged_settings: Final = _stage(target, merged)
except ClaudeSettingsError:
discard_staged_json(staged_receipt)
raise
_land(state_path, staged_receipt, (staged_settings,), commit)
try:
_land(target, staged_settings, commit=commit)
except ClaudeSettingsError as settings_error:
try:
_land(state_path, None if earlier is None else _stage(state_path, earlier.model_dump(mode="json")))
except ClaudeSettingsError as receipt_error:
raise ClaudeSettingsError(
f"{settings_error} The receipt at {state_path} now describes settings that were not written and "
f"could not be put back either ({receipt_error}); remove it before retrying."
) from settings_error
raise
def _endpoint_text(endpoint: OwnedValue) -> str:
if not endpoint.present:
return f"no {ANTHROPIC_BASE_URL_KEY} (Anthropic's default endpoint)"
return endpoint.value if isinstance(endpoint.value, str) else json.dumps(endpoint.value)
def unconfigure_claude_settings(
settings_path: Path, state_path: Path, owners: Sequence[SettingsFileOwner]
) -> UnconfigureOutcome:
"""Undo `lite configure claude`: put back every key still holding what configure wrote, leave the
rest alone, and withhold a credential the restored file would send to a different server than it
was issued for (the receipt stays, owning only those slots, so a later unconfigure can finish)."""
refuse_while_owned(settings_path, owners)
receipt: Final = read_configure_receipt(state_path)
if receipt is None:
raise ClaudeSettingsError(
f"Claude Code is not configured by `lite configure claude` (no receipt at {state_path}); nothing to undo."
)
current: Final = load_json_or_empty(settings_path)
_env_object(current, settings_path)
ours: Final = tuple(path for path in receipt.written if _ours(current, path, receipt))
kept: Final = tuple(path for path in receipt.written if path not in ours and _lookup(current, path).present)
put_back: Final = _with_all(current, MappingProxyType({path: receipt.previous[path] for path in ours}))
url_after: Final = _lookup(put_back, _BASE_URL_PATH)
withheld: Final = tuple(
WithheldCredential(path, _endpoint_text(receipt.endpoints[path]))
for path in _CREDENTIAL_PATHS
if path in ours and receipt.previous[path].present and receipt.endpoints[path] != url_after
)
absent: Final = OwnedValue(present=False)
trimmed: Final = _with_all(put_back, MappingProxyType({item.key: absent for item in withheld}))
settings: Final = (
trimmed
if _env(trimmed) or receipt.env_was_object
else _with_key(trimmed, ENV_KEY, OwnedValue(present=receipt.env_present, value=None))
)
target: Final = _write_target(settings_path)
file_removed: Final = not settings and not (receipt.file_existed and target.exists())
kept_receipt: Final = ( # mutable-ok: pydantic serializes the update as given and rejects a mappingproxy
receipt.model_copy(update={"written": {item.key: _fingerprint(absent) for item in withheld}})
if withheld
else None
)
staged_settings: Final = None if file_removed else _stage(target, settings)
try:
staged_receipt: Final = (
None if kept_receipt is None else _stage(state_path, kept_receipt.model_dump(mode="json"))
)
except ClaudeSettingsError:
if staged_settings is not None:
discard_staged_json(staged_settings)
raise
_land(target, staged_settings, (staged_receipt,))
_land(state_path, staged_receipt)
return UnconfigureOutcome(
restored=tuple(path for path in ours if _lookup(current, path) != _lookup(settings, path)),
kept=kept,
withheld=withheld,
file_removed=file_removed,
)
__all__ = (
"ANTHROPIC_API_KEY_KEY",
"ANTHROPIC_AUTH_TOKEN_KEY",
"ANTHROPIC_BASE_URL_KEY",
"ANTHROPIC_DEFAULT_MODEL_ENV_KEYS",
"API_KEY_HELPER_KEY",
"AUTOROUTE_BACKUP_PATH",
"BACKUP_PATH",
"CLAUDE_SETTINGS_PATH",
"CONFIGURE_STATE_PATH",
"ENABLE_GATEWAY_MODEL_DISCOVERY_KEY",
"ENABLE_GATEWAY_MODEL_DISCOVERY_VALUE",
"ENABLE_TOOL_SEARCH_KEY",
"ENABLE_TOOL_SEARCH_VALUE",
"ENV_KEY",
"MODEL_KEY",
"OWNED_ENV_KEYS",
"OWNED_PATHS",
"OWNED_TOP_LEVEL_KEYS",
"SETTINGS_FILE_OWNERS",
"STARTING_MODEL_ROLE",
"ApiKeyHelper",
"ClaudeCredential",
"ClaudeSettingsError",
"ConfigureReceipt",
"KeepModel",
"ModelChoice",
"OwnedValue",
"SettingsFileOwner",
"StartOn",
"StaticToken",
"UnconfigureOutcome",
"UnpinModel",
"WithheldCredential",
"configure_claude_settings",
"load_json_or_empty",
"merge_claude_settings",
"read_configure_receipt",
"refuse_while_owned",
"resolve_api_key_helper",
"write_claude_settings",
"unconfigure_claude_settings",
)

View file

@ -0,0 +1,252 @@
"""`lite configure claude` and `lite unconfigure claude`: persistent Claude Code wiring, undoable."""
import re
import sys
from collections.abc import Callable, Sequence
from pathlib import Path
from typing import Final
import click
from InquirerPy import inquirer
from InquirerPy.base.control import Choice
from .auth import CliContextObj, context_secret_vault, get_stored_api_key
from .claude_settings import (
CLAUDE_SETTINGS_PATH,
CONFIGURE_STATE_PATH,
SETTINGS_FILE_OWNERS,
STARTING_MODEL_ROLE,
ApiKeyHelper,
ClaudeCredential,
ClaudeSettingsError,
ModelChoice,
StartOn,
StaticToken,
UnconfigureOutcome,
UnpinModel,
configure_claude_settings,
refuse_while_owned,
resolve_api_key_helper,
unconfigure_claude_settings,
)
from .pi import ListingFailure, PiSyncError, fetch_model_ids
from .up import ensure_fresh_login
_LISTED_MODELS_SHOWN: Final = 20
_CLAUDE_TARGET: Final = "claude"
_TARGETS: Final = ((_CLAUDE_TARGET, "Claude Code (CLI)"),)
_KEEP_DEFAULT_MODEL: Final = "Keep Claude Code's own default"
_CLAUDE_CODE_PICKER_FILTER: Final = re.compile(r"claude|anthropic", re.IGNORECASE)
_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."
)
def resolve_credential(ctx: click.Context, api_key: str | None) -> tuple[ClaudeCredential, str]:
"""The credential to write and the key to check the proxy with.
An explicit key (--api-key, `lite --api-key`, LITELLM_PROXY_API_KEY) is long-lived and goes
into settings.json as a static token. Without one, the stored `lite login` credential is used
the way `lite login --config-claude` uses it, through apiKeyHelper, since it expires within a
day and renews in place there; a missing or stale login is refreshed first, as `lite up` does.
"""
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 explicit:
return StaticToken(explicit), explicit
base_url: Final = ctx_obj["base_url"]
ensure_fresh_login(ctx)
stored: Final = get_stored_api_key(expected_base_url=base_url, vault=context_secret_vault(ctx))
if not stored:
raise ClaudeSettingsError("Login did not produce a usable token.")
return ApiKeyHelper(resolve_api_key_helper(base_url)), stored
def _start(ctx: click.Context, api_key: str | None) -> tuple[ClaudeCredential, tuple[str, ...]]:
"""Every configure path begins the same way: the local ownership check first, so a `lite up`
session is refused before any login prompt or request, then the credential, then the listing."""
try:
refuse_while_owned(CLAUDE_SETTINGS_PATH, SETTINGS_FILE_OWNERS)
credential, key = resolve_credential(ctx, api_key)
except ClaudeSettingsError as e:
raise click.ClickException(str(e))
return credential, _listed_models(ctx.obj["base_url"], key)
def _listing_error(base_url: str, error: PiSyncError) -> 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}). Run `lite login` to refresh it, or pass a valid --api-key."
if error.kind is ListingFailure.UNREACHABLE:
return f"{error.message} Is the proxy at {base_url} running, and is --base-url (or LITELLM_PROXY_URL) correct?"
if error.kind is ListingFailure.EMPTY:
return f"{error.message} Claude Code would have nothing to run; give the key access to at least one model."
return f"{error.message} 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) -> tuple[str, ...]:
listed: Final = fetch_model_ids(base_url, key)
if isinstance(listed, PiSyncError):
raise click.ClickException(_listing_error(base_url, listed))
return listed
def _model_choice(model: str | None) -> ModelChoice:
return StartOn(model) if model is not None else UnpinModel()
def _apply_claude(ctx: click.Context, credential: ClaudeCredential, listed: Sequence[str], model: str | None) -> None:
ctx_obj: Final[CliContextObj] = ctx.obj
base_url: Final = ctx_obj["base_url"]
if model is not None and model not in listed:
shown: Final = ", ".join(listed[:_LISTED_MODELS_SHOWN])
more: Final = f", and {len(listed) - _LISTED_MODELS_SHOWN} more" if len(listed) > _LISTED_MODELS_SHOWN else ""
raise click.ClickException(
f"{model!r} is not served by {base_url} for this key. /v1/models lists: {shown}{more}."
)
try:
configure_claude_settings(
base_url, credential, _model_choice(model), CLAUDE_SETTINGS_PATH, CONFIGURE_STATE_PATH, SETTINGS_FILE_OWNERS
)
except ClaudeSettingsError as e:
raise click.ClickException(str(e))
in_picker: Final = sum(1 for listed_model in listed if _CLAUDE_CODE_PICKER_FILTER.search(listed_model))
click.echo(f"Configured Claude Code: {CLAUDE_SETTINGS_PATH} now routes through {base_url}.")
click.echo(
"Credential: your virtual key, stored in the file as ANTHROPIC_AUTH_TOKEN."
if isinstance(credential, StaticToken)
else "Credential: your `lite login`, read through apiKeyHelper on every request, so a later login renews it."
)
click.echo(
f"Starting model: {model} ({STARTING_MODEL_ROLE}); switch any time with /model."
if model 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."
)
click.echo(
f"/model will list {in_picker} of the proxy's {len(listed)} models (Claude Code shows only ids containing "
"'claude' or 'anthropic')."
)
click.echo("Start `claude` from any terminal. Undo with `lite unconfigure claude`.")
if isinstance(credential, StaticToken) and CLAUDE_SETTINGS_PATH.is_symlink():
click.echo(
f"Note: {CLAUDE_SETTINGS_PATH} is a symlink to {CLAUDE_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],
).execute()
return None if picked == _KEEP_DEFAULT_MODEL else str(picked)
def interactive_configure(
ctx: click.Context,
pick_targets: Callable[[], tuple[str, ...]] = _pick_targets,
pick_model: Callable[[Sequence[str]], str | None] = _pick_model,
) -> None:
"""`lite configure` with no agent named: ask which agents to wire and which model to pin."""
targets: Final = pick_targets()
if _CLAUDE_TARGET not in targets:
return
credential, listed = _start(ctx, None)
_apply_claude(ctx, credential, listed, pick_model(listed))
@click.group(name="configure", invoke_without_command=True)
@click.pass_context
def configure_group(ctx: click.Context) -> None:
"""Persistently route a coding agent through your LiteLLM proxy.
With no agent named, asks which agents to wire and which proxy model to pin.
"""
if ctx.invoked_subcommand is not None:
return
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>`."
)
interactive_configure(ctx)
@click.group(name="unconfigure")
def unconfigure_group() -> None:
"""Undo `lite configure` for a coding agent."""
@configure_group.command(name="claude")
@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; with neither, your `lite login` credential is used through apiKeyHelper.",
)
@click.option("--model", default=None, help=_MODEL_OPTION_HELP)
@click.pass_context
def configure_claude(ctx: click.Context, api_key: str | None, model: 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 credential (a virtual key as a
static token, or your `lite login` through apiKeyHelper), and gateway model discovery so
/model lists the proxy's models; --model picks the one Claude Code starts on. Every other
setting is kept, and what changed is recorded so `lite unconfigure claude` can put it back.
Assumes the proxy is already running.
"""
credential, listed = _start(ctx, api_key)
_apply_claude(ctx, credential, listed, model)
@unconfigure_group.command(name="claude")
def unconfigure_claude() -> None:
"""Return Claude Code's settings to what they were before `lite configure claude`.
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.
"""
try:
outcome: Final = unconfigure_claude_settings(CLAUDE_SETTINGS_PATH, CONFIGURE_STATE_PATH, SETTINGS_FILE_OWNERS)
except ClaudeSettingsError as e:
raise click.ClickException(str(e))
_report_unconfigure(CLAUDE_SETTINGS_PATH, CONFIGURE_STATE_PATH, outcome)
def _report_unconfigure(settings_path: Path, state_path: Path, outcome: UnconfigureOutcome) -> None:
"""Say what unconfigure did, naming only keys whose value it changed."""
if outcome.file_removed:
click.echo(
f"No settings file remains at {settings_path}; it held nothing but `lite configure claude`'s own keys."
)
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 outcome.withheld:
click.echo(
"Left removed, since the file now points at a different server than they were issued for: "
+ "; ".join(f"{item.key} (captured with {item.endpoint})" for item in outcome.withheld)
+ f". They stay in {state_path}: point env.ANTHROPIC_BASE_URL back and run `lite unconfigure claude` "
"again to put them back, or delete that file to drop them."
)
__all__ = ("configure_group", "interactive_configure", "resolve_credential", "unconfigure_group")

View file

@ -10,6 +10,7 @@ import os
import tempfile
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from enum import StrEnum
from pathlib import Path
from types import MappingProxyType
from typing import Final
@ -20,11 +21,28 @@ from pydantic import BaseModel, JsonValue, TypeAdapter, ValidationError
PI_CONFIG_DIR_ENV: Final = "PI_CODING_AGENT_DIR"
PI_PROVIDER_NAME: Final = "litellm"
LITELLM_PROXY_API_KEY_ENV: Final = "LITELLM_PROXY_API_KEY"
_REJECTED_STATUSES: Final = frozenset((401, 403))
class ListingFailure(StrEnum):
"""Why a proxy could not be listed, decided once where the HTTP outcome is classified.
`unreachable` means no response at all; the other kinds prove the proxy answered, so callers
must not suggest checking whether it is running.
"""
UNREACHABLE = "unreachable"
REJECTED = "rejected"
BAD_BODY = "bad_body"
EMPTY = "empty"
OTHER = "other"
@dataclass(frozen=True, slots=True)
class PiSyncError:
message: str
status: int | None = None
kind: ListingFailure | None = None
@dataclass(frozen=True, slots=True)
@ -65,16 +83,20 @@ def fetch_model_ids(
timeout=10,
)
except requests.RequestException as e:
return PiSyncError(f"Could not list models from the proxy: {e}")
return PiSyncError(f"Could not list models from the proxy: {e}", kind=ListingFailure.UNREACHABLE)
if resp.status_code != 200:
return PiSyncError(f"The proxy returned HTTP {resp.status_code} for /v1/models; cannot build pi's model list.")
return PiSyncError(
f"The proxy returned HTTP {resp.status_code} for /v1/models; cannot list models.",
resp.status_code,
ListingFailure.REJECTED if resp.status_code in _REJECTED_STATUSES else ListingFailure.OTHER,
)
try:
listing: Final = _ModelList.model_validate(resp.json())
except (ValueError, ValidationError) as e:
return PiSyncError(f"Unexpected /v1/models response from the proxy: {e}")
return PiSyncError(f"Unexpected /v1/models response from the proxy: {e}", kind=ListingFailure.BAD_BODY)
ids: Final = tuple(dict.fromkeys(model.id for model in listing.data))
if not ids:
return PiSyncError("The proxy returned no models for your key, so pi would have nothing to run.")
return PiSyncError("The proxy returned no models for your key.", kind=ListingFailure.EMPTY)
return ids
@ -200,6 +222,7 @@ __all__ = (
"LITELLM_PROXY_API_KEY_ENV",
"PI_CONFIG_DIR_ENV",
"PI_PROVIDER_NAME",
"ListingFailure",
"ModelLimits",
"PiSyncError",
"fetch_model_ids",

View file

@ -23,6 +23,7 @@ from .auth import CliContextObj, context_secret_vault, get_stored_api_key, load_
from .claude_settings import (
BACKUP_PATH,
CLAUDE_SETTINGS_PATH,
ApiKeyHelper,
ClaudeSettingsError,
load_json_or_empty,
merge_claude_settings,
@ -123,7 +124,7 @@ def _stored_login_is_pkce(vault: SecretVault) -> bool:
return token_data is not None and token_data.get("refresh_token") is not None
def _ensure_fresh_login(ctx: click.Context) -> None:
def ensure_fresh_login(ctx: click.Context) -> None:
ctx_obj: Final[CliContextObj] = ctx.obj
base_url: Final = ctx_obj["base_url"].rstrip("/")
vault: Final = context_secret_vault(ctx)
@ -141,7 +142,7 @@ def _ensure_fresh_login(ctx: click.Context) -> None:
click.echo("No fresh LiteLLM login found for this proxy; starting login...")
ctx.invoke(login, pkce=pkce)
if not _usable_login(get_stored_api_key(expected_base_url=base_url, vault=vault), vault):
raise UpError("Login did not produce a usable token; cannot start `lite up`.")
raise UpError("Login did not produce a usable token.")
def _restore_and_report() -> None:
@ -169,7 +170,7 @@ def up(ctx: click.Context) -> None:
base_url: Final = ctx.obj["base_url"]
try:
_ensure_fresh_login(ctx)
ensure_fresh_login(ctx)
api_key: Final = resolve_api_key(ctx)
verify_proxy_key(base_url, api_key)
@ -190,7 +191,7 @@ def up(ctx: click.Context) -> None:
)
CLAUDE_SETTINGS_PATH.parent.mkdir(exist_ok=True)
merged: Final = merge_claude_settings(original_settings, base_url, api_key_helper)
merged: Final = merge_claude_settings(original_settings, base_url, ApiKeyHelper(api_key_helper))
with open(CLAUDE_SETTINGS_PATH, "w") as f:
json.dump(merged, f, indent=2)
except (AgentRunError, ClaudeSettingsError) as e:

View file

@ -13,6 +13,7 @@ from .commands.auth import auth_group, context_secret_vault, get_stored_api_key,
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.credentials import credentials
from .commands.debug import debug
from .commands.encryption import encryption
@ -162,6 +163,9 @@ cli.add_command(model_groups)
# Add the autoroute command group (QA auto-routing against your real proxy)
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(unconfigure_group)
if __name__ == "__main__":

View file

@ -159,6 +159,10 @@ class TestUpCommand:
assert captured["settings"]["env"]["ANTHROPIC_AUTH_TOKEN"] == "fixed-master-key"
assert captured["settings"]["env"]["ENABLE_TOOL_SEARCH"] == "true"
assert "apiKeyHelper" not in captured["settings"]
# The ephemeral proxy serves only the autorouter, so a starting model left by
# `lite configure claude --model` or a user pin would 400 on the first message.
assert captured["settings"]["model"] == "autorouter"
assert captured["settings"]["env"]["ANTHROPIC_DEFAULT_SONNET_MODEL"] == "autorouter"
assert captured["settings_mode"] == 0o600
assert terminate_calls == [99999]

View file

@ -1,63 +0,0 @@
from litellm.proxy.client.cli.commands.autoroute.settings import (
ANTHROPIC_DEFAULT_MODEL_ENV_KEYS,
merge_claude_settings_static_token,
)
def test_preserves_unrelated_top_level_keys():
merged = merge_claude_settings_static_token({"theme": "dark"}, "http://127.0.0.1:4000", "token-abc")
assert merged["theme"] == "dark"
def test_preserves_unrelated_env_keys():
settings = {"env": {"SOME_OTHER_VAR": "value"}}
merged = merge_claude_settings_static_token(settings, "http://127.0.0.1:4000", "token-abc")
assert merged["env"]["SOME_OTHER_VAR"] == "value"
def test_sets_base_url_and_auth_token():
merged = merge_claude_settings_static_token({}, "http://127.0.0.1:4000/", "token-abc")
assert merged["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:4000"
assert merged["env"]["ANTHROPIC_AUTH_TOKEN"] == "token-abc"
assert merged["env"]["ENABLE_TOOL_SEARCH"] == "true"
def test_preserves_existing_tool_search():
settings = {"env": {"ENABLE_TOOL_SEARCH": "false"}}
merged = merge_claude_settings_static_token(settings, "http://127.0.0.1:4000", "token-abc")
assert merged["env"]["ENABLE_TOOL_SEARCH"] == "false"
def test_drops_stray_api_key():
settings = {"env": {"ANTHROPIC_API_KEY": "leaked-key"}}
merged = merge_claude_settings_static_token(settings, "http://127.0.0.1:4000", "token-abc")
assert "ANTHROPIC_API_KEY" not in merged["env"]
def test_removes_existing_api_key_helper():
settings = {"apiKeyHelper": "/usr/local/bin/lite auth print-token"}
merged = merge_claude_settings_static_token(settings, "http://127.0.0.1:4000", "token-abc")
assert "apiKeyHelper" not in merged
def test_does_not_mutate_input():
settings = {"env": {"FOO": "bar"}, "apiKeyHelper": "old-helper"}
merge_claude_settings_static_token(settings, "http://127.0.0.1:4000", "token-abc")
assert settings == {"env": {"FOO": "bar"}, "apiKeyHelper": "old-helper"}
def test_forces_all_claude_code_default_model_tiers_to_the_autorouter():
# A bare "*" model_name deployment looks like the obvious way to catch every request
# regardless of which model Claude Code thinks it's using, but Router's auto-router
# registry is keyed by the literal requested model string with no wildcard resolution
# (litellm/router.py:10711-10717) -- so the only reliable way to make every one of Claude
# Code's own tiers hit the auto-router is to override the env vars it reads per tier.
merged = merge_claude_settings_static_token({}, "http://127.0.0.1:4000", "token-abc")
for key in ANTHROPIC_DEFAULT_MODEL_ENV_KEYS:
assert merged["env"][key] == "autorouter"
def test_overrides_a_preexisting_default_model_env_var():
settings = {"env": {"ANTHROPIC_DEFAULT_SONNET_MODEL": "claude-opus-4-8"}}
merged = merge_claude_settings_static_token(settings, "http://127.0.0.1:4000", "token-abc")
assert merged["env"]["ANTHROPIC_DEFAULT_SONNET_MODEL"] == "autorouter"

View file

@ -27,6 +27,7 @@ from litellm.proxy.client.cli.commands.auth import (
print_token,
whoami,
)
from litellm.proxy.client.cli.commands import auth as auth_module
from litellm.proxy.client.cli.commands.claude_settings import SettingsFileOwner
@ -1398,8 +1399,10 @@ class TestLoginConfigClaude:
def setup_method(self):
self.runner = CliRunner()
def _run_login(self, tmp_path, args, base_url="https://test.example.com"):
def _run_login(self, tmp_path, monkeypatch, args, base_url="https://test.example.com"):
settings_path = tmp_path / "claude" / "settings.json"
monkeypatch.setattr(auth_module, "CLAUDE_SETTINGS_PATH", settings_path)
monkeypatch.setattr(auth_module, "CONFIGURE_STATE_PATH", tmp_path / "claude_configure_state.json")
backup_path = tmp_path / "claude_settings_backup.json"
poll_response = Mock()
poll_response.status_code = 200
@ -1416,7 +1419,6 @@ class TestLoginConfigClaude:
patch("requests.get", return_value=poll_response),
patch("litellm.proxy.client.cli.commands.auth.save_cli_token"),
patch("litellm.proxy.client.cli.interface.show_commands"),
patch("litellm.proxy.client.cli.commands.auth.CLAUDE_SETTINGS_PATH", settings_path),
patch(
"litellm.proxy.client.cli.commands.auth.SETTINGS_FILE_OWNERS",
(SettingsFileOwner(backup_path, "lite up", "lite down"),),
@ -1429,16 +1431,16 @@ class TestLoginConfigClaude:
result = self.runner.invoke(login, args, obj={"base_url": base_url})
return result, settings_path, backup_path
def test_default_login_does_not_touch_claude_settings(self, tmp_path):
result, settings_path, _backup_path = self._run_login(tmp_path, [])
def test_default_login_does_not_touch_claude_settings(self, tmp_path, monkeypatch):
result, settings_path, _backup_path = self._run_login(tmp_path, monkeypatch, [])
assert result.exit_code == 0
assert "Login successful!" in result.output
assert not settings_path.exists()
assert "Configured Claude Code" not in result.output
def test_flag_writes_the_settings_file_and_reports_success(self, tmp_path):
result, settings_path, _backup_path = self._run_login(tmp_path, ["--config-claude"])
def test_flag_writes_the_settings_file_and_reports_success(self, tmp_path, monkeypatch):
result, settings_path, _backup_path = self._run_login(tmp_path, monkeypatch, ["--config-claude"])
assert result.exit_code == 0
written = json.loads(settings_path.read_text())
@ -1446,25 +1448,43 @@ class TestLoginConfigClaude:
assert written["env"]["ENABLE_TOOL_SEARCH"] == "true"
assert written["apiKeyHelper"] == "/usr/local/bin/lite --base-url https://test.example.com auth print-token"
assert "Configured Claude Code" in result.output
assert "pins a proxy model for every tier" not in result.output
assert "the model Claude Code starts on" in result.output
def test_flag_preserves_unrelated_settings_on_an_existing_file(self, tmp_path):
def test_flag_preserves_unrelated_settings_on_an_existing_file(self, tmp_path, monkeypatch):
settings_path = tmp_path / "claude" / "settings.json"
settings_path.parent.mkdir(parents=True)
settings_path.write_text(json.dumps({"theme": "dark", "env": {"KEEP": "me"}}))
result, _settings_path, _backup_path = self._run_login(tmp_path, ["--config-claude"])
result, _settings_path, _backup_path = self._run_login(tmp_path, monkeypatch, ["--config-claude"])
assert result.exit_code == 0
written = json.loads(settings_path.read_text())
assert written["theme"] == "dark"
assert written["env"]["KEEP"] == "me"
def test_settings_failure_is_reported_without_claiming_login_failed(self, tmp_path):
def test_refuses_before_logging_in_while_lite_up_holds_the_settings(self, tmp_path, monkeypatch):
# The local precondition comes first: no browser, no token stored, no "Login successful!".
backup_path = tmp_path / "claude_settings_backup.json"
backup_path.write_text("{}")
monkeypatch.setattr(auth_module, "CLAUDE_SETTINGS_PATH", tmp_path / "claude" / "settings.json")
monkeypatch.setattr(
auth_module, "SETTINGS_FILE_OWNERS", (SettingsFileOwner(backup_path, "lite up", "lite down"),)
)
with patch("requests.post") as post, patch("webbrowser.open") as browser:
result = self.runner.invoke(login, ["--config-claude"], obj={"base_url": "https://test.example.com"})
assert result.exit_code != 0
assert "not logging in" in result.output and "lite down" in result.output
assert "Login successful!" not in result.output
post.assert_not_called()
browser.assert_not_called()
def test_settings_failure_is_reported_without_claiming_login_failed(self, tmp_path, monkeypatch):
settings_path = tmp_path / "claude" / "settings.json"
settings_path.parent.mkdir(parents=True)
settings_path.write_text("not json at all {{{")
result, _settings_path, _backup_path = self._run_login(tmp_path, ["--config-claude"])
result, _settings_path, _backup_path = self._run_login(tmp_path, monkeypatch, ["--config-claude"])
assert result.exit_code != 0
assert "Login successful!" in result.output

View file

@ -1,4 +1,5 @@
import json
import os
import shlex
import stat
import time
@ -9,14 +10,25 @@ from click.testing import CliRunner
from litellm.litellm_core_utils.cli_token_utils import CliTokenRecord
from litellm.proxy.client.cli import cli
from litellm.litellm_core_utils.private_json import commit_staged_json
from litellm.proxy.client.cli.commands.claude_settings import (
ANTHROPIC_DEFAULT_MODEL_ENV_KEYS,
AUTOROUTE_BACKUP_PATH,
BACKUP_PATH,
OWNED_ENV_KEYS,
OWNED_TOP_LEVEL_KEYS,
SETTINGS_FILE_OWNERS,
ApiKeyHelper,
ClaudeSettingsError,
KeepModel,
SettingsFileOwner,
StartOn,
StaticToken,
UnpinModel,
configure_claude_settings,
merge_claude_settings,
resolve_api_key_helper,
write_claude_settings,
unconfigure_claude_settings,
)
@ -24,6 +36,7 @@ def _owners(*backup_paths):
"""Stand-in owners for the real `lite up` / `lite autoroute up` registry."""
return tuple(SettingsFileOwner(path, "lite up", "lite down") for path in backup_paths)
CLAUDE_SETTINGS_MODULE = "litellm.proxy.client.cli.commands.claude_settings"
AUTH_MODULE = "litellm.proxy.client.cli.commands.auth"
WINDOWS_LITE_EXE = "C:\\Users\\u\\AppData\\Local\\Programs\\Python\\Python313\\Scripts\\lite.EXE"
@ -97,17 +110,28 @@ def lite_on_path():
yield
class TestWriteClaudeSettings:
def _helper_configure(base_url, settings_path, owners, state_path=None):
"""`lite login --config-claude`'s shape: the login credential behind apiKeyHelper, no pinned model."""
state = state_path if state_path is not None else settings_path.parent.parent / "state.json"
root = base_url.rstrip("/")
configure_claude_settings(
root, ApiKeyHelper(resolve_api_key_helper(root)), KeepModel(), settings_path, state, owners
)
class TestConfigureWithTheLoginHelper:
def test_creates_the_file_and_its_parent_when_missing(self, paths, lite_on_path):
settings_path, backup_path = paths
assert not settings_path.parent.exists()
write_claude_settings("https://proxy.example.com/", settings_path, _owners(backup_path))
_helper_configure("https://proxy.example.com/", settings_path, _owners(backup_path))
written = json.loads(settings_path.read_text())
assert written["env"]["ANTHROPIC_BASE_URL"] == "https://proxy.example.com"
assert written["env"]["ENABLE_TOOL_SEARCH"] == "true"
assert written["env"]["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] == "1"
assert written["apiKeyHelper"] == "/usr/local/bin/lite --base-url https://proxy.example.com auth print-token"
assert "model" not in written
def test_updates_an_existing_file_preserving_unrelated_settings(self, paths, lite_on_path):
settings_path, backup_path = paths
@ -123,7 +147,7 @@ class TestWriteClaudeSettings:
)
)
write_claude_settings("https://proxy.example.com", settings_path, _owners(backup_path))
_helper_configure("https://proxy.example.com", settings_path, _owners(backup_path))
written = json.loads(settings_path.read_text())
assert written["theme"] == "dark"
@ -135,26 +159,31 @@ class TestWriteClaudeSettings:
def test_rerunning_against_a_new_proxy_refreshes_both_base_url_and_helper(self, paths, lite_on_path):
settings_path, backup_path = paths
write_claude_settings("https://first.example.com", settings_path, _owners(backup_path))
write_claude_settings("https://second.example.com", settings_path, _owners(backup_path))
_helper_configure("https://first.example.com", settings_path, _owners(backup_path))
_helper_configure("https://second.example.com", settings_path, _owners(backup_path))
written = json.loads(settings_path.read_text())
assert written["env"]["ANTHROPIC_BASE_URL"] == "https://second.example.com"
assert "second.example.com" in written["apiKeyHelper"]
assert "first.example.com" not in written["apiKeyHelper"]
def test_drops_a_stray_static_api_key_so_the_helper_token_wins(self, paths, lite_on_path):
def test_drops_stray_static_credentials_so_the_helper_token_wins(self, paths, lite_on_path):
# Claude Code prefers ANTHROPIC_AUTH_TOKEN over apiKeyHelper, so a virtual key left behind
# by an earlier `lite configure claude --api-key` would silently keep winning.
settings_path, backup_path = paths
settings_path.parent.mkdir(parents=True)
settings_path.write_text(json.dumps({"env": {"ANTHROPIC_API_KEY": "sk-leaked"}}))
settings_path.write_text(
json.dumps({"env": {"ANTHROPIC_API_KEY": "sk-leaked", "ANTHROPIC_AUTH_TOKEN": "sk-old"}})
)
write_claude_settings("https://proxy.example.com", settings_path, _owners(backup_path))
_helper_configure("https://proxy.example.com", settings_path, _owners(backup_path))
assert "ANTHROPIC_API_KEY" not in json.loads(settings_path.read_text())["env"]
env = json.loads(settings_path.read_text())["env"]
assert "ANTHROPIC_API_KEY" not in env and "ANTHROPIC_AUTH_TOKEN" not in env
def test_written_file_is_owner_only(self, paths, lite_on_path):
settings_path, backup_path = paths
write_claude_settings("https://proxy.example.com", settings_path, _owners(backup_path))
_helper_configure("https://proxy.example.com", settings_path, _owners(backup_path))
assert stat.S_IMODE(settings_path.stat().st_mode) == 0o600
def test_refuses_while_lite_up_holds_a_backup(self, paths, lite_on_path):
@ -162,7 +191,7 @@ class TestWriteClaudeSettings:
backup_path.write_text("{}")
with pytest.raises(ClaudeSettingsError, match="lite down"):
write_claude_settings("https://proxy.example.com", settings_path, _owners(backup_path))
_helper_configure("https://proxy.example.com", settings_path, _owners(backup_path))
assert not settings_path.exists()
@ -172,7 +201,7 @@ class TestWriteClaudeSettings:
settings_path.write_text("not json at all {{{")
with pytest.raises(ClaudeSettingsError, match="invalid JSON"):
write_claude_settings("https://proxy.example.com", settings_path, _owners(backup_path))
_helper_configure("https://proxy.example.com", settings_path, _owners(backup_path))
assert settings_path.read_text() == "not json at all {{{"
@ -180,7 +209,7 @@ class TestWriteClaudeSettings:
settings_path, backup_path = paths
with patch(f"{CLAUDE_SETTINGS_MODULE}.shutil.which", return_value=None):
with pytest.raises(ClaudeSettingsError, match="Could not find `lite`"):
write_claude_settings("https://proxy.example.com", settings_path, _owners(backup_path))
_helper_configure("https://proxy.example.com", settings_path, _owners(backup_path))
assert not settings_path.exists()
@ -196,7 +225,7 @@ class TestWriteClaudeSettings:
settings_path.write_bytes(b'{"theme": "\xff\xfe"}')
with pytest.raises(ClaudeSettingsError, match="invalid JSON"):
write_claude_settings("https://proxy.example.com", settings_path, _owners(backup_path))
_helper_configure("https://proxy.example.com", settings_path, _owners(backup_path))
def test_reports_an_actionable_error_when_the_file_cannot_be_read(self, paths, lite_on_path):
"""An unreadable settings file must not surface as "Authentication failed".
@ -210,16 +239,18 @@ class TestWriteClaudeSettings:
settings_path.mkdir()
with pytest.raises(ClaudeSettingsError, match="Could not read"):
write_claude_settings("https://proxy.example.com", settings_path, _owners(backup_path))
_helper_configure("https://proxy.example.com", settings_path, _owners(backup_path))
def test_reports_an_actionable_error_when_the_file_cannot_be_written(self, paths, lite_on_path):
settings_path, backup_path = paths
with patch(
f"{CLAUDE_SETTINGS_MODULE}.write_private_json",
side_effect=OSError("Read-only file system"),
):
with pytest.raises(ClaudeSettingsError, match="Read-only file system"):
write_claude_settings("https://proxy.example.com", settings_path, _owners(backup_path))
settings_path.parent.mkdir(parents=True)
settings_path.parent.chmod(0o500)
try:
with pytest.raises(ClaudeSettingsError, match="Could not write"):
_helper_configure("https://proxy.example.com", settings_path, _owners(backup_path))
finally:
settings_path.parent.chmod(0o700)
assert not settings_path.exists()
class TestApiKeyHelperIsActuallyInvocable:
@ -303,7 +334,7 @@ class TestConflictingOwnersOfTheSettingsFile:
backup.write_text("{}")
stand_in = SettingsFileOwner(backup, owner.start_command, owner.stop_command)
with pytest.raises(ClaudeSettingsError, match="currently managing"):
write_claude_settings("https://proxy.example.com", settings_path, (stand_in,))
_helper_configure("https://proxy.example.com", settings_path, (stand_in,))
backup.unlink()
assert not settings_path.exists()
@ -314,9 +345,9 @@ class TestConflictingOwnersOfTheSettingsFile:
autoroute = SettingsFileOwner(backup, "lite autoroute up", "lite autoroute down")
with pytest.raises(ClaudeSettingsError, match="`lite autoroute up` is currently managing"):
write_claude_settings("https://proxy.example.com", settings_path, (autoroute,))
_helper_configure("https://proxy.example.com", settings_path, (autoroute,))
with pytest.raises(ClaudeSettingsError, match="Run `lite autoroute down` first"):
write_claude_settings("https://proxy.example.com", settings_path, (autoroute,))
_helper_configure("https://proxy.example.com", settings_path, (autoroute,))
def test_the_registry_matches_the_paths_the_commands_actually_use(self):
"""A second definition of the autoroute dir must not drift from this one."""
@ -341,7 +372,7 @@ class TestDoesNotDestroyUserOwnedStructure:
link.parent.mkdir()
link.symlink_to(real)
write_claude_settings("https://proxy.example.com", link, ())
_helper_configure("https://proxy.example.com", link, ())
assert link.is_symlink()
assert json.loads(real.read_text())["env"]["ANTHROPIC_BASE_URL"] == "https://proxy.example.com"
@ -354,6 +385,456 @@ class TestDoesNotDestroyUserOwnedStructure:
settings_path.write_text(json.dumps({"theme": "dark", "env": "not-an-object"}))
with pytest.raises(ClaudeSettingsError, match="non-object"):
write_claude_settings("https://proxy.example.com", settings_path, _owners(backup_path))
_helper_configure("https://proxy.example.com", settings_path, _owners(backup_path))
assert json.loads(settings_path.read_text())["env"] == "not-an-object"
class TestMergeClaudeSettings:
"""One merge for every way Claude Code gets wired: `lite up`, `lite login --config-claude`,
`lite configure claude` and `lite autoroute up`."""
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"}}
merged = merge_claude_settings(settings, "http://127.0.0.1:4000/", StaticToken("token-abc"))
assert merged["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:4000"
assert merged["env"]["ANTHROPIC_AUTH_TOKEN"] == "token-abc"
assert merged["env"]["ENABLE_TOOL_SEARCH"] == "true"
assert merged["env"]["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] == "1"
assert "ANTHROPIC_API_KEY" not in merged["env"]
assert "apiKeyHelper" not in merged
assert "model" not in merged
assert not any(key in merged["env"] for key in ANTHROPIC_DEFAULT_MODEL_ENV_KEYS)
def test_a_helper_lands_top_level_and_the_static_slots_are_cleared(self):
settings = {"env": {"ANTHROPIC_AUTH_TOKEN": "sk-old", "ANTHROPIC_API_KEY": "leaked"}}
merged = merge_claude_settings(settings, "http://127.0.0.1:4000", ApiKeyHelper("lite auth print-token"))
assert merged["apiKeyHelper"] == "lite auth print-token"
assert "ANTHROPIC_AUTH_TOKEN" not in merged["env"] and "ANTHROPIC_API_KEY" not in merged["env"]
def test_keeps_existing_switch_values_and_unrelated_keys_without_mutating_the_input(self):
settings = {"theme": "dark", "env": {"SOME_OTHER_VAR": "value", "ENABLE_TOOL_SEARCH": "false"}}
merged = merge_claude_settings(settings, "http://127.0.0.1:4000", StaticToken("token-abc"))
assert merged["theme"] == "dark"
assert merged["env"]["SOME_OTHER_VAR"] == "value"
assert merged["env"]["ENABLE_TOOL_SEARCH"] == "false"
assert settings == {"theme": "dark", "env": {"SOME_OTHER_VAR": "value", "ENABLE_TOOL_SEARCH": "false"}}
def test_a_default_model_sets_only_the_row_claude_code_starts_on(self):
merged = merge_claude_settings(
{}, "http://127.0.0.1:4000", StaticToken("token-abc"), default_model="claude-auto"
)
assert merged["model"] == "claude-auto"
assert not any(key in merged["env"] for key in ANTHROPIC_DEFAULT_MODEL_ENV_KEYS)
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.
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"
)
assert {merged["env"][key] for key in ANTHROPIC_DEFAULT_MODEL_ENV_KEYS} == {"autorouter"}
assert "model" not in merged
def test_touches_exactly_the_declared_owned_keys(self):
# The receipt and unconfigure restore exactly OWNED_*_KEYS, so a key the merge writes outside
# that table would be written by configure and never undone.
settings = {
"theme": "dark",
"permissions": {"allow": ["Bash"]},
"env": {"KEEP_ME": "1", "ANTHROPIC_API_KEY": "old", "ENABLE_TOOL_SEARCH": "false"},
"apiKeyHelper": "old-helper",
"model": "old-model",
}
for credential in (StaticToken("token-abc"), ApiKeyHelper("helper")):
merged = merge_claude_settings(settings, "http://127.0.0.1:4000", credential, default_model="claude-auto")
changed_top_level = {key for key in set(settings) | set(merged) if settings.get(key) != merged.get(key)}
assert changed_top_level - {"env"} <= set(OWNED_TOP_LEVEL_KEYS)
changed_env = {
key
for key in set(settings["env"]) | set(merged["env"])
if settings["env"].get(key) != merged["env"].get(key)
}
assert changed_env <= set(OWNED_ENV_KEYS)
assert merged["permissions"] == {"allow": ["Bash"]}
assert merged["env"]["KEEP_ME"] == "1"
PROXY = "http://127.0.0.1:4000"
ANTHROPIC = "https://api.anthropic.com"
HELPER = ApiKeyHelper("lite auth print-token")
ORIGINAL = {
"theme": "dark",
"permissions": {"allow": ["Bash"]},
"env": {"KEEP_ME": "1", "ANTHROPIC_API_KEY": "sk-ant-mine", "ANTHROPIC_BASE_URL": ANTHROPIC},
"apiKeyHelper": "/usr/local/bin/lite auth print-token",
"model": "claude-opus-5",
}
def _set(path, value):
"""A user edit: set (or with `_ABSENT`, remove) the key at a dotted path in the settings file."""
def edit(settings):
section, _, key = path.rpartition(".")
container = settings.setdefault(section, {}) if section else settings
if value is _ABSENT:
container.pop(key, None)
else:
container[key] = value
return settings
return edit
_ABSENT = object()
class _Rig:
"""One settings file plus receipt under tmp_path, driven through the public functions only."""
def __init__(self, tmp_path, initial):
self.settings = tmp_path / "claude" / "settings.json"
self.state = tmp_path / "state" / "claude_configure_state.json"
if initial is not None:
self.settings.parent.mkdir(parents=True)
self.settings.write_text(json.dumps(initial))
def read(self):
return json.loads(self.settings.read_text()) if self.settings.exists() else None
def configure(self, credential=StaticToken("sk-virtual-key"), model=StartOn("claude-auto"), **kwargs):
configure_claude_settings(PROXY, credential, model, self.settings, self.state, (), **kwargs)
def edit(self, *edits):
settings = self.read()
for apply in edits:
settings = apply(settings)
self.settings.write_text(json.dumps(settings))
def unconfigure(self):
return unconfigure_claude_settings(self.settings, self.state, ())
# Each row: initial file, steps (configure kwargs dicts or edit callables) between the first configure
# and unconfigure, the expected file afterwards, and the expected outcome fields. Sequences that used
# to be one test each; the receipt's rules are what make them all come out right.
UNDO_SCENARIOS = {
"plain round trip": (ORIGINAL, [], ORIGINAL, {"kept": ()}),
"no file before": (None, [], None, {"file_removed": True}),
"no env before": ({"theme": "dark"}, [], {"theme": "dark"}, {}),
"null env before": ({"theme": "dark", "env": None}, [], {"theme": "dark", "env": None}, {}),
"empty env before": ({"theme": "dark", "env": {}}, [], {"theme": "dark", "env": {}}, {}),
"user edits stay and are named": (
ORIGINAL,
[_set("env.ENABLE_TOOL_SEARCH", "false"), _set("model", "claude-sonnet-4-6")],
{**ORIGINAL, "env": {**ORIGINAL["env"], "ENABLE_TOOL_SEARCH": "false"}, "model": "claude-sonnet-4-6"},
{"kept": {"env.ENABLE_TOOL_SEARCH", "model"}, "withheld": ()},
),
"user filled an env configure created": (None, [_set("env.MY_VAR", "mine")], {"env": {"MY_VAR": "mine"}}, {}),
"user deleted the file": (None, [lambda s: None], None, {"file_removed": True, "restored": (), "kept": ()}),
"user removed our key: neither restored nor kept": (
ORIGINAL,
[_set("env.ANTHROPIC_AUTH_TOKEN", _ABSENT)],
ORIGINAL,
{"not_restored": {"env.ANTHROPIC_AUTH_TOKEN"}, "kept": ()},
),
"restored names only what changed": (
{"model": "claude-opus-5"},
[],
{"model": "claude-opus-5"},
{
"restored": {
"env.ANTHROPIC_BASE_URL",
"env.ENABLE_TOOL_SEARCH",
"env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY",
"apiKeyHelper",
},
"kept": (),
},
{"credential": HELPER, "model": KeepModel()},
),
"repeat across credential kinds keeps the first snapshot": (
ORIGINAL,
[
{"credential": HELPER, "model": UnpinModel()},
{"credential": StaticToken("sk-rotated"), "model": StartOn("claude-sonnet-4-6")},
],
ORIGINAL,
{},
),
"repeat without a model lets go of our pin, user had none": ({}, [{"model": UnpinModel()}], {}, {}),
"repeat without a model lets go of our pin, user had one": (
{"model": "claude-opus-5"},
[{"model": UnpinModel()}],
{"model": "claude-opus-5"},
{},
),
"re-login keeps our pin": (None, [{"credential": HELPER, "model": KeepModel()}], None, {"file_removed": True}),
"edit between configures survives an unpin repeat": (
ORIGINAL,
[
_set("model", "my-favourite"),
_set("env.ENABLE_TOOL_SEARCH", "false"),
{"credential": HELPER, "model": UnpinModel()},
],
{**ORIGINAL, "env": {**ORIGINAL["env"], "ENABLE_TOOL_SEARCH": "false"}, "model": "my-favourite"},
{"kept": {"env.ENABLE_TOOL_SEARCH", "model"}},
),
"edit between configures survives a re-login": (
ORIGINAL,
[
_set("model", "my-favourite"),
_set("env.ENABLE_TOOL_SEARCH", "false"),
{"credential": HELPER, "model": KeepModel()},
],
{**ORIGINAL, "env": {**ORIGINAL["env"], "ENABLE_TOOL_SEARCH": "false"}, "model": "my-favourite"},
{"kept": {"env.ENABLE_TOOL_SEARCH", "model"}},
),
"edit between configures: a same-model repeat displaces it, so it is what comes back": (
ORIGINAL,
[_set("model", "my-favourite"), _set("env.ENABLE_TOOL_SEARCH", "false"), {"credential": HELPER}],
{**ORIGINAL, "env": {**ORIGINAL["env"], "ENABLE_TOOL_SEARCH": "false"}, "model": "my-favourite"},
{"kept": {"env.ENABLE_TOOL_SEARCH"}, "restored_includes": {"model"}},
),
"base URL changed since: credentials withheld, receipt kept": (
ORIGINAL,
[_set("env.ANTHROPIC_BASE_URL", "http://other-proxy:4000")],
{**ORIGINAL, "env": {"KEEP_ME": "1", "ANTHROPIC_BASE_URL": "http://other-proxy:4000"}, "apiKeyHelper": _ABSENT},
{
"withheld": {("env.ANTHROPIC_API_KEY", ANTHROPIC), ("apiKeyHelper", ANTHROPIC)},
"kept": {"env.ANTHROPIC_BASE_URL"},
"receipt_kept": True,
},
),
"base URL changed and back: judged against the URL the restored file holds": (
ORIGINAL,
[_set("env.ANTHROPIC_BASE_URL", ANTHROPIC)],
ORIGINAL,
{"withheld": ()},
),
"credential captured beside no URL goes back only beside no URL": (
{"env": {"ANTHROPIC_API_KEY": "sk-default-endpoint"}},
[_set("env.ANTHROPIC_BASE_URL", "http://other-proxy:4000")],
{"env": {"ANTHROPIC_BASE_URL": "http://other-proxy:4000"}},
{
"withheld": {("env.ANTHROPIC_API_KEY", "no ANTHROPIC_BASE_URL (Anthropic's default endpoint)")},
"receipt_kept": True,
},
),
"restored document empty while a credential is withheld: file goes, receipt stays": (
None,
[
_set("env.ANTHROPIC_API_KEY", "sk-user"),
{"credential": HELPER, "model": KeepModel()},
_set("env.ANTHROPIC_BASE_URL", _ABSENT),
],
None,
{"withheld": {("env.ANTHROPIC_API_KEY", PROXY)}, "file_removed": True, "receipt_kept": True},
{"credential": HELPER, "model": KeepModel()},
),
"a credential the user changed is kept, never also withheld": (
ORIGINAL,
[_set("env.ANTHROPIC_BASE_URL", "http://other-proxy:4000"), _set("apiKeyHelper", "/opt/mine/helper")],
{
**ORIGINAL,
"env": {"KEEP_ME": "1", "ANTHROPIC_BASE_URL": "http://other-proxy:4000"},
"apiKeyHelper": "/opt/mine/helper",
},
{
"withheld": {("env.ANTHROPIC_API_KEY", ANTHROPIC)},
"kept": {"env.ANTHROPIC_BASE_URL", "apiKeyHelper"},
"receipt_kept": True,
},
),
}
def _expected_file(expected):
if expected is None:
return None
return {k: v for k, v in expected.items() if v is not _ABSENT}
class TestConfigureAndUnconfigure:
"""`configure_claude_settings` records how to undo itself; `unconfigure_claude_settings` undoes only that."""
@pytest.mark.parametrize("scenario", UNDO_SCENARIOS.values(), ids=UNDO_SCENARIOS.keys())
def test_undo_matrix(self, tmp_path, scenario):
initial, steps, expected, outcome_expectations, *first = scenario
rig = _Rig(tmp_path, initial)
rig.configure(**(first[0] if first else {}))
for step in steps:
if isinstance(step, dict):
rig.configure(**step)
elif rig.settings.exists() and step(json.loads(rig.settings.read_text())) is None:
rig.settings.unlink()
else:
rig.edit(step)
outcome = rig.unconfigure()
assert rig.read() == _expected_file(expected)
assert rig.state.exists() == outcome_expectations.get("receipt_kept", False)
for field, want in outcome_expectations.items():
if field == "withheld":
assert {(item.key, item.endpoint) for item in outcome.withheld} == set(want)
elif field == "not_restored":
assert not set(want) & set(outcome.restored) and not set(want) & set(outcome.kept)
elif field == "restored_includes":
assert set(want) <= set(outcome.restored)
elif field in ("restored", "kept"):
assert set(getattr(outcome, field)) == set(want)
elif field != "receipt_kept":
assert getattr(outcome, field) == want
assert not {item.key for item in outcome.withheld} & set(outcome.kept)
def test_configure_writes_owner_only_and_the_receipt_never_holds_the_key(self, tmp_path):
rig = _Rig(tmp_path, ORIGINAL)
rig.configure(credential=StaticToken("sk-virtual-key-never-on-disk-twice"))
configured = rig.read()
assert configured["env"]["ANTHROPIC_AUTH_TOKEN"] == "sk-virtual-key-never-on-disk-twice"
assert configured["env"]["ANTHROPIC_BASE_URL"] == PROXY and configured["model"] == "claude-auto"
assert "ANTHROPIC_API_KEY" not in configured["env"] and "apiKeyHelper" not in configured
assert stat.S_IMODE(rig.settings.stat().st_mode) == 0o600 == stat.S_IMODE(rig.state.stat().st_mode)
assert "sk-virtual-key-never-on-disk-twice" not in rig.state.read_text()
def test_withheld_credentials_come_back_once_the_url_points_at_their_server_again(self, tmp_path):
# The kept receipt owns only the withheld slots: the second unconfigure restores exactly those.
rig = _Rig(tmp_path, ORIGINAL)
rig.configure()
rig.edit(_set("env.ANTHROPIC_BASE_URL", "http://other-proxy:4000"))
rig.unconfigure()
rig.edit(_set("env.ANTHROPIC_BASE_URL", ANTHROPIC), _set("theme", "light"))
outcome = rig.unconfigure()
assert rig.read() == {**ORIGINAL, "theme": "light"}
assert set(outcome.restored) == {"env.ANTHROPIC_API_KEY", "apiKeyHelper"}
assert outcome.kept == () and outcome.withheld == () and not rig.state.exists()
@pytest.mark.parametrize(
("path", "value", "repeat_credential"),
[
("env.ANTHROPIC_API_KEY", "sk-user-added-later", HELPER),
("env.ANTHROPIC_AUTH_TOKEN", "sk-users-own-token", HELPER),
("apiKeyHelper", "/opt/mine/helper", StaticToken("sk-rotated")),
],
ids=["user-adds-api-key", "user-replaces-our-token", "user-sets-own-helper"],
)
def test_a_credential_the_user_set_between_two_configures_is_what_comes_back(
self, tmp_path, path, value, repeat_credential
):
# The repeat's merge clears the slot, so the displaced value is snapshotted and is what returns;
# it was set while the file pointed at the proxy, so it returns once the file points there again.
rig = _Rig(tmp_path, {"theme": "dark"})
rig.configure(credential=HELPER, model=KeepModel())
rig.edit(_set(path, value))
rig.configure(credential=repeat_credential, model=KeepModel())
assert not _lookup(rig.read(), path)
outcome = rig.unconfigure()
assert [(item.key, item.endpoint) for item in outcome.withheld] == [(path, PROXY)]
assert rig.read() == {"theme": "dark"} and rig.state.exists()
rig.settings.write_text(json.dumps({"theme": "dark", "env": {"ANTHROPIC_BASE_URL": PROXY}}))
outcome = rig.unconfigure()
assert _lookup(rig.read(), path) == value
assert outcome.restored == (path,) and outcome.withheld == () and not rig.state.exists()
def test_a_receipt_commit_that_fails_leaves_no_staged_token_behind(self, tmp_path):
rig = _Rig(tmp_path, {})
def commit_receipt_fails(staged, path):
if path == str(rig.state):
os.unlink(staged)
raise OSError("receipt rename failed")
commit_staged_json(staged, path)
with pytest.raises(ClaudeSettingsError, match=r"Could not write .*receipt rename failed"):
rig.configure(credential=StaticToken("sk-never-left-in-a-temp-file"), commit=commit_receipt_fails)
assert not list(rig.settings.parent.glob(".tmp-*")) and not list(rig.state.parent.glob(".tmp-*"))
assert rig.read() == {} and not rig.state.exists()
@pytest.mark.parametrize("configured_before", [False, True], ids=["first-configure", "repeat-configure"])
def test_a_settings_commit_that_fails_after_the_receipt_landed_puts_the_receipt_back(
self, tmp_path, configured_before
):
# The two renames are not atomic: a settings rename that fails after the receipt landed must
# not leave a receipt describing settings that were never written.
rig = _Rig(tmp_path, ORIGINAL)
if configured_before:
rig.configure()
receipt_before = rig.state.read_text() if configured_before else None
settings_before = rig.settings.read_text()
def commit_settings_fails(staged, path):
if path == str(rig.settings):
os.unlink(staged)
raise OSError("rename failed")
commit_staged_json(staged, path)
with pytest.raises(ClaudeSettingsError, match="rename failed"):
rig.configure(credential=StaticToken("sk-rotated"), commit=commit_settings_fails)
assert rig.settings.read_text() == settings_before
assert (rig.state.read_text() if rig.state.exists() else None) == receipt_before
if configured_before:
rig.unconfigure()
assert rig.read() == ORIGINAL
def test_a_failed_repeat_configure_leaves_the_earlier_undo_intact(self, tmp_path):
rig = _Rig(tmp_path, ORIGINAL)
rig.configure()
receipt_before = rig.state.read_text()
rig.settings.parent.chmod(0o500)
try:
with pytest.raises(ClaudeSettingsError, match="Could not write"):
rig.configure(credential=StaticToken("sk-rotated"))
finally:
rig.settings.parent.chmod(0o700)
assert rig.state.read_text() == receipt_before and not list(rig.state.parent.glob(".tmp-*"))
assert rig.read()["env"]["ANTHROPIC_AUTH_TOKEN"] == "sk-virtual-key"
rig.unconfigure()
assert rig.read() == ORIGINAL
def test_unconfigure_reports_a_receipt_it_cannot_remove_as_a_settings_error(self, tmp_path):
rig = _Rig(tmp_path, ORIGINAL)
rig.configure()
rig.state.parent.chmod(0o500)
try:
with pytest.raises(ClaudeSettingsError, match="Could not remove"):
rig.unconfigure()
finally:
rig.state.parent.chmod(0o700)
def test_configure_writes_through_a_symlinked_settings_file(self, tmp_path):
target = tmp_path / "dotfiles" / "settings.json"
target.parent.mkdir()
target.write_text(json.dumps({"theme": "dark"}))
link = tmp_path / "settings.json"
link.symlink_to(target)
configure_claude_settings(PROXY, StaticToken("sk-virtual-key"), UnpinModel(), link, tmp_path / "state.json", ())
assert link.is_symlink()
assert json.loads(target.read_text())["env"]["ANTHROPIC_AUTH_TOKEN"] == "sk-virtual-key"
@pytest.mark.parametrize("operation", ["configure", "unconfigure"])
def test_refuses_while_a_temporary_owner_holds_a_backup(self, paths, tmp_path, operation):
settings_path, backup_path = paths
backup_path.write_text("{}")
owners = _owners(backup_path)
state = tmp_path / "state.json"
attempt = (
(lambda: configure_claude_settings(PROXY, StaticToken("k"), UnpinModel(), settings_path, state, owners))
if operation == "configure"
else (lambda: unconfigure_claude_settings(settings_path, state, owners))
)
with pytest.raises(ClaudeSettingsError, match="lite down"):
attempt()
assert not settings_path.exists()
def test_unconfigure_without_a_receipt_is_an_error_not_a_silent_no_op(self, tmp_path):
with pytest.raises(ClaudeSettingsError, match="nothing to undo"):
_Rig(tmp_path, None).unconfigure()
def _lookup(settings, path):
section, _, key = path.rpartition(".")
return (settings.get(section) or {}).get(key) if section else settings.get(key)

View file

@ -0,0 +1,331 @@
import json
import os
import stat
import click
import pytest
import requests
import responses
from click.testing import CliRunner
from litellm.proxy.client.cli import cli
from litellm.proxy.client.cli.commands import configure as configure_module
from litellm.proxy.client.cli.commands.claude_settings import SettingsFileOwner
from litellm.proxy.client.cli.commands.configure import configure_claude, configure_group, interactive_configure
PROXY = "http://proxy.test:4000"
VALID_KEY = "sk-virtual-key"
LISTED_MODELS = ("claude-auto", "gpt-5.6-luna")
def _mock_models():
responses.get(
f"{PROXY}/v1/models",
json={"data": [{"id": model, "object": "model"} for model in LISTED_MODELS]},
match=[responses.matchers.header_matcher({"Authorization": f"Bearer {VALID_KEY}"})],
)
responses.get(f"{PROXY}/v1/models", status=401)
@pytest.fixture
def paths(monkeypatch, tmp_path):
settings_path = tmp_path / "claude" / "settings.json"
state_path = tmp_path / "litellm" / "claude_configure_state.json"
monkeypatch.setattr(configure_module, "CLAUDE_SETTINGS_PATH", settings_path)
monkeypatch.setattr(configure_module, "CONFIGURE_STATE_PATH", state_path)
return settings_path, state_path
@pytest.fixture
def lite_on_path(monkeypatch, tmp_path):
"""A real `lite` executable on PATH, so the apiKeyHelper command resolves without patching."""
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
lite = bin_dir / "lite"
lite.write_text("#!/bin/sh\nexit 0\n")
lite.chmod(lite.stat().st_mode | stat.S_IXUSR)
monkeypatch.setenv("PATH", f"{bin_dir}{os.pathsep}{os.environ.get('PATH', '')}")
return str(lite)
@pytest.fixture
def runner():
return CliRunner()
@pytest.fixture
def lite_up_backup(monkeypatch, tmp_path):
"""A `lite up` session holding its backup, the local precondition every settings write refuses on."""
backup = tmp_path / "claude_settings_backup.json"
backup.write_text("{}")
monkeypatch.setattr(configure_module, "SETTINGS_FILE_OWNERS", (SettingsFileOwner(backup, "lite up", "lite down"),))
return backup
def _configure(runner, *args):
return runner.invoke(cli, ["--base-url", PROXY, "configure", "claude", *args])
class TestConfigureClaudeWithAVirtualKey:
@responses.activate
def test_writes_settings_and_reports_without_echoing_the_key(self, runner, paths):
_mock_models()
settings_path, state_path = paths
result = _configure(runner, "--api-key", VALID_KEY, "--model", "claude-auto")
assert result.exit_code == 0, result.output
written = json.loads(settings_path.read_text())
assert written["env"]["ANTHROPIC_BASE_URL"] == PROXY
assert written["env"]["ANTHROPIC_AUTH_TOKEN"] == VALID_KEY
assert written["env"]["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] == "1"
assert written["model"] == "claude-auto"
assert "ANTHROPIC_DEFAULT_SONNET_MODEL" not in written["env"]
assert state_path.exists()
assert VALID_KEY not in result.output
assert "Starting model: claude-auto" in result.output
assert "1 of the proxy's 2 models" in result.output
assert "lite unconfigure claude" in result.output
assert len(responses.calls) == 1
@responses.activate
def test_takes_the_key_from_the_global_option_and_keeps_claude_codes_default(self, runner, paths):
_mock_models()
settings_path, _ = paths
result = runner.invoke(cli, ["--base-url", PROXY, "--api-key", VALID_KEY, "configure", "claude"])
assert result.exit_code == 0, result.output
written = json.loads(settings_path.read_text())
assert written["env"]["ANTHROPIC_AUTH_TOKEN"] == VALID_KEY
assert "model" not in written
assert "Starting model: not pinned" in result.output
@responses.activate
def test_refuses_a_model_the_proxy_does_not_list(self, runner, paths):
_mock_models()
settings_path, _ = paths
result = _configure(runner, "--api-key", VALID_KEY, "--model", "claude-nope")
assert result.exit_code != 0
assert "'claude-nope' is not served" in result.output
assert "claude-auto, gpt-5.6-luna" in result.output
assert not settings_path.exists()
@responses.activate
def test_refuses_a_key_the_proxy_rejects(self, runner, paths):
_mock_models()
settings_path, _ = paths
result = _configure(runner, "--api-key", "sk-wrong")
assert result.exit_code != 0
assert "rejected your key (HTTP 401)" in result.output
assert not settings_path.exists()
@responses.activate
@pytest.mark.parametrize(
("mock", "expected", "unexpected"),
[
(
lambda: responses.get(f"{PROXY}/v1/models", body=requests.ConnectionError("refused")),
"Is the proxy at",
"answered",
),
(
lambda: responses.get(f"{PROXY}/v1/models", status=500),
"The proxy at http://proxy.test:4000 answered",
"Is the proxy at",
),
(
lambda: responses.get(f"{PROXY}/v1/models", body="<html>not json</html>"),
"answered, so check that it is a LiteLLM proxy",
"Is the proxy at",
),
(
lambda: responses.get(f"{PROXY}/v1/models", json={"data": []}),
"Claude Code would have nothing to run",
"Is the proxy at",
),
],
ids=["unreachable", "http-500", "non-json-body", "empty-list"],
)
def test_the_listing_hint_matches_how_the_listing_failed(self, runner, paths, mock, expected, unexpected):
# Only a proxy that never answered gets the "is it running" question; a 500, a non-JSON body or an
# empty list prove it is up, and the hint says so instead.
mock()
settings_path, _ = paths
result = _configure(runner, "--api-key", VALID_KEY)
assert result.exit_code != 0
assert expected in result.output and unexpected not in result.output
assert not settings_path.exists()
@responses.activate
@pytest.mark.parametrize("entry", ["virtual-key", "login", "interactive"])
def test_refuses_while_lite_up_holds_a_backup_before_any_login_or_request(
self, runner, paths, monkeypatch, lite_up_backup, entry
):
_mock_models()
def login_must_not_run(ctx):
raise AssertionError("the local precondition must be checked before a login is attempted")
monkeypatch.setattr(configure_module, "ensure_fresh_login", login_must_not_run)
if entry == "interactive":
ctx = click.Context(configure_group, obj={"base_url": PROXY, "api_key": None})
with pytest.raises(click.ClickException, match="lite down"):
interactive_configure(ctx, pick_targets=lambda: ("claude",), pick_model=lambda listed: None)
else:
args = ["--api-key", VALID_KEY] if entry == "virtual-key" else []
result = runner.invoke(configure_claude, args, obj={"base_url": PROXY, "api_key": None})
assert result.exit_code != 0 and "lite down" in result.output
assert len(responses.calls) == 0
assert not paths[0].exists()
@responses.activate
def test_says_so_when_the_key_is_written_through_a_symlink(self, runner, paths, tmp_path):
_mock_models()
settings_path, _ = paths
target = tmp_path / "dotfiles" / "settings.json"
target.parent.mkdir()
target.write_text("{}")
settings_path.parent.mkdir(parents=True)
settings_path.symlink_to(target)
result = _configure(runner, "--api-key", VALID_KEY)
assert result.exit_code == 0, result.output
assert "keep it out of version control" in result.output
assert json.loads(target.read_text())["env"]["ANTHROPIC_AUTH_TOKEN"] == VALID_KEY
class TestConfigureClaudeWithTheLogin:
def _stored_login(self, monkeypatch):
monkeypatch.setattr(configure_module, "ensure_fresh_login", lambda ctx: None)
monkeypatch.setattr(configure_module, "get_stored_api_key", lambda expected_base_url, vault: VALID_KEY)
@responses.activate
def test_uses_the_login_through_the_helper_and_writes_no_secret(self, runner, paths, monkeypatch, lite_on_path):
_mock_models()
self._stored_login(monkeypatch)
settings_path, _ = paths
result = runner.invoke(
configure_claude,
["--model", "claude-auto"],
obj={"base_url": PROXY, "api_key": VALID_KEY, "api_key_from_token_file": True},
)
assert result.exit_code == 0, result.output
written = json.loads(settings_path.read_text())
assert written["apiKeyHelper"] == f"{lite_on_path} --base-url {PROXY} auth print-token"
assert "ANTHROPIC_AUTH_TOKEN" not in written["env"]
assert written["model"] == "claude-auto"
assert VALID_KEY not in settings_path.read_text()
assert "read through apiKeyHelper" in result.output
@responses.activate
def test_an_explicit_key_still_wins_over_a_stored_login(self, runner, paths, monkeypatch, lite_on_path):
_mock_models()
self._stored_login(monkeypatch)
settings_path, _ = paths
result = runner.invoke(
configure_claude,
["--api-key", VALID_KEY],
obj={"base_url": PROXY, "api_key": "sk-login-jwt", "api_key_from_token_file": True},
)
assert result.exit_code == 0, result.output
written = json.loads(settings_path.read_text())
assert written["env"]["ANTHROPIC_AUTH_TOKEN"] == VALID_KEY and "apiKeyHelper" not in written
class TestInteractiveConfigure:
@responses.activate
def test_asks_for_targets_and_a_starting_model_then_configures(self, paths):
_mock_models()
settings_path, _ = paths
asked = {}
def pick_model(listed):
asked["listed"] = tuple(listed)
return "claude-auto"
ctx = click.Context(
configure_group, obj={"base_url": PROXY, "api_key": VALID_KEY, "api_key_from_token_file": False}
)
interactive_configure(ctx, pick_targets=lambda: ("claude",), pick_model=pick_model)
assert asked["listed"] == LISTED_MODELS
assert json.loads(settings_path.read_text())["model"] == "claude-auto"
def test_does_nothing_when_claude_code_is_not_picked(self, paths):
settings_path, _ = paths
ctx = click.Context(
configure_group, obj={"base_url": PROXY, "api_key": VALID_KEY, "api_key_from_token_file": False}
)
interactive_configure(ctx, pick_targets=lambda: (), pick_model=lambda listed: None)
assert not settings_path.exists()
def test_bare_configure_without_a_terminal_names_the_non_interactive_command(self, runner, paths):
result = runner.invoke(cli, ["--base-url", PROXY, "configure"])
assert result.exit_code != 0
assert "lite configure claude --api-key" in result.output
class TestUnconfigureClaude:
@responses.activate
def test_restores_the_original_file_and_removes_the_receipt(self, runner, paths):
_mock_models()
settings_path, state_path = paths
settings_path.parent.mkdir(parents=True)
original = {"theme": "dark", "model": "claude-opus-5"}
settings_path.write_text(json.dumps(original))
assert _configure(runner, "--api-key", VALID_KEY, "--model", "claude-auto").exit_code == 0
result = runner.invoke(cli, ["unconfigure", "claude"])
assert result.exit_code == 0, result.output
assert json.loads(settings_path.read_text()) == original
assert not state_path.exists()
assert "Restored in" in result.output and "model" in result.output
assert "ANTHROPIC_API_KEY" not in result.output, "a key that never existed was not restored"
@responses.activate
def test_a_file_only_configure_created_is_reported_removed_not_restored(self, runner, paths):
_mock_models()
settings_path, _ = paths
assert _configure(runner, "--api-key", VALID_KEY).exit_code == 0
result = runner.invoke(cli, ["unconfigure", "claude"])
assert result.exit_code == 0, result.output
assert not settings_path.exists()
assert "No settings file remains" in result.output and "Restored" not in result.output
@responses.activate
def test_says_when_nothing_was_still_ours_and_names_what_it_kept(self, runner, paths):
_mock_models()
settings_path, _ = paths
settings_path.parent.mkdir(parents=True)
settings_path.write_text(json.dumps({"theme": "dark"}))
assert _configure(runner, "--api-key", VALID_KEY, "--model", "claude-auto").exit_code == 0
edited = json.loads(settings_path.read_text())
edited["env"] = {key: f"{value}-edited" for key, value in edited["env"].items()}
edited["model"] = "mine"
settings_path.write_text(json.dumps(edited))
result = runner.invoke(cli, ["unconfigure", "claude"])
assert result.exit_code == 0, result.output
assert "Nothing in" in result.output and "was still ours to restore" in result.output
assert "Left as you changed them since:" in result.output and "model" in result.output
@responses.activate
def test_names_the_server_a_withheld_credential_was_captured_with_and_keeps_the_receipt(self, runner, paths):
_mock_models()
settings_path, state_path = paths
settings_path.parent.mkdir(parents=True)
settings_path.write_text(
json.dumps({"env": {"ANTHROPIC_BASE_URL": "https://api.anthropic.com", "ANTHROPIC_API_KEY": "sk-ant"}})
)
assert _configure(runner, "--api-key", VALID_KEY).exit_code == 0
edited = json.loads(settings_path.read_text())
edited["env"]["ANTHROPIC_BASE_URL"] = "http://other-proxy:4000"
settings_path.write_text(json.dumps(edited))
result = runner.invoke(cli, ["unconfigure", "claude"])
assert result.exit_code == 0, result.output
assert "env.ANTHROPIC_API_KEY (captured with https://api.anthropic.com)" in result.output
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_without_a_receipt_it_fails_loudly(self, runner, paths):
result = runner.invoke(cli, ["unconfigure", "claude"])
assert result.exit_code != 0
assert "nothing to undo" in result.output

View file

@ -4,9 +4,11 @@ import stat
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import pytest
import requests
from litellm.proxy.client.cli.commands.pi import (
ListingFailure,
ModelLimits,
PiSyncError,
fetch_model_ids,
@ -28,6 +30,10 @@ class _FakeResponse:
return self._payload
def _refused(*args, **kwargs):
raise requests.ConnectionError("refused")
class TestFetchModelIds:
def test_returns_ids_in_proxy_order_deduped(self):
captured = {}
@ -53,9 +59,7 @@ class TestFetchModelIds:
assert "Could not list models" in result.message
def test_non_200_is_a_value(self):
result = fetch_model_ids(
"http://localhost:4000", "sk-key", get=lambda *a, **k: _FakeResponse(500)
)
result = fetch_model_ids("http://localhost:4000", "sk-key", get=lambda *a, **k: _FakeResponse(500))
assert isinstance(result, PiSyncError)
assert "HTTP 500" in result.message
@ -75,6 +79,22 @@ class TestFetchModelIds:
)
assert isinstance(result, PiSyncError)
assert "no models" in result.message
assert result.kind is ListingFailure.EMPTY
@pytest.mark.parametrize(
("get", "kind"),
[
(_refused, ListingFailure.UNREACHABLE),
(lambda *a, **k: _FakeResponse(401), ListingFailure.REJECTED),
(lambda *a, **k: _FakeResponse(403), ListingFailure.REJECTED),
(lambda *a, **k: _FakeResponse(500), ListingFailure.OTHER),
(lambda *a, **k: _FakeResponse(200), ListingFailure.BAD_BODY),
],
ids=["unreachable", "401", "403", "500", "bad-body"],
)
def test_the_failure_kind_is_decided_where_the_response_is_classified(self, get, kind):
result = fetch_model_ids("http://localhost:4000", "sk-key", get=get)
assert isinstance(result, PiSyncError) and result.kind is kind
class TestFetchModelLimits:

View file

@ -11,11 +11,11 @@ from click.testing import CliRunner
from litellm.proxy.client.cli.commands import up as up_module
from litellm.proxy.client.cli.commands.agents import AgentRunError
from litellm.proxy.client.cli.commands.claude_settings import ClaudeSettingsError
from litellm.proxy.client.cli.commands.claude_settings import ApiKeyHelper, ClaudeSettingsError
from litellm.proxy.client.cli.commands.up import (
BackupRecord,
UpError,
_ensure_fresh_login,
ensure_fresh_login,
down,
load_json_or_empty,
merge_claude_settings,
@ -40,12 +40,12 @@ def _patch_paths(monkeypatch, tmp_path):
class TestMergeClaudeSettings:
def test_preserves_unrelated_top_level_keys(self):
merged = merge_claude_settings({"theme": "dark"}, "http://localhost:4000", "helper")
merged = merge_claude_settings({"theme": "dark"}, "http://localhost:4000", ApiKeyHelper("helper"))
assert merged["theme"] == "dark"
def test_preserves_unrelated_env_keys(self):
settings = {"env": {"SOME_OTHER_VAR": "value"}}
merged = merge_claude_settings(settings, "http://localhost:4000", "helper")
merged = merge_claude_settings(settings, "http://localhost:4000", ApiKeyHelper("helper"))
assert merged["env"]["SOME_OTHER_VAR"] == "value"
def test_overrides_base_url_and_helper(self):
@ -53,7 +53,7 @@ class TestMergeClaudeSettings:
"env": {"ANTHROPIC_BASE_URL": "https://old.example.com"},
"apiKeyHelper": "old-helper",
}
merged = merge_claude_settings(settings, "http://localhost:4000/", "new-helper")
merged = merge_claude_settings(settings, "http://localhost:4000/", ApiKeyHelper("new-helper"))
assert merged["env"]["ANTHROPIC_BASE_URL"] == "http://localhost:4000"
assert merged["env"]["ENABLE_TOOL_SEARCH"] == "true"
assert merged["env"]["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] == "1"
@ -61,21 +61,21 @@ class TestMergeClaudeSettings:
def test_preserves_existing_gateway_model_discovery(self):
settings = {"env": {"CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY": "0"}}
merged = merge_claude_settings(settings, "http://localhost:4000", "helper")
merged = merge_claude_settings(settings, "http://localhost:4000", ApiKeyHelper("helper"))
assert merged["env"]["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] == "0"
def test_preserves_existing_tool_search(self):
settings = {"env": {"ENABLE_TOOL_SEARCH": "false"}}
merged = merge_claude_settings(settings, "http://localhost:4000", "helper")
merged = merge_claude_settings(settings, "http://localhost:4000", ApiKeyHelper("helper"))
assert merged["env"]["ENABLE_TOOL_SEARCH"] == "false"
def test_drops_stray_api_key(self):
settings = {"env": {"ANTHROPIC_API_KEY": "leaked-key"}}
merged = merge_claude_settings(settings, "http://localhost:4000", "helper")
merged = merge_claude_settings(settings, "http://localhost:4000", ApiKeyHelper("helper"))
assert "ANTHROPIC_API_KEY" not in merged["env"]
def test_works_from_empty_settings(self):
merged = merge_claude_settings({}, "http://localhost:4000", "helper")
merged = merge_claude_settings({}, "http://localhost:4000", ApiKeyHelper("helper"))
assert merged["env"] == {
"ANTHROPIC_BASE_URL": "http://localhost:4000",
"ENABLE_TOOL_SEARCH": "true",
@ -85,7 +85,7 @@ class TestMergeClaudeSettings:
def test_does_not_mutate_input(self):
settings = {"env": {"FOO": "bar"}}
merge_claude_settings(settings, "http://localhost:4000", "helper")
merge_claude_settings(settings, "http://localhost:4000", ApiKeyHelper("helper"))
assert settings == {"env": {"FOO": "bar"}}
@ -327,7 +327,7 @@ class TestEnsureFreshLogin:
monkeypatch.setattr(up_module, "is_cli_token_fresh", lambda token_data: True)
login_calls = _capture_login(monkeypatch)
_ensure_fresh_login(_make_ctx("http://proxy-a:4000"))
ensure_fresh_login(_make_ctx("http://proxy-a:4000"))
assert login_calls == []
@ -339,7 +339,7 @@ class TestEnsureFreshLogin:
monkeypatch, on_login=lambda: store.log_in({"key": "sk-b", "base_url": "http://proxy-b:4000"}, "sk-b")
)
_ensure_fresh_login(_make_ctx("http://proxy-b:4000"))
ensure_fresh_login(_make_ctx("http://proxy-b:4000"))
assert login_calls == [("http://proxy-b:4000", False)]
assert store.key_requests == ["http://proxy-b:4000", "http://proxy-b:4000"]
@ -353,7 +353,7 @@ class TestEnsureFreshLogin:
on_login=lambda: store.log_in({"key": "sk-a", "base_url": "http://proxy-a:4000"}, "sk-a"),
)
_ensure_fresh_login(_make_ctx("http://proxy-a:4000"))
ensure_fresh_login(_make_ctx("http://proxy-a:4000"))
assert login_calls == [("http://proxy-a:4000", False)]
@ -363,7 +363,7 @@ class TestEnsureFreshLogin:
monkeypatch.setattr(up_module, "is_cli_token_fresh", lambda token_data: True)
with pytest.raises(UpError, match="Run `lite login` first"):
_ensure_fresh_login(_make_ctx("http://proxy-b:4000"))
ensure_fresh_login(_make_ctx("http://proxy-b:4000"))
def test_trusts_a_pkce_credential_that_was_renewed_on_the_way_in(self, monkeypatch):
"""A --pkce key inside its freshness buffer is renewed by `get_stored_api_key`, so `lite up`
@ -377,7 +377,7 @@ class TestEnsureFreshLogin:
)
login_calls = _capture_login(monkeypatch)
_ensure_fresh_login(_make_ctx("http://proxy-a:4000"))
ensure_fresh_login(_make_ctx("http://proxy-a:4000"))
assert login_calls == []
assert store.key_requests == ["http://proxy-a:4000"]
@ -390,7 +390,7 @@ class TestEnsureFreshLogin:
on_login=lambda: store.log_in(_pkce_record("http://proxy-a:4000", seconds_left=86_400), "sk-pkce-fresh"),
)
_ensure_fresh_login(_make_ctx("http://proxy-a:4000"))
ensure_fresh_login(_make_ctx("http://proxy-a:4000"))
assert login_calls == [("http://proxy-a:4000", True)]
@ -399,7 +399,7 @@ class TestEnsureFreshLogin:
_FakeTokenStore(monkeypatch, _pkce_record("http://proxy-a:4000", seconds_left=-10), {})
with pytest.raises(UpError, match="Run `lite login --pkce` first"):
_ensure_fresh_login(_make_ctx("http://proxy-a:4000"))
ensure_fresh_login(_make_ctx("http://proxy-a:4000"))
def test_trusts_the_key_the_cli_group_already_resolved_instead_of_reading_the_token_file_again(
self, monkeypatch
@ -409,7 +409,7 @@ class TestEnsureFreshLogin:
store = _FakeTokenStore(monkeypatch, _pkce_record("http://proxy-a:4000", seconds_left=86_400), {})
login_calls = _capture_login(monkeypatch)
_ensure_fresh_login(_make_group_ctx("http://proxy-a:4000", api_key="sk-pkce-renewed-by-the-group"))
ensure_fresh_login(_make_group_ctx("http://proxy-a:4000", api_key="sk-pkce-renewed-by-the-group"))
assert login_calls == []
assert store.key_requests == []
@ -423,7 +423,7 @@ class TestEnsureFreshLogin:
)
with pytest.raises(UpError, match="Run `lite login --pkce` first"):
_ensure_fresh_login(_make_group_ctx("http://proxy-a:4000", api_key=None))
ensure_fresh_login(_make_group_ctx("http://proxy-a:4000", api_key=None))
assert store.key_requests == []
@ -435,7 +435,7 @@ class TestEnsureFreshLogin:
on_login=lambda: store.log_in(_pkce_record("http://proxy-a:4000", seconds_left=86_400), "sk-pkce-fresh"),
)
_ensure_fresh_login(_make_group_ctx("http://proxy-a:4000", api_key=None))
ensure_fresh_login(_make_group_ctx("http://proxy-a:4000", api_key=None))
assert login_calls == [("http://proxy-a:4000", True)]
assert store.key_requests == ["http://proxy-a:4000"]