import json import os import pathlib import shlex import stat import sys import time from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeoutError from pathlib import Path from threading import Event from typing import Final from unittest.mock import patch import pytest from click.testing import CliRunner from litellm.litellm_core_utils.private_json import commit_staged_json, write_private_bytes 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, ClaudeSettingsError, KeepModel, SettingsFileOwner, StartOn, StaticToken, UnpinModel, claude_settings_path, configure_claude_settings, install_statusline_script, configure_state_path, merge_claude_settings, statusline_command, unconfigure_claude_settings, with_status_line, ) def _owners(*backup_paths): """Stand-in owners for the real `lite up` / `lite autoroute start` registry.""" return tuple(SettingsFileOwner(path, "lite up", "lite down") for path in backup_paths) CLAUDE_SETTINGS_MODULE = "litellm.proxy.client.cli.commands.claude_settings" AUTH_MODULE = "litellm.proxy.client.cli.commands.auth" @pytest.fixture def paths(tmp_path): return tmp_path / "claude" / "settings.json", tmp_path / "backup.json" def _static_configure(base_url, settings_path, owners, state_path=None): """`lite configure claude --api-key`'s shape: a virtual key as a static token, no pinned model.""" state = state_path if state_path is not None else settings_path.parent.parent / "state.json" configure_claude_settings(base_url.rstrip("/"), StaticToken("sk-virtual-key"), KeepModel(), settings_path, state, owners) class TestConfigureClaudeSettings: def test_creates_the_file_and_its_parent_when_missing(self, paths): settings_path, backup_path = paths assert not settings_path.parent.exists() _static_configure("https://proxy.example.com/", settings_path, _owners(backup_path)) written = json.loads(settings_path.read_text()) assert written["env"]["ANTHROPIC_BASE_URL"] == "https://proxy.example.com" assert written["env"]["ANTHROPIC_AUTH_TOKEN"] == "sk-virtual-key" assert written["env"]["ENABLE_TOOL_SEARCH"] == "true" assert written["env"]["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] == "1" assert "apiKeyHelper" not in written assert "model" not in written and "ANTHROPIC_MODEL" not in written["env"] def test_updates_an_existing_file_preserving_unrelated_settings(self, paths): settings_path, backup_path = paths settings_path.parent.mkdir(parents=True) settings_path.write_text( json.dumps( { "theme": "dark", "permissions": {"allow": ["Bash"]}, "env": {"SOME_OTHER_VAR": "keep-me", "ANTHROPIC_BASE_URL": "https://old.example.com"}, } ) ) _static_configure("https://proxy.example.com", settings_path, _owners(backup_path)) written = json.loads(settings_path.read_text()) assert written["theme"] == "dark" assert written["permissions"] == {"allow": ["Bash"]} assert written["env"]["SOME_OTHER_VAR"] == "keep-me" assert written["env"]["ANTHROPIC_BASE_URL"] == "https://proxy.example.com" def test_a_helper_left_by_an_older_lite_is_stripped_so_only_the_static_token_is_sent(self, paths): # Older `lite` versions wrote `apiKeyHelper: lite auth print-token`; Claude Code would keep spawning # `lite` (and its keychain probe) on every credential refresh, so configure takes the slot over. settings_path, backup_path = paths settings_path.parent.mkdir(parents=True) settings_path.write_text( json.dumps({"apiKeyHelper": "/usr/local/bin/lite auth print-token", "env": {"ANTHROPIC_API_KEY": "sk-leaked"}}) ) _static_configure("https://proxy.example.com", settings_path, _owners(backup_path)) written = json.loads(settings_path.read_text()) assert "apiKeyHelper" not in written assert "ANTHROPIC_API_KEY" not in written["env"] assert written["env"]["ANTHROPIC_AUTH_TOKEN"] == "sk-virtual-key" def test_written_file_is_owner_only(self, paths): settings_path, backup_path = paths _static_configure("https://proxy.example.com", settings_path, _owners(backup_path)) assert stat.S_IMODE(settings_path.stat().st_mode) == 0o600 def test_refuses_while_lite_up_holds_a_backup(self, paths): settings_path, backup_path = paths backup_path.write_text("{}") with pytest.raises(ClaudeSettingsError, match="lite down"): _static_configure("https://proxy.example.com", settings_path, _owners(backup_path)) assert not settings_path.exists() def test_refuses_on_corrupt_existing_settings_without_touching_the_file(self, paths): settings_path, backup_path = paths settings_path.parent.mkdir(parents=True) settings_path.write_text("not json at all {{{") with pytest.raises(ClaudeSettingsError, match="invalid JSON"): _static_configure("https://proxy.example.com", settings_path, _owners(backup_path)) assert settings_path.read_text() == "not json at all {{{" def test_reports_an_actionable_error_on_a_non_utf8_file(self, paths): # UnicodeDecodeError is a ValueError, not an OSError, so a decode-side catch is easy to miss. settings_path, backup_path = paths settings_path.parent.mkdir(parents=True) settings_path.write_bytes(b'{"theme": "\xff\xfe"}') with pytest.raises(ClaudeSettingsError, match="invalid JSON"): _static_configure("https://proxy.example.com", settings_path, _owners(backup_path)) def test_reports_an_actionable_error_when_the_file_cannot_be_read(self, paths): settings_path, backup_path = paths settings_path.parent.mkdir(parents=True) settings_path.mkdir() with pytest.raises(ClaudeSettingsError, match="Could not read"): _static_configure("https://proxy.example.com", settings_path, _owners(backup_path)) def test_reports_an_actionable_error_when_the_file_cannot_be_written(self, paths): settings_path, backup_path = paths settings_path.parent.mkdir(parents=True) settings_path.parent.chmod(0o500) try: with pytest.raises(ClaudeSettingsError, match="Could not write"): _static_configure("https://proxy.example.com", settings_path, _owners(backup_path)) finally: settings_path.parent.chmod(0o700) assert not settings_path.exists() class TestConflictingOwnersOfTheSettingsFile: """Both `lite up` and `lite autoroute start` restore a backup when they stop. Guarding only one of them leaves the other free to silently revert this write, which is the exact hazard the guard exists to prevent. """ def test_any_owner_holding_a_backup_blocks_the_write(self, tmp_path): settings_path = tmp_path / "claude" / "settings.json" for index, owner in enumerate(SETTINGS_FILE_OWNERS): backup = tmp_path / f"backup-{index}.json" backup.write_text("{}") stand_in = SettingsFileOwner(backup, owner.start_command, owner.stop_command) with pytest.raises(ClaudeSettingsError, match="currently managing"): _static_configure("https://proxy.example.com", settings_path, (stand_in,)) backup.unlink() assert not settings_path.exists() def test_the_error_names_the_owner_that_actually_holds_the_file(self, tmp_path): settings_path = tmp_path / "claude" / "settings.json" backup = tmp_path / "auto.json" backup.write_text("{}") autoroute = SettingsFileOwner(backup, "lite autoroute start", "lite autoroute stop") with pytest.raises(ClaudeSettingsError, match="`lite autoroute start` is currently managing"): _static_configure("https://proxy.example.com", settings_path, (autoroute,)) with pytest.raises(ClaudeSettingsError, match="Run `lite autoroute stop` first"): _static_configure("https://proxy.example.com", settings_path, (autoroute,)) def test_the_registry_matches_the_paths_the_commands_actually_use(self): """A second definition of the autoroute dir must not drift from this one.""" from litellm.proxy.client.cli.commands.autoroute.process import AUTOROUTE_DIR assert AUTOROUTE_BACKUP_PATH == AUTOROUTE_DIR / "claude_settings_backup.json" assert {o.backup_path for o in SETTINGS_FILE_OWNERS} == {BACKUP_PATH, AUTOROUTE_BACKUP_PATH} assert {o.stop_command for o in SETTINGS_FILE_OWNERS} == {"lite down", "lite autoroute stop"} class TestDoesNotDestroyUserOwnedStructure: def test_writes_through_a_symlinked_settings_file(self, tmp_path): """os.replace() swaps the symlink for a regular file, detaching a dotfiles repo. There is no backup here to undo that, so the link must survive and its target must be the thing that gets updated. """ real = tmp_path / "dotfiles" / "settings.json" real.parent.mkdir() real.write_text(json.dumps({"theme": "dark"})) link = tmp_path / "claude" / "settings.json" link.parent.mkdir() link.symlink_to(real) _static_configure("https://proxy.example.com", link, ()) assert link.is_symlink() assert json.loads(real.read_text())["env"]["ANTHROPIC_BASE_URL"] == "https://proxy.example.com" assert json.loads(real.read_text())["theme"] == "dark" def test_refuses_rather_than_discarding_a_non_object_env(self, paths): """merge coerces a non-dict env to {}; that is silent data loss on a persistent write.""" settings_path, backup_path = paths settings_path.parent.mkdir(parents=True) settings_path.write_text(json.dumps({"theme": "dark", "env": "not-an-object"})) with pytest.raises(ClaudeSettingsError, match="non-object"): _static_configure("https://proxy.example.com", settings_path, _owners(backup_path)) 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 ): _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", StaticToken("sk-virtual-key"), 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 TestMergeClaudeSettings: """One merge for every way Claude Code gets wired: `lite up`, `lite configure claude` and `lite autoroute start`.""" def test_a_static_token_lands_in_env_and_the_helper_slot_is_cleared(self): settings = {"apiKeyHelper": "/usr/local/bin/lite auth print-token", "env": {"ANTHROPIC_API_KEY": "leaked"}} merged = merge_claude_settings(settings, "http://127.0.0.1:4000/", StaticToken("token-abc")) assert merged["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:4000" assert merged["env"]["ANTHROPIC_AUTH_TOKEN"] == "token-abc" assert merged["env"]["ENABLE_TOOL_SEARCH"] == "true" assert merged["env"]["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] == "1" assert "ANTHROPIC_API_KEY" not in merged["env"] assert "apiKeyHelper" not in merged assert "model" not in merged assert not any(key in merged["env"] for key in ANTHROPIC_DEFAULT_MODEL_ENV_KEYS) def test_keeps_existing_switch_values_and_unrelated_keys_without_mutating_the_input(self): settings = {"theme": "dark", "env": {"SOME_OTHER_VAR": "value", "ENABLE_TOOL_SEARCH": "false"}} merged = merge_claude_settings(settings, "http://127.0.0.1:4000", StaticToken("token-abc")) assert merged["theme"] == "dark" assert merged["env"]["SOME_OTHER_VAR"] == "value" assert merged["env"]["ENABLE_TOOL_SEARCH"] == "false" assert settings == {"theme": "dark", "env": {"SOME_OTHER_VAR": "value", "ENABLE_TOOL_SEARCH": "false"}} def test_a_default_model_pins_the_starting_row_and_the_model_a_resumed_session_keeps(self): # `model` is only the row Claude Code starts on: a resumed session re-sends the model its transcript # recorded, which behind a raw-model auto-router is the tier model (403 for a key scoped to the # router). ANTHROPIC_MODEL outranks the transcript on resume, so the pin has to land there too. merged = merge_claude_settings( {}, "http://127.0.0.1:4000", StaticToken("token-abc"), default_model="claude-auto" ) assert merged["model"] == "claude-auto" assert merged["env"]["ANTHROPIC_MODEL"] == "claude-auto" assert not any(key in merged["env"] for key in ANTHROPIC_DEFAULT_MODEL_ENV_KEYS) def test_without_a_default_model_neither_pin_is_written_and_a_users_own_stays(self): settings = {"model": "mine", "env": {"ANTHROPIC_MODEL": "mine-too"}} merged = merge_claude_settings(settings, "http://127.0.0.1:4000", StaticToken("token-abc")) assert merged["model"] == "mine" and merged["env"]["ANTHROPIC_MODEL"] == "mine-too" def test_a_tier_model_forces_every_claude_code_tier_as_autoroute_needs(self): # Router's auto-router registry is keyed by the literal requested model string with no # wildcard resolution, so `lite autoroute start` overrides the env var each tier reads. settings = {"env": {"ANTHROPIC_DEFAULT_SONNET_MODEL": "claude-opus-4-8"}} merged = merge_claude_settings( settings, "http://127.0.0.1:4000", StaticToken("token-abc"), tier_model="autorouter" ) assert {merged["env"][key] for key in ANTHROPIC_DEFAULT_MODEL_ENV_KEYS} == {"autorouter"} assert "model" not in merged def test_touches_exactly_the_declared_owned_keys(self): # The receipt and unconfigure restore exactly OWNED_*_KEYS, so a key the merge writes outside # that table would be written by configure and never undone. settings = { "theme": "dark", "permissions": {"allow": ["Bash"]}, "env": {"KEEP_ME": "1", "ANTHROPIC_API_KEY": "old", "ENABLE_TOOL_SEARCH": "false"}, "apiKeyHelper": "old-helper", "model": "old-model", } for credential in (StaticToken("token-abc"), StaticToken("token-rotated")): merged = merge_claude_settings(settings, "http://127.0.0.1:4000", credential, default_model="claude-auto") changed_top_level = {key for key in set(settings) | set(merged) if settings.get(key) != merged.get(key)} assert changed_top_level - {"env"} <= set(OWNED_TOP_LEVEL_KEYS) changed_env = { key for key in set(settings["env"]) | set(merged["env"]) if settings["env"].get(key) != merged["env"].get(key) } assert changed_env <= set(OWNED_ENV_KEYS) assert merged["permissions"] == {"allow": ["Bash"]} assert merged["env"]["KEEP_ME"] == "1" PROXY = "http://127.0.0.1:4000" ANTHROPIC = "https://api.anthropic.com" RELOGIN = StaticToken("sk-fresh-login") ORIGINAL = { "theme": "dark", "permissions": {"allow": ["Bash"]}, "env": {"KEEP_ME": "1", "ANTHROPIC_API_KEY": "sk-ant-mine", "ANTHROPIC_BASE_URL": ANTHROPIC}, "apiKeyHelper": "/usr/local/bin/lite auth print-token", "model": "claude-opus-5", } def _set(path, value): """A user edit: set (or with `_ABSENT`, remove) the key at a dotted path in the settings file.""" def edit(settings): section, _, key = path.rpartition(".") container = settings.setdefault(section, {}) if section else settings if value is _ABSENT: container.pop(key, None) else: container[key] = value return settings return edit _ABSENT = object() class _Rig: """One settings file plus receipt under tmp_path, driven through the public functions only.""" def __init__(self, tmp_path, initial): self.settings = tmp_path / "claude" / "settings.json" self.state = tmp_path / "state" / "claude_configure_state.json" if initial is not None: self.settings.parent.mkdir(parents=True) self.settings.write_text(json.dumps(initial)) def read(self): return json.loads(self.settings.read_text()) if self.settings.exists() else None def configure(self, credential=StaticToken("sk-virtual-key"), model=StartOn("claude-auto"), **kwargs): configure_claude_settings(PROXY, credential, model, self.settings, self.state, (), **kwargs) def edit(self, *edits): settings = self.read() for apply in edits: settings = apply(settings) self.settings.write_text(json.dumps(settings)) def unconfigure(self): return unconfigure_claude_settings(self.settings, self.state, ()) # Each row: initial file, steps (configure kwargs dicts or edit callables) between the first configure # and unconfigure, the expected file afterwards, and the expected outcome fields. Sequences that used # to be one test each; the receipt's rules are what make them all come out right. UNDO_SCENARIOS = { "plain round trip": (ORIGINAL, [], ORIGINAL, {"kept": ()}), "no file before": (None, [], None, {"file_removed": True}), "no env before": ({"theme": "dark"}, [], {"theme": "dark"}, {}), "null env before": ({"theme": "dark", "env": None}, [], {"theme": "dark", "env": None}, {}), "empty env before": ({"theme": "dark", "env": {}}, [], {"theme": "dark", "env": {}}, {}), "user edits stay and are named": ( ORIGINAL, [_set("env.ENABLE_TOOL_SEARCH", "false"), _set("model", "claude-sonnet-4-6")], {**ORIGINAL, "env": {**ORIGINAL["env"], "ENABLE_TOOL_SEARCH": "false"}, "model": "claude-sonnet-4-6"}, {"kept": {"env.ENABLE_TOOL_SEARCH", "model"}, "withheld": ()}, ), "user filled an env configure created": (None, [_set("env.MY_VAR", "mine")], {"env": {"MY_VAR": "mine"}}, {}), "user deleted the file": (None, [lambda s: None], None, {"file_removed": True, "restored": (), "kept": ()}), "user removed our key: neither restored nor kept": ( ORIGINAL, [_set("env.ANTHROPIC_AUTH_TOKEN", _ABSENT)], ORIGINAL, {"not_restored": {"env.ANTHROPIC_AUTH_TOKEN"}, "kept": ()}, ), "restored names only what changed": ( {"model": "claude-opus-5"}, [], {"model": "claude-opus-5"}, { "restored": { "env.ANTHROPIC_BASE_URL", "env.ANTHROPIC_AUTH_TOKEN", "env.ENABLE_TOOL_SEARCH", "env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY", "statusLine", }, "kept": (), }, {"credential": RELOGIN, "model": KeepModel()}, ), "repeat across credential kinds keeps the first snapshot": ( ORIGINAL, [ {"credential": RELOGIN, "model": UnpinModel()}, {"credential": StaticToken("sk-rotated"), "model": StartOn("claude-sonnet-4-6")}, ], ORIGINAL, {}, ), "repeat without a model lets go of our pin, user had none": ({}, [{"model": UnpinModel()}], {}, {}), "repeat without a model lets go of our pin, user had one": ( {"model": "claude-opus-5"}, [{"model": UnpinModel()}], {"model": "claude-opus-5"}, {}, ), "re-login keeps our pin": (None, [{"credential": RELOGIN, "model": KeepModel()}], None, {"file_removed": True}), "edit between configures survives an unpin repeat": ( ORIGINAL, [ _set("model", "my-favourite"), _set("env.ENABLE_TOOL_SEARCH", "false"), {"credential": RELOGIN, "model": UnpinModel()}, ], {**ORIGINAL, "env": {**ORIGINAL["env"], "ENABLE_TOOL_SEARCH": "false"}, "model": "my-favourite"}, {"kept": {"env.ENABLE_TOOL_SEARCH", "model"}}, ), "edit between configures survives a re-login": ( ORIGINAL, [ _set("model", "my-favourite"), _set("env.ENABLE_TOOL_SEARCH", "false"), {"credential": RELOGIN, "model": KeepModel()}, ], {**ORIGINAL, "env": {**ORIGINAL["env"], "ENABLE_TOOL_SEARCH": "false"}, "model": "my-favourite"}, {"kept": {"env.ENABLE_TOOL_SEARCH", "model"}}, ), "edit between configures: a same-model repeat displaces it, so it is what comes back": ( ORIGINAL, [_set("model", "my-favourite"), _set("env.ENABLE_TOOL_SEARCH", "false"), {"credential": RELOGIN}], {**ORIGINAL, "env": {**ORIGINAL["env"], "ENABLE_TOOL_SEARCH": "false"}, "model": "my-favourite"}, {"kept": {"env.ENABLE_TOOL_SEARCH"}, "restored_includes": {"model"}}, ), "base URL changed since: credentials withheld, receipt kept": ( ORIGINAL, [_set("env.ANTHROPIC_BASE_URL", "http://other-proxy:4000")], {**ORIGINAL, "env": {"KEEP_ME": "1", "ANTHROPIC_BASE_URL": "http://other-proxy:4000"}, "apiKeyHelper": _ABSENT}, { "withheld": {("env.ANTHROPIC_API_KEY", ANTHROPIC), ("apiKeyHelper", ANTHROPIC)}, "kept": {"env.ANTHROPIC_BASE_URL"}, "receipt_kept": True, }, ), "base URL changed and back: judged against the URL the restored file holds": ( ORIGINAL, [_set("env.ANTHROPIC_BASE_URL", ANTHROPIC)], ORIGINAL, {"withheld": ()}, ), "credential captured beside no URL goes back only beside no URL": ( {"env": {"ANTHROPIC_API_KEY": "sk-default-endpoint"}}, [_set("env.ANTHROPIC_BASE_URL", "http://other-proxy:4000")], {"env": {"ANTHROPIC_BASE_URL": "http://other-proxy:4000"}}, { "withheld": {("env.ANTHROPIC_API_KEY", "no ANTHROPIC_BASE_URL (Anthropic's default endpoint)")}, "receipt_kept": True, }, ), "restored document empty while a credential is withheld: file goes, receipt stays": ( None, [ _set("env.ANTHROPIC_API_KEY", "sk-user"), {"credential": RELOGIN, "model": KeepModel()}, _set("env.ANTHROPIC_BASE_URL", _ABSENT), ], None, {"withheld": {("env.ANTHROPIC_API_KEY", PROXY)}, "file_removed": True, "receipt_kept": True}, {"credential": RELOGIN, "model": KeepModel()}, ), "a credential the user changed is kept, never also withheld": ( ORIGINAL, [_set("env.ANTHROPIC_BASE_URL", "http://other-proxy:4000"), _set("apiKeyHelper", "/opt/mine/helper")], { **ORIGINAL, "env": {"KEEP_ME": "1", "ANTHROPIC_BASE_URL": "http://other-proxy:4000"}, "apiKeyHelper": "/opt/mine/helper", }, { "withheld": {("env.ANTHROPIC_API_KEY", ANTHROPIC)}, "kept": {"env.ANTHROPIC_BASE_URL", "apiKeyHelper"}, "receipt_kept": True, }, ), } def _expected_file(expected): if expected is None: return None return {k: v for k, v in expected.items() if v is not _ABSENT} class TestConfigureAndUnconfigure: """`configure_claude_settings` records how to undo itself; `unconfigure_claude_settings` undoes only that.""" @pytest.mark.parametrize("scenario", UNDO_SCENARIOS.values(), ids=UNDO_SCENARIOS.keys()) def test_undo_matrix(self, tmp_path, scenario): initial, steps, expected, outcome_expectations, *first = scenario rig = _Rig(tmp_path, initial) rig.configure(**(first[0] if first else {})) for step in steps: if isinstance(step, dict): rig.configure(**step) elif rig.settings.exists() and step(json.loads(rig.settings.read_text())) is None: rig.settings.unlink() else: rig.edit(step) outcome = rig.unconfigure() assert rig.read() == _expected_file(expected) assert rig.state.exists() == outcome_expectations.get("receipt_kept", False) for field, want in outcome_expectations.items(): if field == "withheld": assert {(item.key, item.endpoint) for item in outcome.withheld} == set(want) elif field == "not_restored": assert not set(want) & set(outcome.restored) and not set(want) & set(outcome.kept) elif field == "restored_includes": assert set(want) <= set(outcome.restored) elif field in ("restored", "kept"): assert set(getattr(outcome, field)) == set(want) elif field != "receipt_kept": assert getattr(outcome, field) == want assert not {item.key for item in outcome.withheld} & set(outcome.kept) def test_configure_writes_owner_only_and_the_receipt_never_holds_the_key(self, tmp_path): rig = _Rig(tmp_path, ORIGINAL) rig.configure(credential=StaticToken("sk-virtual-key-never-on-disk-twice")) configured = rig.read() assert configured["env"]["ANTHROPIC_AUTH_TOKEN"] == "sk-virtual-key-never-on-disk-twice" assert configured["env"]["ANTHROPIC_BASE_URL"] == PROXY and configured["model"] == "claude-auto" assert "ANTHROPIC_API_KEY" not in configured["env"] and "apiKeyHelper" not in configured assert stat.S_IMODE(rig.settings.stat().st_mode) == 0o600 == stat.S_IMODE(rig.state.stat().st_mode) assert "sk-virtual-key-never-on-disk-twice" not in rig.state.read_text() def test_withheld_credentials_come_back_once_the_url_points_at_their_server_again(self, tmp_path): # The kept receipt owns only the withheld slots: the second unconfigure restores exactly those. rig = _Rig(tmp_path, ORIGINAL) rig.configure() rig.edit(_set("env.ANTHROPIC_BASE_URL", "http://other-proxy:4000")) rig.unconfigure() rig.edit(_set("env.ANTHROPIC_BASE_URL", ANTHROPIC), _set("theme", "light")) outcome = rig.unconfigure() assert rig.read() == {**ORIGINAL, "theme": "light"} assert set(outcome.restored) == {"env.ANTHROPIC_API_KEY", "apiKeyHelper"} assert outcome.kept == () and outcome.withheld == () and not rig.state.exists() @pytest.mark.parametrize( ("path", "value", "repeat_credential"), [ ("env.ANTHROPIC_API_KEY", "sk-user-added-later", RELOGIN), ("apiKeyHelper", "/opt/mine/helper", StaticToken("sk-rotated")), ], ids=["user-adds-api-key", "user-sets-own-helper"], ) def test_a_credential_the_user_set_between_two_configures_is_what_comes_back( self, tmp_path, path, value, repeat_credential ): # The repeat's merge clears the slot, so the displaced value is snapshotted and is what returns; # it was set while the file pointed at the proxy, so it returns once the file points there again. rig = _Rig(tmp_path, {"theme": "dark"}) rig.configure(credential=RELOGIN, model=KeepModel()) rig.edit(_set(path, value)) rig.configure(credential=repeat_credential, model=KeepModel()) assert not _lookup(rig.read(), path) outcome = rig.unconfigure() assert [(item.key, item.endpoint) for item in outcome.withheld] == [(path, PROXY)] assert rig.read() == {"theme": "dark"} and rig.state.exists() rig.settings.write_text(json.dumps({"theme": "dark", "env": {"ANTHROPIC_BASE_URL": PROXY}})) outcome = rig.unconfigure() assert _lookup(rig.read(), path) == value assert outcome.restored == (path,) and outcome.withheld == () and not rig.state.exists() def test_a_receipt_commit_that_fails_leaves_no_staged_token_behind(self, tmp_path): rig = _Rig(tmp_path, {}) def commit_receipt_fails(staged, path): if path == str(rig.state): os.unlink(staged) raise OSError("receipt rename failed") commit_staged_json(staged, path) with pytest.raises(ClaudeSettingsError, match=r"Could not write .*receipt rename failed"): rig.configure(credential=StaticToken("sk-never-left-in-a-temp-file"), commit=commit_receipt_fails) assert not list(rig.settings.parent.glob(".tmp-*")) and not list(rig.state.parent.glob(".tmp-*")) assert rig.read() == {} and not rig.state.exists() @pytest.mark.parametrize("configured_before", [False, True], ids=["first-configure", "repeat-configure"]) def test_a_settings_commit_that_fails_after_the_receipt_landed_puts_the_receipt_back( self, tmp_path, configured_before ): # The two renames are not atomic: a settings rename that fails after the receipt landed must # not leave a receipt describing settings that were never written. rig = _Rig(tmp_path, ORIGINAL) if configured_before: rig.configure() receipt_before = rig.state.read_text() if configured_before else None settings_before = rig.settings.read_text() def commit_settings_fails(staged, path): if path == str(rig.settings): os.unlink(staged) raise OSError("rename failed") commit_staged_json(staged, path) with pytest.raises(ClaudeSettingsError, match="rename failed"): rig.configure(credential=StaticToken("sk-rotated"), commit=commit_settings_fails) assert rig.settings.read_text() == settings_before assert (rig.state.read_text() if rig.state.exists() else None) == receipt_before if configured_before: rig.unconfigure() assert rig.read() == ORIGINAL def test_a_failed_repeat_configure_leaves_the_earlier_undo_intact(self, tmp_path): rig = _Rig(tmp_path, ORIGINAL) rig.configure() receipt_before = rig.state.read_text() rig.settings.parent.chmod(0o500) try: with pytest.raises(ClaudeSettingsError, match="Could not write"): rig.configure(credential=StaticToken("sk-rotated")) finally: rig.settings.parent.chmod(0o700) assert rig.state.read_text() == receipt_before and not list(rig.state.parent.glob(".tmp-*")) assert rig.read()["env"]["ANTHROPIC_AUTH_TOKEN"] == "sk-virtual-key" rig.unconfigure() assert rig.read() == ORIGINAL def test_unconfigure_reports_a_receipt_it_cannot_remove_as_a_settings_error(self, tmp_path): rig = _Rig(tmp_path, ORIGINAL) rig.configure() rig.state.parent.chmod(0o500) try: with pytest.raises(ClaudeSettingsError, match="Could not remove"): rig.unconfigure() finally: rig.state.parent.chmod(0o700) def test_configure_writes_through_a_symlinked_settings_file(self, tmp_path): target = tmp_path / "dotfiles" / "settings.json" target.parent.mkdir() target.write_text(json.dumps({"theme": "dark"})) link = tmp_path / "settings.json" link.symlink_to(target) configure_claude_settings(PROXY, StaticToken("sk-virtual-key"), UnpinModel(), link, tmp_path / "state.json", ()) assert link.is_symlink() assert json.loads(target.read_text())["env"]["ANTHROPIC_AUTH_TOKEN"] == "sk-virtual-key" @pytest.mark.parametrize("operation", ["configure", "unconfigure"]) def test_refuses_while_a_temporary_owner_holds_a_backup(self, paths, tmp_path, operation): settings_path, backup_path = paths backup_path.write_text("{}") owners = _owners(backup_path) state = tmp_path / "state.json" attempt = ( (lambda: configure_claude_settings(PROXY, StaticToken("k"), UnpinModel(), settings_path, state, owners)) if operation == "configure" else (lambda: unconfigure_claude_settings(settings_path, state, owners)) ) with pytest.raises(ClaudeSettingsError, match="lite down"): attempt() assert not settings_path.exists() def test_unconfigure_without_a_receipt_is_an_error_not_a_silent_no_op(self, tmp_path): with pytest.raises(ClaudeSettingsError, match="nothing to undo"): _Rig(tmp_path, None).unconfigure() def _lookup(settings, path): section, _, key = path.rpartition(".") return (settings.get(section) or {}).get(key) if section else settings.get(key) class TestStatusLine: """Every configure registers the status line, and only while the slot is empty or already ours.""" COMMAND = "/opt/lite/bin/python /Users/me/.litellm/statusline.py" def test_an_empty_slot_gets_our_status_line(self): assert with_status_line({}, self.COMMAND)["statusLine"] == {"type": "command", "command": self.COMMAND} def test_a_users_own_status_line_is_never_replaced(self): theirs = {"type": "command", "command": "~/.claude/my-statusline.sh"} assert with_status_line({"statusLine": theirs}, self.COMMAND)["statusLine"] == theirs def test_ours_under_an_older_interpreter_is_refreshed(self): stale = {"type": "command", "command": "/old/python /Users/me/.litellm/statusline.py"} assert with_status_line({"statusLine": stale}, self.COMMAND)["statusLine"]["command"] == self.COMMAND def test_the_merge_carries_it(self): merged = merge_claude_settings({}, PROXY, StaticToken("tok"), status_line=self.COMMAND) assert merged["statusLine"] == {"type": "command", "command": self.COMMAND} def test_the_installed_script_is_the_bundled_one_and_the_command_runs_this_interpreter(self, tmp_path): from litellm.proxy.client.cli.commands import statusline_script script = tmp_path / "lite" / "statusline.py" command = install_statusline_script(script) assert script.read_bytes().split(b"\n", 1)[1] == pathlib.Path(statusline_script.__file__).read_bytes() assert shlex.split(command) == [sys.executable, str(script)] assert command == statusline_command(script) assert stat.S_IMODE(script.stat().st_mode) == 0o600 assert stat.S_IMODE(script.parent.stat().st_mode) == 0o700 assert install_statusline_script(script) == command def test_a_reinstall_replaces_the_script_in_one_step_and_a_refused_one_leaves_the_old_script_whole(self, tmp_path): # Claude Code may be running the script at the moment `lite` reinstalls it; the file it has open # must stay complete, and a reinstall that cannot land must not leave a truncated script behind. script = tmp_path / "lite" / "statusline.py" install_statusline_script(script) bundled = script.read_bytes() with script.open("rb") as running: install_statusline_script(script) assert running.read() == bundled assert {child.name for child in script.parent.iterdir()} <= {"statusline.py", "statusline.py.lock"} if os.geteuid() != 0: script.parent.chmod(0o500) try: with pytest.raises(ClaudeSettingsError, match="Could not install the status line script"): install_statusline_script(script) finally: script.parent.chmod(0o700) assert script.read_bytes() == bundled @pytest.mark.parametrize( ("installed_version", "older_version"), (("2.10.0", "2.9.0"), ("2.1.0", "2.1.0rc1"), ("2.1.0rc1", "2.1.0.dev2"), ("2.1.0.post1", "2.1.0")), ) def test_an_older_cli_preserves_the_newer_footer( self, tmp_path: Path, capsys: pytest.CaptureFixture[str], installed_version: str, older_version: str ) -> None: script: Final = tmp_path / "statusline.py" command: Final = install_statusline_script(script, package_version=installed_version) installed: Final = script.read_bytes() modified: Final = script.stat().st_mtime_ns assert install_statusline_script(script, package_version=older_version) == command assert script.read_bytes() == installed assert script.stat().st_mtime_ns == modified assert f"Keeping the status line from LiteLLM {installed_version}" in capsys.readouterr().err @pytest.mark.parametrize( "old_header", (b"", b"# litellm-statusline-version: invalid\n", b"# litellm-statusline-version: \xff\n") ) def test_a_legacy_or_damaged_version_marker_is_repaired(self, tmp_path: Path, old_header: bytes) -> None: from litellm.proxy.client.cli.commands import statusline_script script: Final = tmp_path / "statusline.py" script.write_bytes(old_header + b"print('old footer')\n") install_statusline_script(script, package_version="2.1.0") assert script.read_bytes() == ( b"# litellm-statusline-version: 2.1.0\n" + Path(statusline_script.__file__).read_bytes() ) @pytest.mark.parametrize("next_version", ("2.1.0", "2.2.0")) def test_an_equal_or_newer_cli_refreshes_the_footer(self, tmp_path: Path, next_version: str) -> None: from litellm.proxy.client.cli.commands import statusline_script script: Final = tmp_path / "statusline.py" script.write_bytes(b"# litellm-statusline-version: 2.1.0\nprint('old footer')\n") install_statusline_script(script, package_version=next_version) assert script.read_bytes() == ( f"# litellm-statusline-version: {next_version}\n".encode() + Path(statusline_script.__file__).read_bytes() ) def test_configure_keeps_a_newer_footer_while_updating_settings( self, tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: script: Final = tmp_path / "statusline.py" script.write_bytes(b"# litellm-statusline-version: 999999.0.0\nprint('newer footer')\n") installed: Final = script.read_bytes() rig: Final = _Rig(tmp_path, {"theme": "dark"}) rig.configure(script_path=script) assert script.read_bytes() == installed assert rig.read()["statusLine"]["command"] == statusline_command(script) assert rig.read()["env"]["ANTHROPIC_BASE_URL"] == PROXY assert "Keeping the status line" in capsys.readouterr().err @pytest.mark.parametrize("package_version", ("unknown", "", "invalid-version")) @pytest.mark.parametrize("existing", (None, b"print('legacy footer')\n", b"# litellm-statusline-version: invalid\n")) def test_an_unknown_cli_version_can_install_and_refresh_an_unversioned_footer( self, tmp_path: Path, package_version: str, existing: bytes | None ) -> None: from litellm.proxy.client.cli.commands import statusline_script script: Final = tmp_path / "statusline.py" if existing is not None: script.write_bytes(existing) assert install_statusline_script(script, package_version=package_version) == statusline_command(script) assert script.read_bytes() == Path(statusline_script.__file__).read_bytes() def test_an_unknown_cli_version_preserves_a_versioned_footer( self, tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: script: Final = tmp_path / "statusline.py" command: Final = install_statusline_script(script, package_version="2.1.0") installed: Final = script.read_bytes() assert install_statusline_script(script, package_version="unknown") == command assert script.read_bytes() == installed assert "Keeping the status line from LiteLLM 2.1.0" in capsys.readouterr().err @pytest.mark.parametrize(("first_version", "second_version"), (("2.0", "3.0"), ("3.0", "2.0"))) def test_overlapping_installs_keep_the_newest_footer( self, tmp_path: Path, first_version: str, second_version: str ) -> None: from litellm.proxy.client.cli.commands import statusline_script script: Final = tmp_path / "statusline.py" first_writing: Final = Event() release_first: Final = Event() second_started: Final = Event() def paused_write(path: str, data: bytes) -> None: first_writing.set() assert release_first.wait(5), "First installer was never released" write_private_bytes(path, data) def second_install() -> str: second_started.set() return install_statusline_script(script, package_version=second_version) with ThreadPoolExecutor(max_workers=2) as pool: first: Final = pool.submit(install_statusline_script, script, package_version=first_version, write=paused_write) try: assert first_writing.wait(5), "First installer did not reach the write" second: Final = pool.submit(second_install) assert second_started.wait(5), "Second installer did not start" with pytest.raises(FutureTimeoutError): second.result(timeout=0.5) finally: release_first.set() assert first.result(timeout=5) == statusline_command(script) assert second.result(timeout=5) == statusline_command(script) assert script.read_bytes() == b"# litellm-statusline-version: 3.0\n" + Path(statusline_script.__file__).read_bytes() def test_a_failed_install_keeps_the_footer_and_releases_the_lock(self, tmp_path: Path) -> None: script: Final = tmp_path / "statusline.py" install_statusline_script(script, package_version="2.0") installed: Final = script.read_bytes() def failed_write(path: str, data: bytes) -> None: raise OSError("disk full") with pytest.raises(ClaudeSettingsError, match="disk full"): install_statusline_script(script, package_version="3.0", write=failed_write) assert script.read_bytes() == installed assert install_statusline_script(script, package_version="3.0") == statusline_command(script) assert script.read_bytes().startswith(b"# litellm-statusline-version: 3.0\n") def test_configure_installs_it_and_unconfigure_removes_only_ours(self, tmp_path): rig = _Rig(tmp_path, {"theme": "dark"}) script = tmp_path / "statusline.py" rig.configure(script_path=script) assert rig.read()["statusLine"]["command"] == statusline_command(script) assert script.exists() outcome = rig.unconfigure() assert rig.read() == {"theme": "dark"} assert "statusLine" in outcome.restored def test_a_status_line_the_user_replaced_after_configure_survives_unconfigure(self, tmp_path): rig = _Rig(tmp_path, None) rig.configure(model=KeepModel(), script_path=tmp_path / "statusline.py") theirs = {"type": "command", "command": "~/.claude/my-statusline.sh"} rig.edit(lambda settings: {**settings, "statusLine": theirs}) outcome = rig.unconfigure() assert rig.read()["statusLine"] == theirs assert "statusLine" in outcome.kept def test_a_receipt_from_before_the_status_line_existed_still_unconfigures(self, tmp_path): # Older receipts never claimed statusLine; a key no configure wrote is never ours, so it stays. rig = _Rig(tmp_path, None) script = tmp_path / "statusline.py" rig.configure(model=KeepModel(), script_path=script) receipt = json.loads(rig.state.read_text()) receipt["written"].pop("statusLine") receipt["previous"].pop("statusLine") rig.state.write_text(json.dumps(receipt)) outcome = rig.unconfigure() restored = rig.read() assert "ANTHROPIC_AUTH_TOKEN" not in restored.get("env", {}) assert restored["statusLine"]["command"] == statusline_command(script) assert "statusLine" not in outcome.restored