mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
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:
commit
c005431cad
10 changed files with 505 additions and 81 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,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))
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
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"
|
||||
)
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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`."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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