Merge pull request #40489 from BerriAI/litellm_lite_claude_apikeyhelper_conflict

fix(cli): let the apiKeyHelper supply Claude Code's key under lite claude
This commit is contained in:
Mateo Wang 2026-09-09 19:33:59 -07:00 • committed by GitHub
commit c005431cad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 505 additions and 81 deletions

View file

@ -4,6 +4,7 @@ import subprocess
import sys
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path
from types import MappingProxyType
from typing import Final, TypeAlias
@ -12,6 +13,7 @@ import requests
from pydantic import BaseModel, TypeAdapter, ValidationError
from .auth import CliContextObj, context_secret_vault, get_stored_api_key, login
from .claude_settings import claude_settings_path, lite_api_key_helper_configured
from .cmd_quoting import quote_for_cmd
from .pi import (
LITELLM_PROXY_API_KEY_ENV,
@ -84,6 +86,8 @@ def build_agent_env(
base_url: str,
api_key: str,
profiles: frozenset[str],
*,
export_anthropic_token: bool = True,
) -> dict[str, str]:
"""Return a copy of base_env wired to route the agent through the proxy.
@ -98,12 +102,19 @@ def build_agent_env(
proxy's /v1/models; likewise left alone when already set.
pi ignores both base URL variables and instead resolves $LITELLM_PROXY_API_KEY
from its synced models.json provider entry.
With export_anthropic_token=False the bearer is left out (and any inherited
one dropped) so Claude Code asks its configured apiKeyHelper instead; Claude
Code prefers ANTHROPIC_AUTH_TOKEN over the helper and warns when both are set.
"""
env: Final = dict(base_env)
root: Final = base_url.rstrip("/")
if PROFILE_ANTHROPIC in profiles:
env[ANTHROPIC_BASE_URL_ENV] = root
env[ANTHROPIC_AUTH_TOKEN_ENV] = api_key
if export_anthropic_token:
env[ANTHROPIC_AUTH_TOKEN_ENV] = api_key
else:
env.pop(ANTHROPIC_AUTH_TOKEN_ENV, None)
env.pop(ANTHROPIC_API_KEY_ENV, None)
if ENABLE_TOOL_SEARCH_ENV not in env:
env[ENABLE_TOOL_SEARCH_ENV] = ENABLE_TOOL_SEARCH_VALUE
@ -463,6 +474,7 @@ def run_agent(
launcher: Callable[[str, Sequence[str], Mapping[str, str]], None] = _hand_off,
reattach_terminal: Callable[[], None] | None = None,
preparers: Mapping[str, _Preparer] = MappingProxyType(_PREPARERS),
export_anthropic_token: bool = True,
) -> None:
"""Validate, wire the environment, and hand off to the agent.
@ -494,7 +506,9 @@ def run_agent(
env: Final = MappingProxyType(
{
**build_agent_env(env_before_sync, base_url, api_key, profiles),
**build_agent_env(
env_before_sync, base_url, api_key, profiles, export_anthropic_token=export_anthropic_token
),
**(_NO_EXTRA_ENV if isinstance(synced, ModelSyncSkipped) else synced),
}
)
@ -532,14 +546,26 @@ def resolve_api_key(ctx: click.Context) -> str:
_SKIP_VERIFY_HELP: Final = "Skip the pre-launch key check against the proxy."
def _helper_supplies_token(
ctx_obj: CliContextObj, base_url: str, profiles: frozenset[str], settings_path: Path
) -> bool:
if PROFILE_ANTHROPIC not in profiles or not ctx_obj.get("api_key_from_token_file"):
return False
return lite_api_key_helper_configured(base_url, settings_path)
def _launch(ctx: click.Context, binary: str, args: Sequence[str], *, skip_verify: bool) -> None:
ctx_obj: Final[CliContextObj] = ctx.obj
base_url: Final = ctx_obj["base_url"]
started_interactive: Final = _is_interactive()
api_key: Final = resolve_api_key(ctx)
display_name, _ = agent_profile(binary)
display_name, profiles = agent_profile(binary)
settings_path: Final = claude_settings_path(os.environ)
helper_supplies_token: Final = _helper_supplies_token(ctx_obj, base_url, profiles, settings_path)
click.echo(f"litellm: routing {display_name} through proxy at {base_url.rstrip('/')}")
if helper_supplies_token:
click.echo(f"litellm: {display_name} reads its key from the apiKeyHelper in {settings_path}")
try:
run_agent(
@ -548,6 +574,7 @@ def _launch(ctx: click.Context, binary: str, args: Sequence[str], *, skip_verify
[binary, *args],
skip_verify=skip_verify,
reattach_terminal=(_restore_controlling_terminal if started_interactive else None),
export_anthropic_token=not helper_supplies_token,
)
except AgentRunError as e:
raise click.ClickException(str(e))

View file

@ -1,3 +1,4 @@
import os
import sys
import time
import webbrowser
@ -40,16 +41,16 @@ 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,
KeepModel,
claude_settings_path,
configure_claude_settings,
configure_state_path,
refuse_while_owned,
resolve_api_key_helper,
settings_file_owners,
)
from .pkce_login import (
Http,
@ -784,19 +785,20 @@ 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, undoable with `lite unconfigure claude`."""
"""Point Claude Code at base_url by patching the settings.json it reads, undoable with `lite unconfigure claude`."""
settings_path: Final = claude_settings_path(os.environ)
try:
configure_claude_settings(
base_url,
ApiKeyHelper(resolve_api_key_helper(base_url)),
KeepModel(),
CLAUDE_SETTINGS_PATH,
CONFIGURE_STATE_PATH,
SETTINGS_FILE_OWNERS,
settings_path,
configure_state_path(settings_path),
settings_file_owners(settings_path),
)
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(f"\nConfigured Claude Code: {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. "
f"Undo with `lite unconfigure claude`; `lite configure claude --model` sets {STARTING_MODEL_ROLE}."
@ -870,8 +872,9 @@ 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:
settings_path: Final = claude_settings_path(os.environ)
try:
refuse_while_owned(CLAUDE_SETTINGS_PATH, SETTINGS_FILE_OWNERS)
refuse_while_owned(settings_path, settings_file_owners(settings_path))
except ClaudeSettingsError as e:
raise click.ClickException(f"Cannot configure Claude Code, so not logging in: {e}")

View file

@ -62,6 +62,7 @@ _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"
CLAUDE_CONFIG_DIR_ENV: Final = "CLAUDE_CONFIG_DIR"
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"
@ -88,6 +89,33 @@ class ClaudeSettingsError(Exception):
"""Raised for any user-actionable failure while reading or writing Claude Code settings."""
def claude_settings_path(environ: Mapping[str, str]) -> Path:
"""The settings.json Claude Code reads: under CLAUDE_CONFIG_DIR when set, else ~/.claude/settings.json."""
config_dir: Final = environ.get(CLAUDE_CONFIG_DIR_ENV, "")
if not config_dir:
return CLAUDE_SETTINGS_PATH
return Path(config_dir).expanduser() / "settings.json"
def _is_default_settings_file(settings_path: Path) -> bool:
return settings_path.resolve() == CLAUDE_SETTINGS_PATH.resolve()
def settings_file_owners(settings_path: Path) -> tuple[SettingsFileOwner, ...]:
"""The commands whose backups guard settings_path: `lite up` and `lite autoroute up` only ever manage the default file."""
return SETTINGS_FILE_OWNERS if _is_default_settings_file(settings_path) else ()
def configure_state_path(settings_path: Path) -> Path:
"""The receipt describing settings_path: the default file keeps CONFIGURE_STATE_PATH, and any other file
(a CLAUDE_CONFIG_DIR) gets its own beside it, keyed by its resolved path, so two settings files never
share one undo record."""
if _is_default_settings_file(settings_path):
return CONFIGURE_STATE_PATH
digest: Final = hashlib.sha256(str(settings_path.resolve()).encode()).hexdigest()
return CONFIGURE_STATE_PATH.parent / CONFIGURE_STATE_PATH.stem / f"{digest}.json"
@dataclass(frozen=True, slots=True)
class StaticToken:
"""A long-lived virtual key, written into env.ANTHROPIC_AUTH_TOKEN."""
@ -325,6 +353,19 @@ 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 lite_api_key_helper_configured(base_url: str, settings_path: Path) -> bool:
"""Whether settings_path already carries the apiKeyHelper `lite login --config-claude` writes for base_url.
Only an exact match counts: a helper for another proxy, a hand-written one, or
settings that cannot be read leave the caller on the env-token path.
"""
try:
configured_helper: Final = load_json_or_empty(settings_path).get(API_KEY_HELPER_KEY)
return configured_helper == resolve_api_key_helper(base_url.rstrip("/"))
except ClaudeSettingsError:
return False
def _owned(container: Mapping[str, JsonValue], key: str) -> OwnedValue:
return OwnedValue(present=key in container, value=container.get(key))
@ -543,6 +584,7 @@ __all__ = (
"API_KEY_HELPER_KEY",
"AUTOROUTE_BACKUP_PATH",
"BACKUP_PATH",
"CLAUDE_CONFIG_DIR_ENV",
"CLAUDE_SETTINGS_PATH",
"CONFIGURE_STATE_PATH",
"ENABLE_GATEWAY_MODEL_DISCOVERY_KEY",
@ -569,11 +611,15 @@ __all__ = (
"UnconfigureOutcome",
"UnpinModel",
"WithheldCredential",
"claude_settings_path",
"configure_claude_settings",
"configure_state_path",
"lite_api_key_helper_configured",
"load_json_or_empty",
"merge_claude_settings",
"read_configure_receipt",
"refuse_while_owned",
"resolve_api_key_helper",
"settings_file_owners",
"unconfigure_claude_settings",
)

View file

@ -1,5 +1,6 @@
"""`lite configure claude` and `lite unconfigure claude`: persistent Claude Code wiring, undoable."""
import os
import re
import sys
from collections.abc import Callable, Sequence
@ -12,9 +13,6 @@ 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,
@ -24,9 +22,12 @@ from .claude_settings import (
StaticToken,
UnconfigureOutcome,
UnpinModel,
claude_settings_path,
configure_claude_settings,
configure_state_path,
refuse_while_owned,
resolve_api_key_helper,
settings_file_owners,
unconfigure_claude_settings,
)
from .pi import ListingFailure, PiSyncError, fetch_model_ids
@ -67,8 +68,9 @@ def resolve_credential(ctx: click.Context, api_key: str | None) -> tuple[ClaudeC
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."""
settings_path: Final = claude_settings_path(os.environ)
try:
refuse_while_owned(CLAUDE_SETTINGS_PATH, SETTINGS_FILE_OWNERS)
refuse_while_owned(settings_path, settings_file_owners(settings_path))
credential, key = resolve_credential(ctx, api_key)
except ClaudeSettingsError as e:
raise click.ClickException(str(e))
@ -106,14 +108,20 @@ def _apply_claude(ctx: click.Context, credential: ClaudeCredential, listed: Sequ
raise click.ClickException(
f"{model!r} is not served by {base_url} for this key. /v1/models lists: {shown}{more}."
)
settings_path: Final = claude_settings_path(os.environ)
try:
configure_claude_settings(
base_url, credential, _model_choice(model), CLAUDE_SETTINGS_PATH, CONFIGURE_STATE_PATH, SETTINGS_FILE_OWNERS
base_url,
credential,
_model_choice(model),
settings_path,
configure_state_path(settings_path),
settings_file_owners(settings_path),
)
except ClaudeSettingsError as e:
raise click.ClickException(str(e))
in_picker: Final = sum(1 for listed_model in listed if _CLAUDE_CODE_PICKER_FILTER.search(listed_model))
click.echo(f"Configured Claude Code: {CLAUDE_SETTINGS_PATH} now routes through {base_url}.")
click.echo(f"Configured Claude Code: {settings_path} now routes through {base_url}.")
click.echo(
"Credential: your virtual key, stored in the file as ANTHROPIC_AUTH_TOKEN."
if isinstance(credential, StaticToken)
@ -130,9 +138,9 @@ def _apply_claude(ctx: click.Context, credential: ClaudeCredential, listed: Sequ
"'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():
if isinstance(credential, StaticToken) and 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 "
f"Note: {settings_path} is a symlink to {settings_path.resolve()}, so your key now lives in "
"that file; keep it out of version control.",
err=True,
)
@ -221,11 +229,13 @@ def unconfigure_claude() -> None:
Also undoes `lite login --config-claude`. Only keys still holding what configure wrote are
put back; anything you changed since is left as it is and named in the output.
"""
settings_path: Final = claude_settings_path(os.environ)
state_path: Final = configure_state_path(settings_path)
try:
outcome: Final = unconfigure_claude_settings(CLAUDE_SETTINGS_PATH, CONFIGURE_STATE_PATH, SETTINGS_FILE_OWNERS)
outcome: Final = unconfigure_claude_settings(settings_path, state_path, settings_file_owners(settings_path))
except ClaudeSettingsError as e:
raise click.ClickException(str(e))
_report_unconfigure(CLAUDE_SETTINGS_PATH, CONFIGURE_STATE_PATH, outcome)
_report_unconfigure(settings_path, state_path, outcome)
def _report_unconfigure(settings_path: Path, state_path: Path, outcome: UnconfigureOutcome) -> None:

View file

@ -0,0 +1,32 @@
import os
from collections.abc import Iterator
from pathlib import Path
from typing import Final
import pytest
REAL_CLAUDE_SETTINGS: Final = Path(os.path.expanduser("~")) / ".claude" / "settings.json"
def _current_bytes() -> bytes | None:
return REAL_CLAUDE_SETTINGS.read_bytes() if REAL_CLAUDE_SETTINGS.exists() else None
@pytest.fixture(autouse=True)
def isolated_claude_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[Path]:
before: Final = _current_bytes()
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path / ".claude"))
yield tmp_path
after: Final = _current_bytes()
if after == before:
return
if before is None:
REAL_CLAUDE_SETTINGS.unlink()
else:
REAL_CLAUDE_SETTINGS.write_bytes(before)
pytest.fail(
f"this test wrote the developer's real {REAL_CLAUDE_SETTINGS}; the original bytes were restored. "
"Resolve the Claude settings path at call time (never Path.home() at import) and point the test at tmp_path"
)

View file

@ -28,6 +28,7 @@ from litellm.proxy.client.cli.commands.agents import (
)
AGENTS_MODULE = "litellm.proxy.client.cli.commands.agents"
CLAUDE_SETTINGS_MODULE = "litellm.proxy.client.cli.commands.claude_settings"
def _agent_command(name):
@ -163,6 +164,27 @@ class TestBuildAgentEnv:
assert env["PATH"] == "/usr/bin"
assert base == {"PATH": "/usr/bin", "ANTHROPIC_API_KEY": "real-key"}
def test_anthropic_profile_leaves_the_bearer_to_the_api_key_helper(self):
env = build_agent_env(
{"ANTHROPIC_AUTH_TOKEN": "stale-token", "ANTHROPIC_API_KEY": "real-key"},
"http://localhost:4000/",
"sk-key",
frozenset({"anthropic"}),
export_anthropic_token=False,
)
assert "ANTHROPIC_AUTH_TOKEN" not in env
assert "ANTHROPIC_API_KEY" not in env
assert env["ANTHROPIC_BASE_URL"] == "http://localhost:4000"
assert env["ENABLE_TOOL_SEARCH"] == "true"
assert env["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] == "1"
def test_helper_mode_still_exports_the_openai_key(self):
env = build_agent_env(
{}, "http://localhost:4000", "sk-key", frozenset({"anthropic", "openai"}), export_anthropic_token=False
)
assert "ANTHROPIC_AUTH_TOKEN" not in env
assert env["OPENAI_API_KEY"] == "sk-key"
class TestAgentLaunchArgs:
def test_claude_and_opencode_get_no_extra_args(self):
@ -509,6 +531,25 @@ class TestRunAgent:
assert "ANTHROPIC_API_KEY" not in env
assert "OPENAI_BASE_URL" not in env
def test_helper_supplied_token_never_reaches_the_launch_env(self):
calls = {}
verified = []
run_agent(
"http://localhost:4000",
"sk-key",
["claude"],
base_env={"PATH": "/usr/bin", "ANTHROPIC_AUTH_TOKEN": "stale-token"},
which=lambda name: "/usr/local/bin/claude",
verify=lambda base_url, api_key: verified.append(api_key),
launcher=lambda p, a, e: calls.update(env=dict(e)),
export_anthropic_token=False,
)
assert verified == ["sk-key"]
assert "ANTHROPIC_AUTH_TOKEN" not in calls["env"]
assert calls["env"]["ANTHROPIC_BASE_URL"] == "http://localhost:4000"
def test_codex_gets_openai_env(self):
calls = {}
run_agent(
@ -1052,6 +1093,101 @@ class TestAgentCommands:
in result.output
)
def _invoke_claude_with_settings(self, tmp_path, settings, obj, *, default_settings=None):
config_dir = tmp_path / "claude-config"
config_dir.mkdir()
if settings is not None:
(config_dir / "settings.json").write_text(json.dumps(settings))
default_path = tmp_path / "home-claude" / "settings.json"
default_path.parent.mkdir()
if default_settings is not None:
default_path.write_text(json.dumps(default_settings))
captured = {}
with (
patch(f"{CLAUDE_SETTINGS_MODULE}.CLAUDE_SETTINGS_PATH", default_path),
patch(f"{CLAUDE_SETTINGS_MODULE}.shutil.which", return_value="/usr/local/bin/lite"),
patch(f"{AGENTS_MODULE}.run_agent", side_effect=lambda b, k, c, **kw: captured.update(kw)),
):
result = self.runner.invoke(
_agent_command("claude"), [], obj=obj, env={"CLAUDE_CONFIG_DIR": str(config_dir)}
)
assert result.exit_code == 0, result.output
return captured, result.output
def test_helper_is_read_from_the_config_dir_claude_code_uses(self, tmp_path):
captured, output = self._invoke_claude_with_settings(
tmp_path,
{"apiKeyHelper": "/usr/local/bin/lite --base-url http://localhost:4000 auth print-token"},
{"base_url": "http://localhost:4000", "api_key": "sk-key", "api_key_from_token_file": True},
)
assert captured["export_anthropic_token"] is False
assert str(tmp_path / "claude-config" / "settings.json") in output
def test_helper_only_in_the_default_file_keeps_the_env_token_when_config_dir_points_elsewhere(self, tmp_path):
captured, output = self._invoke_claude_with_settings(
tmp_path,
None,
{"base_url": "http://localhost:4000", "api_key": "sk-key", "api_key_from_token_file": True},
default_settings={"apiKeyHelper": "/usr/local/bin/lite --base-url http://localhost:4000 auth print-token"},
)
assert captured["export_anthropic_token"] is True
assert "apiKeyHelper" not in output
def test_stored_login_with_a_matching_helper_leaves_the_token_to_the_helper(self, tmp_path):
captured, output = self._invoke_claude_with_settings(
tmp_path,
{"apiKeyHelper": "/usr/local/bin/lite --base-url http://localhost:4000 auth print-token"},
{"base_url": "http://localhost:4000", "api_key": "sk-key", "api_key_from_token_file": True},
)
assert captured["export_anthropic_token"] is False
assert "reads its key from the apiKeyHelper" in output
def test_explicit_key_is_exported_even_when_a_helper_matches(self, tmp_path):
captured, output = self._invoke_claude_with_settings(
tmp_path,
{"apiKeyHelper": "/usr/local/bin/lite --base-url http://localhost:4000 auth print-token"},
{"base_url": "http://localhost:4000", "api_key": "sk-key", "api_key_from_token_file": False},
)
assert captured["export_anthropic_token"] is True
assert "apiKeyHelper" not in output
def test_helper_for_another_proxy_keeps_the_env_token(self, tmp_path):
captured, _ = self._invoke_claude_with_settings(
tmp_path,
{"apiKeyHelper": "/usr/local/bin/lite --base-url https://other.example.com auth print-token"},
{"base_url": "http://localhost:4000", "api_key": "sk-key", "api_key_from_token_file": True},
)
assert captured["export_anthropic_token"] is True
def test_no_claude_settings_keeps_the_env_token(self, tmp_path):
captured, _ = self._invoke_claude_with_settings(
tmp_path,
None,
{"base_url": "http://localhost:4000", "api_key": "sk-key", "api_key_from_token_file": True},
)
assert captured["export_anthropic_token"] is True
def test_codex_never_consults_claude_settings(self):
captured = {}
with (
patch(f"{AGENTS_MODULE}.lite_api_key_helper_configured", side_effect=AssertionError("consulted")),
patch(f"{AGENTS_MODULE}.run_agent", side_effect=lambda b, k, c, **kw: captured.update(kw)),
):
result = self.runner.invoke(
_agent_command("codex"),
[],
obj={"base_url": "http://localhost:4000", "api_key": "sk-key", "api_key_from_token_file": True},
)
assert result.exit_code == 0, result.output
assert captured["export_anthropic_token"] is True
def test_codex_shows_friendly_name(self):
captured = {}
with patch(

View file

@ -6,7 +6,6 @@ from pathlib import Path
from unittest.mock import Mock, patch
import pytest
from click.testing import CliRunner
@ -27,7 +26,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 import claude_settings as claude_settings_module
from litellm.proxy.client.cli.commands.claude_settings import SettingsFileOwner
@ -85,7 +84,7 @@ class TestPollingErrorSurfacing:
}
with patch("requests.get", return_value=mock_response) as mock_get, patch("time.sleep"):
with pytest.raises(ValueError, match='Your litellm CLI is out of date and uses a login flow') as exc_info:
with pytest.raises(ValueError, match="Your litellm CLI is out of date and uses a login flow") as exc_info:
_poll_for_ready_data("http://test/sso/cli/poll/sk-legacy")
assert mock_get.call_count == 1
@ -152,7 +151,7 @@ class TestStartCliSsoFlowErrors:
mock_response.status_code = 404
with patch("requests.post", return_value=mock_response):
with pytest.raises(ValueError, match='Either --base-url is wrong, or the proxy is older than') as exc_info:
with pytest.raises(ValueError, match="Either --base-url is wrong, or the proxy is older than") as exc_info:
_start_cli_sso_flow("https://old-proxy.example.com")
message = str(exc_info.value)
@ -168,7 +167,7 @@ class TestStartCliSsoFlowErrors:
mock_response.json.return_value = {"detail": "Too many CLI login attempts. Try again later."}
with patch("requests.post", return_value=mock_response):
with pytest.raises(ValueError, match='Too many CLI login attempts\\. Try again later\\.') as exc_info:
with pytest.raises(ValueError, match="Too many CLI login attempts\\. Try again later\\.") as exc_info:
_start_cli_sso_flow("https://test.example.com")
assert "HTTP 429" in str(exc_info.value)
@ -184,7 +183,7 @@ class TestStartCliSsoFlowErrors:
mock_response.text = "<html>Sign in to corporate VPN</html>"
with patch("requests.post", return_value=mock_response):
with pytest.raises(ValueError, match='A proxy, load balancer, or auth gateway in front of') as exc_info:
with pytest.raises(ValueError, match="A proxy, load balancer, or auth gateway in front of") as exc_info:
_start_cli_sso_flow("https://test.example.com")
message = str(exc_info.value)
@ -198,7 +197,7 @@ class TestStartCliSsoFlowErrors:
from litellm.proxy.client.cli.commands.auth import _start_cli_sso_flow
with patch("requests.post", side_effect=requests.ConnectionError("Connection refused")):
with pytest.raises(ValueError, match='Connection refused\\. Check that the proxy is running') as exc_info:
with pytest.raises(ValueError, match="Connection refused\\. Check that the proxy is running") as exc_info:
_start_cli_sso_flow("https://unreachable.example.com")
message = str(exc_info.value)
@ -585,13 +584,9 @@ class TestLogoutCommand:
assert "could not be checked" in result.output
assert DISABLE_KEYRING_ENV_VAR in result.output
def test_logout_warns_when_the_keychain_refuses_to_release_the_entry(
self, isolated_home, secret_vault_factory
):
def test_logout_warns_when_the_keychain_refuses_to_release_the_entry(self, isolated_home, secret_vault_factory):
"""A locked keychain leaves a live credential behind that the user believes is gone."""
vault = secret_vault_factory(
blob=_secret_blob("https://test.example.com", "sk-stored"), erasable=False
)
vault = secret_vault_factory(blob=_secret_blob("https://test.example.com", "sk-stored"), erasable=False)
_write_token_file(isolated_home, key=None)
result = self.runner.invoke(logout, obj={"secret_vault": vault})
@ -1211,9 +1206,7 @@ class TestKeychainBackedCommands:
assert str(token_file) in result.output
assert json.loads(token_file.read_text())["key"] == "sk-minted"
def test_login_points_a_user_missing_the_keyring_package_at_the_install(
self, isolated_home, secret_vault_factory
):
def test_login_points_a_user_missing_the_keyring_package_at_the_install(self, isolated_home, secret_vault_factory):
"""`lite` ships with every install, the keyring package only with the cli extra. Telling
that user their machine has no keychain sends them looking for a problem they do not have."""
result = self._login(secret_vault_factory(available=False, failure=KeyringNotInstalled()))
@ -1224,9 +1217,7 @@ class TestKeychainBackedCommands:
assert "No OS keychain available" not in result.output
assert json.loads(token_file.read_text())["key"] == "sk-minted"
def test_login_keeps_the_credential_when_the_backend_keeps_nothing(
self, isolated_home, secret_vault_factory
):
def test_login_keeps_the_credential_when_the_backend_keeps_nothing(self, isolated_home, secret_vault_factory):
"""A backend that accepts writes and stores nothing must not be reported as keychain
storage, because the file is then told to drop the only remaining copy."""
result = self._login(secret_vault_factory(discards=True))
@ -1237,9 +1228,7 @@ class TestKeychainBackedCommands:
assert "keyring --enable" in result.output
assert json.loads(token_file.read_text())["key"] == "sk-minted"
def test_login_names_the_kill_switch_instead_of_blaming_the_machine(
self, isolated_home, secret_vault_factory
):
def test_login_names_the_kill_switch_instead_of_blaming_the_machine(self, isolated_home, secret_vault_factory):
result = self._login(secret_vault_factory(available=False, failure=KeyringDisabled()))
assert result.exit_code == 0
@ -1298,9 +1287,7 @@ class TestKeychainBackedCommands:
assert "could not be read" in result.output
assert "lite login" in result.output
def test_whoami_does_not_call_a_credential_it_cannot_read_authenticated(
self, isolated_home, secret_vault_factory
):
def test_whoami_does_not_call_a_credential_it_cannot_read_authenticated(self, isolated_home, secret_vault_factory):
"""A login whose secret is stuck in an unreachable keychain authenticates nothing. Leading
with "Authenticated" and a token age reads as a working session, and sends the user looking
for the problem somewhere other than the keychain the notice underneath names."""
@ -1317,9 +1304,7 @@ class TestKeychainBackedCommands:
assert "the credential cannot be read" in result.output
assert "could not be read" in result.output
def test_whoami_names_the_kill_switch_rather_than_a_missing_package(
self, isolated_home, secret_vault_factory
):
def test_whoami_names_the_kill_switch_rather_than_a_missing_package(self, isolated_home, secret_vault_factory):
"""Every unreachable keychain used to be described as a locked one needing the keyring
package installed. Someone who set the kill switch has the package and an unlocked keychain,
so that advice sends them to fix two things that were never wrong."""
@ -1331,9 +1316,7 @@ class TestKeychainBackedCommands:
assert DISABLE_KEYRING_ENV_VAR in result.output
assert "pip install" not in result.output
def test_print_token_points_an_install_without_keyring_at_the_package(
self, isolated_home, secret_vault_factory
):
def test_print_token_points_an_install_without_keyring_at_the_package(self, isolated_home, secret_vault_factory):
_write_token_file(isolated_home, key=None)
vault = secret_vault_factory(available=False, failure=KeyringNotInstalled())
obj = {"base_url": "https://test.example.com", "secret_vault": vault}
@ -1399,11 +1382,22 @@ class TestLoginConfigClaude:
def setup_method(self):
self.runner = CliRunner()
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")
def _isolate_default_settings(self, tmp_path, monkeypatch):
"""The default file, its `lite up` backup and its configure receipt all live under tmp_path."""
backup_path = tmp_path / "claude_settings_backup.json"
monkeypatch.setattr(
claude_settings_module, "SETTINGS_FILE_OWNERS", (SettingsFileOwner(backup_path, "lite up", "lite down"),)
)
monkeypatch.setattr(
claude_settings_module, "CLAUDE_SETTINGS_PATH", tmp_path / "default-home" / ".claude" / "settings.json"
)
monkeypatch.setattr(claude_settings_module, "CONFIGURE_STATE_PATH", tmp_path / "claude_configure_state.json")
return backup_path
def _run_login(self, tmp_path, monkeypatch, args, base_url="https://test.example.com", *, config_dir_env=None):
settings_path = tmp_path / "claude" / "settings.json"
backup_path = self._isolate_default_settings(tmp_path, monkeypatch)
env = {"CLAUDE_CONFIG_DIR": str(settings_path.parent)} if config_dir_env is None else config_dir_env
poll_response = Mock()
poll_response.status_code = 200
poll_response.json.return_value = {
@ -1419,16 +1413,12 @@ 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.SETTINGS_FILE_OWNERS",
(SettingsFileOwner(backup_path, "lite up", "lite down"),),
),
patch(
"litellm.proxy.client.cli.commands.claude_settings.shutil.which",
return_value="/usr/local/bin/lite",
),
):
result = self.runner.invoke(login, args, obj={"base_url": base_url})
result = self.runner.invoke(login, args, obj={"base_url": base_url}, env=env)
return result, settings_path, backup_path
def test_default_login_does_not_touch_claude_settings(self, tmp_path, monkeypatch):
@ -1447,7 +1437,7 @@ class TestLoginConfigClaude:
assert written["env"]["ANTHROPIC_BASE_URL"] == "https://test.example.com"
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 f"Configured Claude Code: {settings_path} now routes through https://test.example.com." 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
@ -1463,21 +1453,55 @@ class TestLoginConfigClaude:
assert written["theme"] == "dark"
assert written["env"]["KEEP"] == "me"
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"),)
)
def _run_login_refused_before_the_sso_flow(self, tmp_path, monkeypatch, config_dir):
self._isolate_default_settings(tmp_path, monkeypatch).write_text("{}")
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"})
result = self.runner.invoke(
login,
["--config-claude"],
obj={"base_url": "https://test.example.com"},
env={"CLAUDE_CONFIG_DIR": config_dir},
)
assert result.exit_code != 0
assert "not logging in" in result.output and "lite down" in result.output
assert "`lite up` is currently managing" in result.output
assert "Login successful!" not in result.output
post.assert_not_called()
browser.assert_not_called()
assert not (tmp_path / "default-home" / ".claude" / "settings.json").exists()
def test_refuses_before_logging_in_while_lite_up_holds_the_default_settings_file(self, tmp_path, monkeypatch):
self._run_login_refused_before_the_sso_flow(tmp_path, monkeypatch, config_dir="")
def test_refuses_before_logging_in_while_lite_up_holds_the_default_file_reached_through_a_symlink(
self, tmp_path, monkeypatch
):
default_config_dir = tmp_path / "default-home" / ".claude"
default_config_dir.mkdir(parents=True)
alias = tmp_path / "claude-alias"
alias.symlink_to(default_config_dir, target_is_directory=True)
self._run_login_refused_before_the_sso_flow(tmp_path, monkeypatch, config_dir=str(alias))
def test_flag_writes_an_alternate_config_dir_even_while_lite_up_holds_the_default_file(self, tmp_path, monkeypatch):
(tmp_path / "claude_settings_backup.json").write_text("{}")
result, settings_path, _backup_path = self._run_login(tmp_path, monkeypatch, ["--config-claude"])
assert result.exit_code == 0, result.output
written = json.loads(settings_path.read_text())
assert written["apiKeyHelper"] == "/usr/local/bin/lite --base-url https://test.example.com auth print-token"
assert f"Configured Claude Code: {settings_path} now routes through https://test.example.com." in result.output
def test_flag_keeps_a_config_dir_receipt_apart_from_the_default_file_receipt(self, tmp_path, monkeypatch):
result, settings_path, _backup_path = self._run_login(tmp_path, monkeypatch, ["--config-claude"])
assert result.exit_code == 0, result.output
default_receipt = tmp_path / "claude_configure_state.json"
assert not default_receipt.exists()
receipts = list((tmp_path / "claude_configure_state").glob("*.json"))
assert len(receipts) == 1
assert json.loads(receipts[0].read_text())["file_existed"] is False
def test_settings_failure_is_reported_without_claiming_login_failed(self, tmp_path, monkeypatch):
settings_path = tmp_path / "claude" / "settings.json"
@ -1873,7 +1897,10 @@ class TestPkcePrintToken:
assert result.stdout == ""
assert sum(len(session.posts) for session in _FakeSession.instances) == 1
assert result.output.count("Could not renew the key") == 1
assert "Could not renew the key: token request failed with 400: the refresh token was already used" in result.output
assert (
"Could not renew the key: token request failed with 400: the refresh token was already used"
in result.output
)
assert "Key expired. Run 'lite login --pkce' again." in result.output
save.assert_not_called()

View file

@ -3,6 +3,7 @@ import os
import shlex
import stat
import time
from pathlib import Path
from unittest.mock import patch
import pytest
@ -15,6 +16,8 @@ from litellm.proxy.client.cli.commands.claude_settings import (
ANTHROPIC_DEFAULT_MODEL_ENV_KEYS,
AUTOROUTE_BACKUP_PATH,
BACKUP_PATH,
CLAUDE_SETTINGS_PATH,
CONFIGURE_STATE_PATH,
OWNED_ENV_KEYS,
OWNED_TOP_LEVEL_KEYS,
SETTINGS_FILE_OWNERS,
@ -25,7 +28,10 @@ from litellm.proxy.client.cli.commands.claude_settings import (
StartOn,
StaticToken,
UnpinModel,
claude_settings_path,
configure_claude_settings,
configure_state_path,
lite_api_key_helper_configured,
merge_claude_settings,
resolve_api_key_helper,
unconfigure_claude_settings,
@ -390,6 +396,117 @@ class TestDoesNotDestroyUserOwnedStructure:
assert json.loads(settings_path.read_text())["env"] == "not-an-object"
class TestClaudeSettingsPath:
def test_defaults_to_the_home_settings_file(self):
assert claude_settings_path({}) == CLAUDE_SETTINGS_PATH
assert claude_settings_path({"CLAUDE_CONFIG_DIR": ""}) == CLAUDE_SETTINGS_PATH
def test_follows_claude_config_dir_like_claude_code_does(self, tmp_path):
assert claude_settings_path({"CLAUDE_CONFIG_DIR": str(tmp_path)}) == tmp_path / "settings.json"
def test_expands_a_tilde_in_claude_config_dir(self):
assert claude_settings_path({"CLAUDE_CONFIG_DIR": "~/.claude-work"}) == (
Path.home() / ".claude-work" / "settings.json"
)
class TestConfigureStatePath:
"""Each settings file gets its own undo receipt: the default file keeps the long-standing path, and
a CLAUDE_CONFIG_DIR file gets one keyed by its resolved location, so `lite unconfigure claude`
under one config dir never restores the other file's history."""
@pytest.fixture
def default_paths(self, tmp_path):
default_settings = tmp_path / "home" / ".claude" / "settings.json"
default_state = tmp_path / "home" / ".litellm" / "claude_configure_state.json"
with (
patch(f"{CLAUDE_SETTINGS_MODULE}.CLAUDE_SETTINGS_PATH", default_settings),
patch(f"{CLAUDE_SETTINGS_MODULE}.CONFIGURE_STATE_PATH", default_state),
):
yield default_settings, default_state
def test_the_default_file_keeps_the_default_receipt(self, default_paths):
default_settings, default_state = default_paths
assert configure_state_path(default_settings) == default_state
def test_a_symlink_alias_of_the_default_file_shares_its_receipt(self, default_paths):
default_settings, default_state = default_paths
default_settings.parent.mkdir(parents=True)
alias = default_settings.parent.parent / "claude-alias"
alias.symlink_to(default_settings.parent, target_is_directory=True)
assert configure_state_path(alias / "settings.json") == default_state
def test_another_settings_file_gets_a_receipt_of_its_own_beside_the_default_one(self, default_paths, tmp_path):
_default_settings, default_state = default_paths
work_state = configure_state_path(tmp_path / "work" / "settings.json")
play_state = configure_state_path(tmp_path / "play" / "settings.json")
assert work_state != default_state and play_state != default_state
assert work_state != play_state
assert work_state.parent == play_state.parent == default_state.parent / "claude_configure_state"
assert work_state == configure_state_path(tmp_path / "work" / "settings.json")
def test_configure_and_unconfigure_under_a_config_dir_leave_the_default_receipt_alone(
self, default_paths, tmp_path, lite_on_path
):
_default_settings, default_state = default_paths
work_settings = tmp_path / "work" / "settings.json"
work_state = configure_state_path(work_settings)
configure_claude_settings(
"https://proxy.example.com",
ApiKeyHelper(resolve_api_key_helper("https://proxy.example.com")),
KeepModel(),
work_settings,
work_state,
(),
)
assert work_state.exists() and not default_state.exists()
outcome = unconfigure_claude_settings(work_settings, work_state, ())
assert outcome.file_removed and not work_settings.exists()
assert not work_state.exists()
class TestLiteApiKeyHelperConfigured:
def _settings(self, tmp_path, payload):
settings_path = tmp_path / "settings.json"
settings_path.write_text(payload)
return settings_path
def test_recognises_the_helper_lite_login_wrote_for_this_proxy(self, tmp_path, lite_on_path):
settings_path = tmp_path / "settings.json"
_helper_configure("https://proxy.example.com/", settings_path, (), tmp_path / "state.json")
assert lite_api_key_helper_configured("https://proxy.example.com/", settings_path) is True
assert lite_api_key_helper_configured("https://proxy.example.com", settings_path) is True
def test_a_helper_for_another_proxy_does_not_count(self, tmp_path, lite_on_path):
settings_path = tmp_path / "settings.json"
_helper_configure("https://other.example.com", settings_path, (), tmp_path / "state.json")
assert lite_api_key_helper_configured("https://proxy.example.com", settings_path) is False
def test_a_hand_written_helper_does_not_count(self, tmp_path, lite_on_path):
settings_path = self._settings(tmp_path, json.dumps({"apiKeyHelper": "cat ~/.my-proxy-key"}))
assert lite_api_key_helper_configured("https://proxy.example.com", settings_path) is False
def test_missing_or_helperless_settings_do_not_count(self, tmp_path, lite_on_path):
assert lite_api_key_helper_configured("https://proxy.example.com", tmp_path / "absent.json") is False
helperless = json.dumps({"env": {"ANTHROPIC_BASE_URL": "https://proxy.example.com"}})
settings_path = self._settings(tmp_path, helperless)
assert lite_api_key_helper_configured("https://proxy.example.com", settings_path) is False
def test_unreadable_settings_fall_back_to_false(self, tmp_path, lite_on_path):
settings_path = self._settings(tmp_path, "{not json")
assert lite_api_key_helper_configured("https://proxy.example.com", settings_path) is False
def test_lite_missing_from_path_falls_back_to_false(self, tmp_path):
helper = "/usr/local/bin/lite --base-url https://proxy.example.com auth print-token"
settings_path = self._settings(tmp_path, json.dumps({"apiKeyHelper": helper}))
with patch(f"{CLAUDE_SETTINGS_MODULE}.shutil.which", return_value=None):
assert lite_api_key_helper_configured("https://proxy.example.com", settings_path) is False
class TestMergeClaudeSettings:
"""One merge for every way Claude Code gets wired: `lite up`, `lite login --config-claude`,
`lite configure claude` and `lite autoroute up`."""

View file

@ -9,6 +9,7 @@ import responses
from click.testing import CliRunner
from litellm.proxy.client.cli import cli
from litellm.proxy.client.cli.commands import claude_settings as claude_settings_module
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
@ -29,10 +30,12 @@ def _mock_models():
@pytest.fixture
def paths(monkeypatch, tmp_path):
"""The default settings file, reached the way Claude Code reaches it: CLAUDE_CONFIG_DIR names its directory."""
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)
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(settings_path.parent))
monkeypatch.setattr(claude_settings_module, "CLAUDE_SETTINGS_PATH", settings_path)
monkeypatch.setattr(claude_settings_module, "CONFIGURE_STATE_PATH", state_path)
return settings_path, state_path
@ -58,7 +61,9 @@ 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"),))
monkeypatch.setattr(
claude_settings_module, "SETTINGS_FILE_OWNERS", (SettingsFileOwner(backup, "lite up", "lite down"),)
)
return backup
@ -325,6 +330,30 @@ class TestUnconfigureClaude:
result = runner.invoke(cli, ["unconfigure", "claude"])
assert result.exit_code != 0 and "lite down" in result.output
@responses.activate
def test_a_config_dir_is_configured_and_undone_apart_from_the_default_file(
self, runner, paths, monkeypatch, tmp_path, lite_up_backup
):
_mock_models()
default_settings, default_state = paths
work_dir = tmp_path / "claude-work"
work_dir.mkdir()
original = {"theme": "dark"}
(work_dir / "settings.json").write_text(json.dumps(original))
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(work_dir))
configured = _configure(runner, "--api-key", VALID_KEY, "--model", "claude-auto")
assert configured.exit_code == 0, configured.output
assert f"Configured Claude Code: {work_dir / 'settings.json'}" in configured.output
assert json.loads((work_dir / "settings.json").read_text())["env"]["ANTHROPIC_AUTH_TOKEN"] == VALID_KEY
assert not default_settings.exists() and not default_state.exists()
undone = runner.invoke(cli, ["unconfigure", "claude"])
assert undone.exit_code == 0, undone.output
assert json.loads((work_dir / "settings.json").read_text()) == original
assert not default_settings.exists() and not default_state.exists()
assert runner.invoke(cli, ["unconfigure", "claude"]).exit_code != 0, "the receipt is gone with the undo"
def test_without_a_receipt_it_fails_loudly(self, runner, paths):
result = runner.invoke(cli, ["unconfigure", "claude"])
assert result.exit_code != 0

View file

@ -620,10 +620,7 @@ class TestUpCanInvokeTheRealLoginCommand:
ctx.obj = {"base_url": "http://127.0.0.1:9"}
ctx.invoke(real_login, pkce=False)
with (
patch(f"{AUTH_MODULE}.CLAUDE_SETTINGS_PATH", settings_path),
patch(f"{AUTH_MODULE}._start_cli_sso_flow", side_effect=RuntimeError("stop")),
):
CliRunner().invoke(driver, [], standalone_mode=False)
with patch(f"{AUTH_MODULE}._start_cli_sso_flow", side_effect=RuntimeError("stop")):
CliRunner().invoke(driver, [], standalone_mode=False, env={"CLAUDE_CONFIG_DIR": str(tmp_path)})
assert not settings_path.exists()