mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
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:
parent
7e137958e9
commit
b554d79914
8 changed files with 115 additions and 25 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
32
tests/test_litellm/proxy/client/cli/conftest.py
Normal file
32
tests/test_litellm/proxy/client/cli/conftest.py
Normal 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"
|
||||
)
|
||||
|
|
@ -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)),
|
||||
):
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue