fix(cli): resolve Claude Code's settings file through CLAUDE_CONFIG_DIR

Claude Code reads settings.json from CLAUDE_CONFIG_DIR when it is set,
while lite login --config-claude wrote to ~/.claude/settings.json and
lite claude checked that same file for its apiKeyHelper. With the
override set, lite could drop ANTHROPIC_AUTH_TOKEN because the helper
lives in a file Claude Code never reads, leaving it with no key at all.

claude_settings_path(environ) now picks the file the way Claude Code
does, and both commands go through it. The CLI test directory gets an
autouse conftest that isolates HOME, USERPROFILE, and CLAUDE_CONFIG_DIR
per test and fails any test that writes the developer's real
settings.json, restoring it first.
This commit is contained in:
mateo-berri 2026-09-09 18:01:11 -07:00
parent 7e137958e9
commit b554d79914
8 changed files with 115 additions and 25 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,7 +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 .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,
@ -542,10 +543,12 @@ 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]) -> bool:
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, CLAUDE_SETTINGS_PATH)
return lite_api_key_helper_configured(base_url, settings_path)
def _launch(ctx: click.Context, binary: str, args: Sequence[str], *, skip_verify: bool) -> None:
@ -555,10 +558,11 @@ def _launch(ctx: click.Context, binary: str, args: Sequence[str], *, skip_verify
api_key: Final = resolve_api_key(ctx)
display_name, profiles = agent_profile(binary)
helper_supplies_token: Final = _helper_supplies_token(ctx_obj, base_url, profiles)
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 {CLAUDE_SETTINGS_PATH}")
click.echo(f"litellm: {display_name} reads its key from the apiKeyHelper in {settings_path}")
try:
run_agent(

View file

@ -1,3 +1,4 @@
import os
import sys
import time
import webbrowser
@ -40,9 +41,9 @@ from litellm.litellm_core_utils.cli_token_utils import (
)
from .claude_settings import (
CLAUDE_SETTINGS_PATH,
SETTINGS_FILE_OWNERS,
ClaudeSettingsError,
claude_settings_path,
write_claude_settings,
)
from .pkce_login import (
@ -778,12 +779,13 @@ 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 the settings.json it reads."""
settings_path: Final = claude_settings_path(os.environ)
try:
write_claude_settings(base_url, CLAUDE_SETTINGS_PATH, SETTINGS_FILE_OWNERS)
write_claude_settings(base_url, settings_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(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.")

View file

@ -30,6 +30,7 @@ ENABLE_GATEWAY_MODEL_DISCOVERY_KEY: Final = "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DI
ENABLE_GATEWAY_MODEL_DISCOVERY_VALUE: Final = "1"
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"
@ -55,6 +56,14 @@ 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 load_json_or_empty(path: Path) -> dict[str, JsonValue]:
try:
content: Final = path.read_bytes() if path.exists() else b""
@ -173,6 +182,7 @@ __all__ = (
"API_KEY_HELPER_KEY",
"AUTOROUTE_BACKUP_PATH",
"BACKUP_PATH",
"CLAUDE_CONFIG_DIR_ENV",
"CLAUDE_SETTINGS_PATH",
"ENABLE_GATEWAY_MODEL_DISCOVERY_KEY",
"ENABLE_GATEWAY_MODEL_DISCOVERY_VALUE",
@ -182,6 +192,7 @@ __all__ = (
"SETTINGS_FILE_OWNERS",
"ClaudeSettingsError",
"SettingsFileOwner",
"claude_settings_path",
"lite_api_key_helper_configured",
"load_json_or_empty",
"merge_claude_settings",

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

@ -1093,20 +1093,48 @@ class TestAgentCommands:
in result.output
)
def _invoke_claude_with_settings(self, tmp_path, settings, obj):
settings_path = tmp_path / "settings.json"
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:
settings_path.write_text(json.dumps(settings))
(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"{AGENTS_MODULE}.CLAUDE_SETTINGS_PATH", settings_path),
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)
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,
@ -1145,11 +1173,9 @@ class TestAgentCommands:
assert captured["export_anthropic_token"] is True
def test_codex_never_consults_claude_settings(self, tmp_path):
settings_path = tmp_path / "settings.json"
def test_codex_never_consults_claude_settings(self):
captured = {}
with (
patch(f"{AGENTS_MODULE}.CLAUDE_SETTINGS_PATH", settings_path),
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)),
):

View file

@ -1416,7 +1416,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"),),
@ -1426,7 +1425,9 @@ class TestLoginConfigClaude:
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={"CLAUDE_CONFIG_DIR": str(settings_path.parent)}
)
return result, settings_path, backup_path
def test_default_login_does_not_touch_claude_settings(self, tmp_path):
@ -1445,7 +1446,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
def test_flag_preserves_unrelated_settings_on_an_existing_file(self, tmp_path):
settings_path = tmp_path / "claude" / "settings.json"

View file

@ -2,6 +2,7 @@ import json
import shlex
import stat
import time
from pathlib import Path
from unittest.mock import patch
import pytest
@ -12,9 +13,11 @@ from litellm.proxy.client.cli import cli
from litellm.proxy.client.cli.commands.claude_settings import (
AUTOROUTE_BACKUP_PATH,
BACKUP_PATH,
CLAUDE_SETTINGS_PATH,
SETTINGS_FILE_OWNERS,
ClaudeSettingsError,
SettingsFileOwner,
claude_settings_path,
lite_api_key_helper_configured,
resolve_api_key_helper,
write_claude_settings,
@ -360,6 +363,20 @@ 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 TestLiteApiKeyHelperConfigured:
def _settings(self, tmp_path, payload):
settings_path = tmp_path / "settings.json"

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()