litellm/tests/unit/proxy/client/cli/test_configure_commands.py
devin-ai-integration[bot] 39e31958f8
test(proxy): move auth, hooks, policy_engine and client tests into tests/unit/proxy (#43998)
* test(proxy): move auth, hooks, policy_engine and client tests into tests/unit/proxy

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(proxy): stub HIBP through respx by disabling the aiohttp transport

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(proxy): share the httpx transport fixture across proxy unit tests

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(proxy): restore proxy globals without a missing-value sentinel

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(proxy): package moved dirs and stub the login breach check at the HTTP boundary

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(proxy): isolate the mcp server manager per test

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: yuneng <yuneng@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-01 10:11:45 -07:00

1248 lines
57 KiB
Python

import io
import json
import os
import stat
import time
from pathlib import Path
from types import SimpleNamespace
from typing import Final, Literal
import click
import pytest
import requests
import responses
import tomlkit
from click.testing import CliRunner
from InquirerPy.base.control import Choice
from pydantic import JsonValue, TypeAdapter
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
PROXY = "http://proxy.test:4000"
VALID_KEY = "sk-virtual-key"
LISTED_MODELS = ("claude-auto", "gpt-5.6-luna")
def _mock_models():
responses.get(
f"{PROXY}/v1/models",
json={"data": [{"id": model, "object": "model"} for model in LISTED_MODELS]},
match=[responses.matchers.header_matcher({"Authorization": f"Bearer {VALID_KEY}"})],
)
responses.get(f"{PROXY}/v1/models", status=401)
@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.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)
monkeypatch.delenv("LITELLM_PROXY_API_KEY", raising=False)
monkeypatch.delenv("LITELLM_PROXY_URL", raising=False)
return settings_path, state_path
@pytest.fixture
def lite_on_path(monkeypatch, tmp_path):
"""A real `lite` executable on PATH, so the apiKeyHelper command resolves without patching."""
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
lite = bin_dir / "lite"
lite.write_text("#!/bin/sh\nexit 0\n")
lite.chmod(lite.stat().st_mode | stat.S_IXUSR)
monkeypatch.setenv("PATH", f"{bin_dir}{os.pathsep}{os.environ.get('PATH', '')}")
return str(lite)
@pytest.fixture
def runner():
return CliRunner()
@pytest.fixture
def codex_path():
return Path(os.environ["CODEX_HOME"]) / "config.toml"
class _TerminalInput(io.BytesIO):
def isatty(self):
return True
def _mock_agent_models():
def listing(request):
assert request.headers["Authorization"] == f"Bearer {VALID_KEY}"
rows = (
[{"id": "claude-router-6175746f", "source_model": "auto"}]
if request.headers.get("x-gateway-client") == "claude-code"
else [{"id": "auto"}]
)
return 200, {"Content-Type": "application/json"}, json.dumps({"data": rows})
responses.add_callback(responses.GET, f"{PROXY}/v1/models", callback=listing)
@pytest.fixture
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(
claude_settings_module, "SETTINGS_FILE_OWNERS", (SettingsFileOwner(backup, "lite up", "lite down"),)
)
return backup
def _configure(runner, *args):
return runner.invoke(cli, ["--base-url", PROXY, "configure", "claude", *args])
class TestConfigureClaudeWithAVirtualKey:
@responses.activate
def test_writes_settings_and_reports_without_echoing_the_key(self, runner, paths):
_mock_models()
settings_path, state_path = paths
result = _configure(runner, "--api-key", VALID_KEY, "--model", "claude-auto")
assert result.exit_code == 0, result.output
written = json.loads(settings_path.read_text())
assert written["env"]["ANTHROPIC_BASE_URL"] == PROXY
assert written["env"]["ANTHROPIC_AUTH_TOKEN"] == VALID_KEY
assert written["env"]["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] == "1"
assert written["model"] == "claude-auto"
assert "ANTHROPIC_DEFAULT_SONNET_MODEL" not in written["env"]
assert state_path.exists()
assert VALID_KEY not in result.output
assert written["env"]["ANTHROPIC_MODEL"] == "claude-auto"
assert "Starting model: claude-auto" in result.output
assert "1 of the proxy's 2 models" in result.output
assert "lite unconfigure claude" in result.output
assert [call.request.headers.get("x-gateway-client") for call in responses.calls] == ["claude-code"]
@responses.activate
def test_takes_the_key_from_the_global_option_and_keeps_claude_codes_default(self, runner, paths):
_mock_models()
settings_path, _ = paths
result = runner.invoke(cli, ["--base-url", PROXY, "--api-key", VALID_KEY, "configure", "claude"])
assert result.exit_code == 0, result.output
written = json.loads(settings_path.read_text())
assert written["env"]["ANTHROPIC_AUTH_TOKEN"] == VALID_KEY
assert "model" not in written and "ANTHROPIC_MODEL" not in written["env"]
assert "Starting model: not pinned" in result.output
@responses.activate
def test_refuses_a_model_the_proxy_does_not_list(self, runner, paths):
_mock_models()
settings_path, _ = paths
result = _configure(runner, "--api-key", VALID_KEY, "--model", "claude-nope")
assert result.exit_code != 0
assert "'claude-nope' is not served" in result.output
assert "claude-auto, gpt-5.6-luna" in result.output
assert not settings_path.exists()
@responses.activate
def test_refuses_a_key_the_proxy_rejects(self, runner, paths):
_mock_models()
settings_path, _ = paths
result = _configure(runner, "--api-key", "sk-wrong")
assert result.exit_code != 0
assert "rejected your key (HTTP 401)" in result.output
assert not settings_path.exists()
@responses.activate
@pytest.mark.parametrize(
("mock", "expected", "unexpected"),
[
(
lambda: responses.get(f"{PROXY}/v1/models", body=requests.ConnectionError("refused")),
"Is the proxy at",
"answered",
),
(
lambda: responses.get(f"{PROXY}/v1/models", status=500),
"The proxy at http://proxy.test:4000 answered",
"Is the proxy at",
),
(
lambda: responses.get(f"{PROXY}/v1/models", body="<html>not json</html>"),
"answered, so check that it is a LiteLLM proxy",
"Is the proxy at",
),
(
lambda: responses.get(f"{PROXY}/v1/models", json={"data": []}),
"Claude Code would have nothing to run",
"Is the proxy at",
),
],
ids=["unreachable", "http-500", "non-json-body", "empty-list"],
)
def test_the_listing_hint_matches_how_the_listing_failed(self, runner, paths, mock, expected, unexpected):
# Only a proxy that never answered gets the "is it running" question; a 500, a non-JSON body or an
# empty list prove it is up, and the hint says so instead.
mock()
settings_path, _ = paths
result = _configure(runner, "--api-key", VALID_KEY)
assert result.exit_code != 0
assert expected in result.output and unexpected not in result.output
assert not settings_path.exists()
@responses.activate
@pytest.mark.parametrize("entry", ["virtual-key", "no-key", "interactive"])
def test_refuses_while_lite_up_holds_a_backup_before_any_request(self, runner, paths, lite_up_backup, entry):
_mock_models()
if entry == "interactive":
ctx = click.Context(configure_group, obj={"base_url": PROXY, "api_key": VALID_KEY})
with pytest.raises(click.ClickException, match="lite down"):
interactive_configure(ctx, pick_targets=lambda: ("claude",), pick_model=lambda listed: None)
else:
args = ["--api-key", VALID_KEY] if entry == "virtual-key" else []
result = runner.invoke(configure_claude, args, obj={"base_url": PROXY, "api_key": None})
assert result.exit_code != 0 and "lite down" in result.output
assert len(responses.calls) == 0
assert not paths[0].exists()
@responses.activate
def test_says_so_when_the_key_is_written_through_a_symlink(self, runner, paths, tmp_path):
_mock_models()
settings_path, _ = paths
target = tmp_path / "dotfiles" / "settings.json"
target.parent.mkdir()
target.write_text("{}")
settings_path.parent.mkdir(parents=True)
settings_path.symlink_to(target)
result = _configure(runner, "--api-key", VALID_KEY)
assert result.exit_code == 0, result.output
assert "keep it out of version control" in result.output
assert json.loads(target.read_text())["env"]["ANTHROPIC_AUTH_TOKEN"] == VALID_KEY
class TestConfigureClaudeWithoutAKey:
@responses.activate
def test_refuses_and_names_the_ways_to_pass_a_key_without_writing_or_logging_in(self, runner, paths):
# A `lite login` credential expires within a day; the old fallback wrote an apiKeyHelper that made
# Claude Code spawn `lite` (and its keychain probe) on every credential refresh.
_mock_models()
settings_path, state_path = paths
result = runner.invoke(
configure_claude,
["--model", "claude-auto"],
obj={"base_url": PROXY, "api_key": "sk-login-jwt", "api_key_from_token_file": True},
)
assert result.exit_code != 0
assert "--api-key" in result.output and "LITELLM_PROXY_API_KEY" in result.output
assert "apiKeyHelper" not in result.output
assert not settings_path.exists() and not state_path.exists()
assert len(responses.calls) == 0
@responses.activate
def test_an_explicit_key_still_wins_over_a_stored_login(self, runner, paths):
_mock_models()
settings_path, _ = paths
result = runner.invoke(
configure_claude,
["--api-key", VALID_KEY],
obj={"base_url": PROXY, "api_key": "sk-login-jwt", "api_key_from_token_file": True},
)
assert result.exit_code == 0, result.output
written = json.loads(settings_path.read_text())
assert written["env"]["ANTHROPIC_AUTH_TOKEN"] == VALID_KEY and "apiKeyHelper" not in written
class TestInteractiveConfigure:
@responses.activate
def test_asks_for_targets_and_a_starting_model_then_configures(self, paths):
_mock_models()
settings_path, _ = paths
asked = {}
def pick_model(listed):
asked["listed"] = tuple(listed)
return "claude-auto"
ctx = click.Context(
configure_group, obj={"base_url": PROXY, "api_key": VALID_KEY, "api_key_from_token_file": False}
)
interactive_configure(ctx, pick_targets=lambda: ("claude",), pick_model=pick_model)
assert asked["listed"] == LISTED_MODELS
assert json.loads(settings_path.read_text())["model"] == "claude-auto"
def test_does_nothing_when_claude_code_is_not_picked(self, paths):
settings_path, _ = paths
ctx = click.Context(
configure_group, obj={"base_url": PROXY, "api_key": VALID_KEY, "api_key_from_token_file": False}
)
interactive_configure(ctx, pick_targets=lambda: (), pick_model=lambda listed: None)
assert not settings_path.exists()
def test_bare_configure_without_a_terminal_names_the_non_interactive_command(self, runner, paths):
result = runner.invoke(cli, ["--base-url", PROXY, "configure"])
assert result.exit_code != 0
assert "lite configure claude --api-key" in result.output
class TestConfigureAgents:
@responses.activate
@pytest.mark.parametrize("targets", [("claude",), ("codex",), ("claude", "codex")])
def test_group_options_drive_the_agent_picker_and_write_only_selected_agents(
self, runner, paths, codex_path, monkeypatch, targets
):
_mock_agent_models()
asked = []
def checkbox(**kwargs):
assert tuple(choice.value for choice in kwargs["choices"]) == ("claude", "codex")
return SimpleNamespace(execute=lambda: targets)
def fuzzy(**kwargs):
assert "auto" in kwargs["choices"]
assert "claude-router-6175746f" not in kwargs["choices"]
assert not paths[0].exists() and not codex_path.exists()
asked.append(kwargs["message"])
return SimpleNamespace(execute=lambda: "auto")
monkeypatch.setattr(configure_module.inquirer, "checkbox", checkbox)
monkeypatch.setattr(configure_module.inquirer, "fuzzy", fuzzy)
result = runner.invoke(
cli,
["configure", "--api-key", VALID_KEY, "--gateway-url", f"{PROXY}/v1/"],
input=_TerminalInput(),
)
assert result.exit_code == 0, result.output
assert VALID_KEY not in result.output
assert len(asked) == len(targets)
assert paths[0].exists() == ("claude" in targets)
assert codex_path.exists() == ("codex" in targets)
if "claude" in targets:
claude = json.loads(paths[0].read_text())
assert claude["model"] == "claude-router-6175746f"
assert claude["env"]["ANTHROPIC_BASE_URL"] == PROXY
assert claude["env"]["ANTHROPIC_AUTH_TOKEN"] == VALID_KEY
if "codex" in targets:
codex = tomlkit.parse(codex_path.read_text())
assert codex["model"] == "auto"
assert codex["model_provider"] == "litellm"
provider = codex["model_providers"]["litellm"]
assert provider["base_url"] == f"{PROXY}/v1"
assert provider["http_headers"]["Authorization"] == f"Bearer {VALID_KEY}"
assert "env_key" not in provider
assert [call.request.headers.get("x-gateway-client") for call in responses.calls] == [
"claude-code" if target == "claude" else None for target in targets
]
@responses.activate
@pytest.mark.parametrize("target", ["claude", "codex"])
@pytest.mark.parametrize("leaf_override", [False, True], ids=["inherit-group", "leaf-wins"])
def test_group_connection_options_are_inherited_and_leaf_options_take_precedence(
self, runner, paths, codex_path, target, leaf_override
):
_mock_agent_models()
group_url = "http://group.test" if leaf_override else PROXY
group_key = "sk-group" if leaf_override else VALID_KEY
args = [
"--base-url", "http://global.test", "--api-key", "sk-global", "configure",
"--gateway-url", group_url, "--api-key", group_key, target, "--model", "auto",
]
if leaf_override:
args.extend(["--base-url", f"{PROXY}/v1/", "--api-key", VALID_KEY])
result = runner.invoke(cli, args)
assert result.exit_code == 0, result.output
assert all(key not in result.output for key in (VALID_KEY, group_key, "sk-global"))
if target == "claude":
written = json.loads(paths[0].read_text())
assert written["env"]["ANTHROPIC_AUTH_TOKEN"] == VALID_KEY
assert written["env"]["ANTHROPIC_BASE_URL"] == PROXY
assert not codex_path.exists()
else:
provider = tomlkit.parse(codex_path.read_text())["model_providers"]["litellm"]
assert provider["http_headers"]["Authorization"] == f"Bearer {VALID_KEY}"
assert provider["base_url"] == f"{PROXY}/v1"
assert not paths[0].exists()
assert len(responses.calls) == 1
@responses.activate
@pytest.mark.parametrize("failure", ["invalid-model", "cancel"])
def test_both_model_choices_complete_before_either_configuration_changes(
self, paths, codex_path, failure
):
_mock_agent_models()
settings_path, state_path = paths
settings_path.parent.mkdir(parents=True)
settings_path.write_text('{"theme": "dark"}')
codex_path.parent.mkdir(parents=True)
codex_path.write_text('model = "original"\n')
before = (settings_path.read_bytes(), codex_path.read_bytes())
def pick_codex_model(listed):
assert listed == ("auto",)
assert (settings_path.read_bytes(), codex_path.read_bytes()) == before
if failure == "cancel":
raise KeyboardInterrupt()
return "not-listed"
ctx = click.Context(configure_group, obj={"base_url": PROXY, "api_key": VALID_KEY})
expected = KeyboardInterrupt if failure == "cancel" else click.ClickException
with pytest.raises(expected):
interactive_configure(
ctx,
pick_targets=lambda: ("claude", "codex"),
pick_model=lambda listed: "auto",
pick_codex_model=pick_codex_model,
)
assert (settings_path.read_bytes(), codex_path.read_bytes()) == before
assert not state_path.exists()
assert not tuple((codex_path.parent / ".litellm").glob("*.json"))
@responses.activate
def test_both_configs_are_preflighted_before_fetching_models_or_writing(
self, paths, codex_path
):
_mock_agent_models()
codex_path.parent.mkdir(parents=True)
codex_path.write_text("[invalid")
ctx = click.Context(configure_group, obj={"base_url": PROXY, "api_key": VALID_KEY})
with pytest.raises(click.ClickException, match="Could not read Codex settings"):
interactive_configure(
ctx,
pick_targets=lambda: ("claude", "codex"),
pick_model=lambda listed: "auto",
pick_codex_model=lambda listed: "auto",
)
assert not paths[0].exists() and not paths[1].exists()
assert codex_path.read_text() == "[invalid"
assert len(responses.calls) == 0
@responses.activate
@pytest.mark.parametrize("targets", [("claude", "codex"), ("codex", "claude")])
@pytest.mark.parametrize("version", [None, "codex-cli 0.128.0\n"])
def test_unsafe_codex_blocks_both_targets_before_requests_or_writes(
self, paths, codex_path, fake_codex_version, targets, version
):
_mock_agent_models()
fake_codex_version(version, 0)
ctx = click.Context(configure_group, obj={"base_url": PROXY, "api_key": VALID_KEY})
with pytest.raises(click.ClickException, match=r"0\.129\.0") as caught:
interactive_configure(
ctx,
pick_targets=lambda: targets,
pick_model=lambda listed: "auto",
pick_codex_model=lambda listed: "auto",
)
assert VALID_KEY not in str(caught.value)
assert len(responses.calls) == 0
assert not paths[0].exists() and not paths[1].exists()
assert not codex_path.exists() and not tuple((codex_path.parent / ".litellm").glob("*.json"))
@responses.activate
def test_claude_only_configuration_does_not_require_codex(
self, runner, paths, codex_path, fake_codex_version
):
_mock_agent_models()
fake_codex_version(None, 0)
result = runner.invoke(
cli, ["configure", "--api-key", VALID_KEY, "--gateway-url", PROXY, "claude", "--model", "auto"]
)
assert result.exit_code == 0, result.output
assert json.loads(paths[0].read_text())["model"] == "claude-router-6175746f"
assert not codex_path.exists()
@responses.activate
def test_codex_only_ignores_claudes_temporary_owner(
self, runner, paths, codex_path, lite_up_backup
):
_mock_agent_models()
result = runner.invoke(
cli, ["configure", "--api-key", VALID_KEY, "--gateway-url", PROXY, "codex", "--model", "auto"]
)
assert result.exit_code == 0, result.output
assert tomlkit.parse(codex_path.read_text())["model"] == "auto"
assert not paths[0].exists() and not paths[1].exists()
assert lite_up_backup.exists()
def test_noninteractive_codex_requires_a_model(self, runner, paths, codex_path):
result = runner.invoke(cli, ["configure", "--api-key", VALID_KEY, "--gateway-url", PROXY, "codex"])
assert result.exit_code != 0 and "Missing option '--model'" in result.output
assert not paths[0].exists() and not codex_path.exists()
@responses.activate
@pytest.mark.parametrize("target", ["claude", "codex"])
@pytest.mark.parametrize(
"option, value, expected",
[
("--api-key", "sk-secret\ninvalid", "must not be blank"),
("--gateway-url", "https://user:sk-secret@proxy.test", "must not contain credentials"),
("--gateway-url", "https://proxy.test?key=sk-secret", "must not include a query"),
("--gateway-url", "file:///sk-secret", "must be a full http:// or https:// URL"),
],
)
def test_invalid_connection_input_never_writes_requests_or_echoes_secrets(
self, runner, paths, codex_path, target, option, value, expected
):
result = runner.invoke(
cli,
[
"configure", "--api-key", VALID_KEY, "--gateway-url", PROXY,
target, "--model", "auto", option, value,
],
)
assert result.exit_code != 0 and expected in result.output
assert "sk-secret" not in result.output and VALID_KEY not in result.output
assert not paths[0].exists() and not codex_path.exists()
assert len(responses.calls) == 0
@responses.activate
@pytest.mark.parametrize("failure", ["rejected", "connection", "response-body"])
def test_gateway_failures_never_echo_the_key(self, runner, paths, codex_path, failure):
if failure == "rejected":
responses.get(f"{PROXY}/v1/models", status=401)
elif failure == "connection":
responses.get(f"{PROXY}/v1/models", body=requests.ConnectionError(VALID_KEY))
else:
responses.get(f"{PROXY}/v1/models", json={"data": VALID_KEY})
result = runner.invoke(
cli, ["configure", "--api-key", VALID_KEY, "--gateway-url", PROXY, "codex", "--model", "auto"]
)
assert result.exit_code != 0 and "Error:" in result.output
assert VALID_KEY not in result.output
assert not paths[0].exists() and not codex_path.exists()
@responses.activate
def test_configure_reconfigure_and_unconfigure_do_not_read_a_stored_login(
self, runner, paths, codex_path, tmp_path, secret_vault_factory, fake_codex_version
):
_mock_agent_models()
token_path = tmp_path / ".litellm" / "token.json"
token_path.parent.mkdir()
token_path.write_text(json.dumps({"base_url": PROXY, "timestamp": time.time()}))
vault = secret_vault_factory(json.dumps({"base_url": PROXY, "key": "sk-login", "jwt_token": ""}))
missing = runner.invoke(
cli, ["configure", "--gateway-url", PROXY, "codex", "--model", "auto"], obj={"secret_vault": vault}
)
assert missing.exit_code != 0 and "needs a long-lived virtual key" in missing.output
assert len(responses.calls) == 0 and not codex_path.exists()
configured = runner.invoke(
cli,
["configure", "--api-key", VALID_KEY, "--gateway-url", PROXY, "codex", "--model", "auto"],
obj={"secret_vault": vault},
)
assert configured.exit_code == 0, configured.output
reconfigured: Final = runner.invoke(
cli, ["reconfigure", "codex", "--model", "auto"], obj={"secret_vault": vault}
)
assert reconfigured.exit_code == 0, reconfigured.output
fake_codex_version(None, 0)
undone = runner.invoke(cli, ["unconfigure", "codex"], obj={"secret_vault": vault})
assert undone.exit_code == 0, undone.output
assert vault.reads == 0 and vault.writes == [] and vault.erases == 0
assert not codex_path.exists() and not paths[0].exists()
assert "Removed" in undone.output
assert "sk-login" not in missing.output + configured.output + reconfigured.output + undone.output
class TestUnconfigureClaude:
@responses.activate
def test_restores_the_original_file_and_removes_the_receipt(self, runner, paths):
_mock_models()
settings_path, state_path = paths
settings_path.parent.mkdir(parents=True)
original = {"theme": "dark", "model": "claude-opus-5"}
settings_path.write_text(json.dumps(original))
assert _configure(runner, "--api-key", VALID_KEY, "--model", "claude-auto").exit_code == 0
result = runner.invoke(cli, ["unconfigure", "claude"])
assert result.exit_code == 0, result.output
assert json.loads(settings_path.read_text()) == original
assert not state_path.exists()
assert "Restored in" in result.output and "model" in result.output
assert "ANTHROPIC_API_KEY" not in result.output, "a key that never existed was not restored"
@responses.activate
def test_a_file_only_configure_created_is_reported_removed_not_restored(self, runner, paths):
_mock_models()
settings_path, _ = paths
assert _configure(runner, "--api-key", VALID_KEY).exit_code == 0
result = runner.invoke(cli, ["unconfigure", "claude"])
assert result.exit_code == 0, result.output
assert not settings_path.exists()
assert "No settings file remains" in result.output and "Restored" not in result.output
@responses.activate
def test_says_when_nothing_was_still_ours_and_names_what_it_kept(self, runner, paths):
_mock_models()
settings_path, _ = paths
settings_path.parent.mkdir(parents=True)
settings_path.write_text(json.dumps({"theme": "dark"}))
assert _configure(runner, "--api-key", VALID_KEY, "--model", "claude-auto").exit_code == 0
edited = json.loads(settings_path.read_text())
edited["env"] = {key: f"{value}-edited" for key, value in edited["env"].items()}
edited["model"] = "mine"
edited["statusLine"] = {"type": "command", "command": "~/.claude/my-statusline.sh"}
settings_path.write_text(json.dumps(edited))
result = runner.invoke(cli, ["unconfigure", "claude"])
assert result.exit_code == 0, result.output
assert "Nothing in" in result.output and "was still ours to restore" in result.output
assert "Left as you changed them since:" in result.output and "model" in result.output
assert "statusLine" in result.output
@responses.activate
def test_names_the_server_a_withheld_credential_was_captured_with_and_keeps_the_receipt(self, runner, paths):
_mock_models()
settings_path, state_path = paths
settings_path.parent.mkdir(parents=True)
settings_path.write_text(
json.dumps({"env": {"ANTHROPIC_BASE_URL": "https://api.anthropic.com", "ANTHROPIC_API_KEY": "sk-ant"}})
)
assert _configure(runner, "--api-key", VALID_KEY).exit_code == 0
edited = json.loads(settings_path.read_text())
edited["env"]["ANTHROPIC_BASE_URL"] = "http://other-proxy:4000"
settings_path.write_text(json.dumps(edited))
result = runner.invoke(cli, ["unconfigure", "claude"])
assert result.exit_code == 0, result.output
assert "env.ANTHROPIC_API_KEY (captured with https://api.anthropic.com)" in result.output
assert str(state_path) in result.output and state_path.exists()
assert "sk-ant" not in result.output
def test_disconnected_unconfigure_does_not_touch_lite_up_backup(
self, runner: CliRunner, paths: tuple[Path, Path], lite_up_backup: Path
) -> None:
result: Final = runner.invoke(cli, ["unconfigure", "claude"])
assert result.exit_code == 0, result.output
assert "nothing to undo" in result.output
assert "Agent settings were not changed" in result.output
assert lite_up_backup.read_text() == "{}"
@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
def test_without_a_receipt_it_reports_nothing_to_undo(self, runner, paths):
result = runner.invoke(cli, ["unconfigure", "claude"])
assert result.exit_code == 0, result.output
assert "nothing to undo" in result.output.lower()
class TestClaudeCodeView:
VIEW = {"anthropic-version": "2023-06-01", "x-gateway-client": "claude-code"}
def _mock(self, rows):
responses.get(
f"{PROXY}/v1/models",
json={"data": rows},
match=[responses.matchers.header_matcher({"Authorization": f"Bearer {VALID_KEY}", **self.VIEW})],
)
@responses.activate
@pytest.mark.parametrize(
"model, pinned",
[
("literal-claude-router-source", "emitted-literal"),
("marked-sibling", "emitted-marked[1m]"),
("emitted-collision", "emitted-source-priority"),
("emitted-only", "emitted-only"),
],
)
def test_pins_source_identity_before_emitted_id(self, runner, paths, model, pinned):
self._mock(
[
{"id": "emitted-collision", "source_model": "other-source"},
{"id": "emitted-source-priority", "source_model": "emitted-collision"},
{"id": "emitted-marked[1m]", "source_model": "marked-sibling"},
{"id": "emitted-literal", "source_model": "literal-claude-router-source"},
{"id": "emitted-only"},
]
)
settings_path, _ = paths
result = _configure(runner, "--api-key", VALID_KEY, "--model", model)
assert result.exit_code == 0, result.output
assert json.loads(settings_path.read_text())["model"] == pinned
assert f"Starting model: {pinned}" in result.output
assert len(responses.calls) == 1
@responses.activate
def test_refuses_unknown_short_suffix(self, runner, paths):
self._mock([{"id": "emitted-router-source", "source_model": "literal-router-source"}])
settings_path, _ = paths
result = _configure(runner, "--api-key", VALID_KEY, "--model", "source")
assert result.exit_code != 0
assert "'source' is not served" in result.output
assert not settings_path.exists()
@responses.activate
def test_interactive_picker_uses_source_names(self, paths):
self._mock([{"id": "emitted", "source_model": "source"}])
settings_path, _ = paths
asked = {}
ctx = click.Context(
configure_group, obj={"base_url": PROXY, "api_key": VALID_KEY, "api_key_from_token_file": False}
)
def pick_model(listed):
asked["listed"] = tuple(listed)
return "source"
interactive_configure(ctx, pick_targets=lambda: ("claude",), pick_model=pick_model)
assert asked["listed"] == ("source",)
assert json.loads(settings_path.read_text())["model"] == "emitted"
@responses.activate
def test_counts_what_an_older_proxy_lets_the_picker_show(self, runner, paths):
_mock_models()
result = _configure(runner, "--api-key", VALID_KEY)
assert result.exit_code == 0, result.output
assert "/model will list 1 of the proxy's 2 models: Claude Code shows only ids containing" in result.output
def _saved_profile_path(target: Literal["claude", "codex"], settings_path: Path) -> Path:
from litellm.proxy.client.cli.commands.configure_profiles import setup_profile_path
return setup_profile_path(target, settings_path)
def _configure_saved_agent(runner: CliRunner, target: Literal["claude", "codex"]) -> None:
result: Final = runner.invoke(
cli,
["configure", "--gateway-url", PROXY, "--api-key", VALID_KEY, target, "--model", "auto"],
)
assert result.exit_code == 0, result.output
def _agent_document(settings_path: Path) -> dict[str, JsonValue]:
adapter: Final = TypeAdapter(dict[str, JsonValue])
if settings_path.suffix == ".json":
return adapter.validate_json(settings_path.read_text())
return adapter.validate_python(tomlkit.parse(settings_path.read_text()).unwrap())
def _prompt_answer(answer: str | tuple[str, ...]) -> SimpleNamespace:
def execute() -> str | tuple[str, ...]:
return answer
return SimpleNamespace(execute=execute)
class TestSavedAgentSetup:
@responses.activate
@pytest.mark.parametrize("target", ["claude", "codex"])
@pytest.mark.parametrize("resume", [("configure",), None], ids=["all", "target"])
def test_disconnect_then_configure_reuses_connection_and_model_without_prompts(
self,
runner: CliRunner,
paths: tuple[Path, Path],
codex_path: Path,
target: Literal["claude", "codex"],
resume: tuple[str, ...] | None,
) -> None:
_mock_agent_models()
settings_path: Final = paths[0] if target == "claude" else codex_path
_configure_saved_agent(runner, target)
configured: Final = _agent_document(settings_path)
undone: Final = runner.invoke(cli, ["unconfigure", target])
assert undone.exit_code == 0, undone.output
assert not settings_path.exists()
resumed: Final = runner.invoke(cli, list(resume or ("configure", target)))
assert resumed.exit_code == 0, resumed.output
assert _agent_document(settings_path) == configured
assert "lite configure" in undone.output and "saved" in undone.output.lower()
repeated: Final = runner.invoke(cli, ["configure", target])
assert repeated.exit_code == 0, repeated.output
assert _agent_document(settings_path) == configured
restored: Final = runner.invoke(cli, ["unconfigure", target])
assert restored.exit_code == 0, restored.output
assert not settings_path.exists()
assert VALID_KEY not in resumed.output + repeated.output + restored.output
@responses.activate
def test_resume_both_agents_captures_the_settings_changed_while_disconnected(
self, runner: CliRunner, paths: tuple[Path, Path], codex_path: Path
) -> None:
_mock_agent_models()
_configure_saved_agent(runner, "claude")
codex_url: Final = "https://codex-gateway.test/prefix"
responses.get(
f"{codex_url}/v1/models",
json={"data": [{"id": "auto"}]},
match=[responses.matchers.header_matcher({"Authorization": "Bearer sk-codex"})],
)
codex_setup: Final = runner.invoke(
cli,
["configure", "codex", "--gateway-url", codex_url, "--api-key", "sk-codex", "--model", "auto"],
)
assert codex_setup.exit_code == 0, codex_setup.output
undone: Final = runner.invoke(cli, ["unconfigure"])
assert undone.exit_code == 0, undone.output
paths[0].write_text('{"theme": "light", "model": "personal-claude"}')
codex_path.write_text('model = "personal-codex"\napproval_policy = "on-request"\n')
resumed: Final = runner.invoke(cli, ["configure"])
assert resumed.exit_code == 0, resumed.output
assert json.loads(paths[0].read_text())["model"] == "claude-router-6175746f"
assert tomlkit.parse(codex_path.read_text())["model"] == "auto"
assert responses.calls[-1].request.url == f"{codex_url}/v1/models"
restored: Final = runner.invoke(cli, ["unconfigure"])
assert restored.exit_code == 0, restored.output
assert json.loads(paths[0].read_text()) == {"theme": "light", "model": "personal-claude"}
assert tomlkit.parse(codex_path.read_text()) == {
"model": "personal-codex", "approval_policy": "on-request"
}
@responses.activate
@pytest.mark.parametrize("disconnected", [False, True], ids=["active", "disconnected"])
@pytest.mark.parametrize(
"forget, forgotten",
[
(("unconfigure", "--forget", "claude"), ("claude",)),
(("unconfigure", "codex", "--forget"), ("codex",)),
(("unconfigure", "--forget"), ("claude", "codex")),
],
ids=["group-option-target", "leaf-option", "all"],
)
def test_forget_removes_only_selected_saved_setups_even_after_disconnect(
self,
runner: CliRunner,
paths: tuple[Path, Path],
codex_path: Path,
disconnected: bool,
forget: tuple[str, ...],
forgotten: tuple[str, ...],
) -> None:
_mock_agent_models()
_configure_saved_agent(runner, "claude")
_configure_saved_agent(runner, "codex")
if disconnected:
undone: Final = runner.invoke(cli, ["unconfigure"])
assert undone.exit_code == 0, undone.output
result: Final = runner.invoke(cli, list(forget))
assert result.exit_code == 0, result.output
for target, settings_path in (("claude", paths[0]), ("codex", codex_path)):
assert _saved_profile_path(target, settings_path).exists() == (target not in forgotten)
resumed: Final = runner.invoke(cli, ["configure", target])
assert (resumed.exit_code == 0) == (target not in forgotten), resumed.output
assert settings_path.exists() == (target not in forgotten)
@responses.activate
@pytest.mark.parametrize("target", ["claude", "codex"])
@pytest.mark.parametrize("source", ["leaf", "global", "environment"])
def test_saved_key_never_follows_a_gateway_override_without_a_replacement(
self,
runner: CliRunner,
paths: tuple[Path, Path],
codex_path: Path,
monkeypatch: pytest.MonkeyPatch,
target: Literal["claude", "codex"],
source: str,
) -> None:
_mock_agent_models()
_configure_saved_agent(runner, target)
undone: Final = runner.invoke(cli, ["unconfigure", target])
assert undone.exit_code == 0, undone.output
replacement_url: Final = "https://replacement.test/gateway"
if source == "environment":
monkeypatch.setenv("LITELLM_PROXY_URL", replacement_url)
args: Final = (
["--base-url", replacement_url, "configure", target]
if source == "global"
else ["configure", target, "--gateway-url", replacement_url]
if source == "leaf"
else ["configure", target]
)
refused: Final = runner.invoke(cli, args)
assert refused.exit_code != 0, refused.output
assert "--api-key" in refused.output and VALID_KEY not in refused.output
assert len(responses.calls) == 1
assert not paths[0].exists() and not codex_path.exists()
responses.get(
f"{replacement_url}/v1/models",
json={"data": [{"id": "auto"}]},
match=[responses.matchers.header_matcher({"Authorization": "Bearer sk-replacement"})],
)
replaced: Final = runner.invoke(cli, [*args, "--api-key", "sk-replacement"])
assert replaced.exit_code == 0, replaced.output
assert len(responses.calls) == 2
assert responses.calls[-1].request.url == f"{replacement_url}/v1/models"
assert VALID_KEY not in replaced.output and "sk-replacement" not in replaced.output
@responses.activate
@pytest.mark.parametrize("target", ["claude", "codex"])
def test_saved_setup_is_private_and_scoped_to_the_resolved_agent_home(
self,
runner: CliRunner,
paths: tuple[Path, Path],
codex_path: Path,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
target: Literal["claude", "codex"],
) -> None:
_mock_agent_models()
_configure_saved_agent(runner, target)
settings_path: Final = paths[0] if target == "claude" else codex_path
profile_path: Final = _saved_profile_path(target, settings_path)
assert stat.S_IMODE(profile_path.stat().st_mode) == 0o600
assert stat.S_IMODE(profile_path.parent.stat().st_mode) & 0o077 == 0
undone: Final = runner.invoke(cli, ["unconfigure", target])
assert undone.exit_code == 0, undone.output
alternate_home: Final = tmp_path / f"other-{target}"
alternate_settings: Final = alternate_home / settings_path.name
environment: Final = "CLAUDE_CONFIG_DIR" if target == "claude" else "CODEX_HOME"
monkeypatch.setenv(environment, str(alternate_home))
missing: Final = runner.invoke(cli, ["configure", target])
assert missing.exit_code != 0, missing.output
assert not alternate_settings.exists() and len(responses.calls) == 1
assert profile_path.exists()
stored_url: Final = runner.invoke(cli, ["config", "set", "base_url", "https://other-default.test"])
assert stored_url.exit_code == 0, stored_url.output
monkeypatch.setenv(environment, str(settings_path.parent))
resumed: Final = runner.invoke(cli, ["configure", target])
assert resumed.exit_code == 0, resumed.output
assert settings_path.exists() and not alternate_settings.exists()
@responses.activate
@pytest.mark.parametrize("target", ["claude", "codex"])
@pytest.mark.parametrize("fault", ["json", "version", "target", "path"])
def test_invalid_saved_setup_fails_without_network_or_secret_output_and_can_be_forgotten(
self,
runner: CliRunner,
paths: tuple[Path, Path],
codex_path: Path,
target: Literal["claude", "codex"],
fault: str,
) -> None:
_mock_agent_models()
_configure_saved_agent(runner, target)
settings_path: Final = paths[0] if target == "claude" else codex_path
profile_path: Final = _saved_profile_path(target, settings_path)
profile: Final = TypeAdapter(dict[str, JsonValue]).validate_json(profile_path.read_text())
corrupted: Final = (
"{ " + VALID_KEY
if fault == "json"
else json.dumps({**profile, "version": 999})
if fault == "version"
else json.dumps({**profile, "target": "codex" if target == "claude" else "claude"})
if fault == "target"
else json.dumps({**profile, "settings_path": str(settings_path.parent / "another-file")})
)
undone: Final = runner.invoke(cli, ["unconfigure", target])
assert undone.exit_code == 0, undone.output
profile_path.write_text(corrupted)
failed: Final = runner.invoke(cli, ["configure", target])
assert failed.exit_code != 0, failed.output
assert "saved" in failed.output.lower() and "--forget" in failed.output
assert VALID_KEY not in failed.output
assert not settings_path.exists() and len(responses.calls) == 1
forgotten: Final = runner.invoke(cli, ["unconfigure", "--forget", target])
assert forgotten.exit_code == 0, forgotten.output
assert not profile_path.exists() and not settings_path.exists()
@responses.activate
@pytest.mark.parametrize("target", ["claude", "codex"])
def test_reconfigure_prefills_saved_choices_and_changes_only_the_selected_agent(
self,
runner: CliRunner,
paths: tuple[Path, Path],
codex_path: Path,
monkeypatch: pytest.MonkeyPatch,
target: Literal["claude", "codex"],
) -> None:
_mock_agent_models()
_configure_saved_agent(runner, "claude")
_configure_saved_agent(runner, "codex")
untouched: Final = codex_path if target == "claude" else paths[0]
before: Final = untouched.read_bytes()
responses.replace(
responses.GET, f"{PROXY}/v1/models", json={"data": [{"id": "auto"}, {"id": "replacement"}]}
)
def checkbox(**kwargs: object) -> SimpleNamespace:
choices: Final = kwargs["choices"]
assert isinstance(choices, list) and len(choices) == 2
for choice in choices:
assert isinstance(choice, Choice) and choice.enabled
return _prompt_answer((target,))
def fuzzy(**kwargs: object) -> SimpleNamespace:
assert kwargs["default"] == "auto"
return _prompt_answer("replacement")
monkeypatch.setattr(configure_module.inquirer, "checkbox", checkbox)
monkeypatch.setattr(configure_module.inquirer, "fuzzy", fuzzy)
changed: Final = runner.invoke(cli, ["reconfigure"], input=_TerminalInput(b"\n\n"))
assert changed.exit_code == 0, changed.output
assert PROXY in changed.output and VALID_KEY not in changed.output
assert untouched.read_bytes() == before
undone: Final = runner.invoke(cli, ["unconfigure", target])
assert undone.exit_code == 0, undone.output
resumed: Final = runner.invoke(cli, ["configure", target])
assert resumed.exit_code == 0, resumed.output
if target == "claude":
assert json.loads(paths[0].read_text())["model"] == "replacement"
else:
assert tomlkit.parse(codex_path.read_text())["model"] == "replacement"
assert untouched.read_bytes() == before
@responses.activate
def test_reconfigure_cancel_preserves_every_agents_settings_and_saved_choices(
self,
runner: CliRunner,
paths: tuple[Path, Path],
codex_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_mock_agent_models()
_configure_saved_agent(runner, "claude")
_configure_saved_agent(runner, "codex")
files: Final = (
paths[0], codex_path, _saved_profile_path("claude", paths[0]), _saved_profile_path("codex", codex_path)
)
before: Final = tuple(path.read_bytes() for path in files)
def checkbox(**kwargs: object) -> SimpleNamespace:
return _prompt_answer(("claude", "codex"))
def fuzzy(**kwargs: object) -> SimpleNamespace:
assert tuple(path.read_bytes() for path in files) == before
if "Codex" in str(kwargs["message"]):
raise KeyboardInterrupt()
return _prompt_answer("Keep Claude Code's own default")
monkeypatch.setattr(configure_module.inquirer, "checkbox", checkbox)
monkeypatch.setattr(configure_module.inquirer, "fuzzy", fuzzy)
cancelled: Final = runner.invoke(cli, ["reconfigure"], input=_TerminalInput(b"\n\n\n\n"))
assert cancelled.exit_code != 0, cancelled.output
assert "Aborted" in cancelled.output
assert tuple(path.read_bytes() for path in files) == before
@responses.activate
def test_explicit_default_model_unpins_claude_and_remains_the_saved_choice(
self, runner: CliRunner, paths: tuple[Path, Path]
) -> None:
_mock_agent_models()
_configure_saved_agent(runner, "claude")
changed: Final = runner.invoke(cli, ["reconfigure", "claude", "--default-model"])
assert changed.exit_code == 0, changed.output
assert "model" not in json.loads(paths[0].read_text())
undone: Final = runner.invoke(cli, ["unconfigure", "claude"])
assert undone.exit_code == 0, undone.output
resumed: Final = runner.invoke(cli, ["configure", "claude"])
assert resumed.exit_code == 0, resumed.output
assert "model" not in json.loads(paths[0].read_text())
@responses.activate
@pytest.mark.parametrize("target", ["claude", "codex"])
@pytest.mark.parametrize("fault", ["key", "model"])
def test_terminal_resume_repairs_only_the_rejected_saved_choice(
self,
runner: CliRunner,
paths: tuple[Path, Path],
codex_path: Path,
monkeypatch: pytest.MonkeyPatch,
target: Literal["claude", "codex"],
fault: str,
) -> None:
_mock_agent_models()
_configure_saved_agent(runner, target)
settings_path: Final = paths[0] if target == "claude" else codex_path
profile_path: Final = _saved_profile_path(target, settings_path)
before: Final = profile_path.read_bytes()
undone: Final = runner.invoke(cli, ["unconfigure", target])
assert undone.exit_code == 0, undone.output
responses.reset()
if fault == "key":
responses.get(
f"{PROXY}/v1/models", status=401,
match=[responses.matchers.header_matcher({"Authorization": f"Bearer {VALID_KEY}"})],
)
responses.get(
f"{PROXY}/v1/models",
json={"data": [{"id": "auto" if fault == "key" else "replacement"}]},
match=[responses.matchers.header_matcher({
"Authorization": "Bearer sk-repaired" if fault == "key" else f"Bearer {VALID_KEY}"
})],
)
failed: Final = runner.invoke(cli, ["configure", target])
assert failed.exit_code != 0, failed.output
assert not settings_path.exists() and profile_path.read_bytes() == before
assert len(responses.calls) == 1
def checkbox(**kwargs: object) -> SimpleNamespace:
raise AssertionError("Saved resume must not ask which agents to configure")
def fuzzy(**kwargs: object) -> SimpleNamespace:
assert fault == "model", "A rejected key must not discard the saved model"
return _prompt_answer("replacement")
monkeypatch.setattr(configure_module.inquirer, "checkbox", checkbox)
monkeypatch.setattr(configure_module.inquirer, "fuzzy", fuzzy)
resumed: Final = runner.invoke(
cli, ["configure"], input=_TerminalInput(b"sk-repaired\n" if fault == "key" else b"")
)
assert resumed.exit_code == 0, resumed.output
assert "gateway URL" not in resumed.output
assert VALID_KEY not in resumed.output and "sk-repaired" not in resumed.output
assert settings_path.exists()
saved: Final = TypeAdapter(dict[str, JsonValue]).validate_json(profile_path.read_text())
assert saved["api_key"] == ("sk-repaired" if fault == "key" else VALID_KEY)
assert saved["model"] == ("auto" if fault == "key" else "replacement")
@responses.activate
@pytest.mark.parametrize("target", ["claude", "codex"])
@pytest.mark.parametrize("lost_receipt", [False, True], ids=["malformed-settings", "lost-receipt"])
def test_forget_without_receipt_preserves_agent_settings_and_reports_unknown_connection(
self,
runner: CliRunner,
paths: tuple[Path, Path],
codex_path: Path,
target: Literal["claude", "codex"],
lost_receipt: bool,
) -> None:
from litellm.proxy.client.cli.commands.configure_profiles import receipt_path_for
_mock_agent_models()
_configure_saved_agent(runner, target)
settings_path: Final = paths[0] if target == "claude" else codex_path
profile_path: Final = _saved_profile_path(target, settings_path)
if lost_receipt:
receipt_path_for(target, settings_path).unlink()
else:
undone: Final = runner.invoke(cli, ["unconfigure", target])
assert undone.exit_code == 0, undone.output
settings_path.write_text("[invalid")
before: Final = settings_path.read_bytes()
forgotten: Final = runner.invoke(cli, ["unconfigure", target, "--forget"])
assert forgotten.exit_code == 0, forgotten.output
assert not profile_path.exists()
assert settings_path.read_bytes() == before
assert "Cannot confirm disconnection" in forgotten.output
assert "gateway connection and key manually" in forgotten.output
assert str(settings_path) in forgotten.output
assert "already disconnected" not in forgotten.output and VALID_KEY not in forgotten.output
assert len(responses.calls) == 1
@responses.activate
@pytest.mark.parametrize("target", ["claude", "codex"])
@pytest.mark.parametrize("failure", ["stage_private_json", "commit_staged_json", "apply"])
def test_failed_setup_write_preserves_saved_intent_and_plain_configure_retries_it(
self,
runner: CliRunner,
paths: tuple[Path, Path],
codex_path: Path,
monkeypatch: pytest.MonkeyPatch,
target: Literal["claude", "codex"],
failure: str,
) -> None:
from litellm.proxy.client.cli.commands import configure_profiles, configure_setup
_mock_agent_models()
_configure_saved_agent(runner, target)
settings_path: Final = paths[0] if target == "claude" else codex_path
profile_path: Final = _saved_profile_path(target, settings_path)
receipt_path: Final = configure_profiles.receipt_path_for(target, settings_path)
before: Final = (settings_path.read_bytes(), profile_path.read_bytes(), receipt_path.read_bytes())
original_settings: Final = _agent_document(settings_path)
original_profile: Final = TypeAdapter(dict[str, JsonValue]).validate_json(profile_path.read_text())
replacement_url: Final = "https://replacement.test/gateway"
replacement_key: Final = "sk-replacement"
responses.get(
f"{replacement_url}/v1/models",
json={"data": [{"id": "replacement"}]},
match=[responses.matchers.header_matcher({"Authorization": f"Bearer {replacement_key}"})],
)
def fail_write(*args: object, **kwargs: object) -> str:
raise OSError(f"simulated disk error {VALID_KEY}")
def fail_apply(*args: object, **kwargs: object) -> None:
error: Final = (
configure_setup.ClaudeSettingsError if target == "claude" else configure_setup.CodexSettingsError
)
raise error("simulated agent settings write failure")
with monkeypatch.context() as patch:
if failure == "apply":
patch.setattr(configure_setup, f"configure_{target}_settings", fail_apply)
else:
patch.setattr(configure_profiles, failure, fail_write)
failed: Final = runner.invoke(
cli,
[
"reconfigure", target, "--gateway-url", replacement_url,
"--api-key", replacement_key, "--model", "replacement",
],
)
assert failed.exit_code != 0, failed.output
assert VALID_KEY not in failed.output and replacement_key not in failed.output
assert (settings_path.read_bytes(), receipt_path.read_bytes()) == (before[0], before[2])
saved: Final = TypeAdapter(dict[str, JsonValue]).validate_json(profile_path.read_text())
if failure == "apply":
assert saved == {
**original_profile, "base_url": replacement_url, "api_key": replacement_key, "model": "replacement"
}
assert "simulated agent settings write failure" in failed.output
assert "setup was saved" in failed.output and f"lite configure {target}" in failed.output
else:
assert "could not save" in failed.output.lower()
assert profile_path.read_bytes() == before[1]
retried: Final = runner.invoke(cli, ["configure", target])
assert retried.exit_code == 0, retried.output
written: Final = _agent_document(settings_path)
if failure != "apply":
assert written == original_settings
elif target == "claude":
environment: Final = written["env"]
assert isinstance(environment, dict)
assert (environment["ANTHROPIC_BASE_URL"], environment["ANTHROPIC_AUTH_TOKEN"], written["model"]) == (
replacement_url, replacement_key, "replacement"
)
else:
providers: Final = written["model_providers"]
assert isinstance(providers, dict)
provider: Final = providers["litellm"]
assert isinstance(provider, dict)
headers: Final = provider["http_headers"]
assert isinstance(headers, dict)
assert (provider["base_url"], headers["Authorization"], written["model"]) == (
f"{replacement_url}/v1", f"Bearer {replacement_key}", "replacement"
)
@responses.activate
def test_contended_setup_lock_blocks_requests_and_agent_writes(
self, runner: CliRunner, paths: tuple[Path, Path]
) -> None:
from litellm.proxy.client.cli.commands.configure_profiles import setup_locks
_mock_agent_models()
with setup_locks(("claude",)):
blocked: Final = runner.invoke(
cli,
["configure", "claude", "--gateway-url", PROXY, "--api-key", VALID_KEY, "--model", "auto"],
)
assert blocked.exit_code != 0, blocked.output
assert "Could not lock agent setup" in blocked.output
assert len(responses.calls) == 0
assert not paths[0].exists() and not paths[1].exists()
assert not _saved_profile_path("claude", paths[0]).exists()