mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-02 02:11:58 +00:00
* 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>
341 lines
16 KiB
Python
341 lines
16 KiB
Python
import json
|
|
import stat
|
|
from collections.abc import Callable
|
|
from pathlib import Path
|
|
from typing import Final
|
|
|
|
import pytest
|
|
import tomlkit
|
|
|
|
from litellm.litellm_core_utils.private_json import commit_staged_json
|
|
from litellm.proxy.client.cli.commands import codex_settings as codex_settings_module
|
|
from litellm.proxy.client.cli.commands.agents import (
|
|
agent_launch_args,
|
|
codex_config_path,
|
|
)
|
|
from litellm.proxy.client.cli.commands.codex_settings import (
|
|
CodexSettingsError,
|
|
_snapshot,
|
|
_with,
|
|
codex_configure_state_path,
|
|
configure_codex_settings,
|
|
preflight_codex_settings,
|
|
unconfigure_codex_settings,
|
|
)
|
|
|
|
GATEWAY: Final = "https://gateway.example.com/team"
|
|
KEY: Final = "sk-test-new-gateway-key"
|
|
MODEL: Final = "gateway-codex-model"
|
|
|
|
|
|
@pytest.mark.parametrize("path,existing,first_value,second_value", [
|
|
("model", 'model = "original" # starting model\n', 'value = "first"\n', 'value = "second"\n'),
|
|
("model_providers.litellm", '', '[value]\nname = "first"\n', '[value]\nname = "second"\n'),
|
|
("model_providers.litellm", '[model_providers.litellm]\nname = "original" # provider\n',
|
|
'[value]\nname = "first"\n', '[value]\nname = "second"\n'),
|
|
])
|
|
def test_toml_transitions_leave_source_and_independent_results_unchanged(
|
|
path: str, existing: str, first_value: str, second_value: str
|
|
) -> None:
|
|
source: Final = tomlkit.parse('# user settings\n' + existing + '[profiles.work]\nmodel = "keep" # profile\n')
|
|
original_bytes: Final = source.as_string().encode()
|
|
first: Final = _with(source, path, first_value)
|
|
first_bytes: Final = first.as_string().encode()
|
|
second: Final = _with(source, path, second_value)
|
|
second_bytes: Final = second.as_string().encode()
|
|
removed: Final = _with(first, path, None)
|
|
first_snapshot: Final = _snapshot(first, path)
|
|
second_snapshot: Final = _snapshot(second, path)
|
|
assert source.as_string().encode() == original_bytes
|
|
assert first.as_string().encode() == first_bytes
|
|
assert second.as_string().encode() == second_bytes
|
|
assert first_snapshot is not None and tomlkit.parse(first_snapshot) == tomlkit.parse(first_value)
|
|
assert second_snapshot is not None and tomlkit.parse(second_snapshot) == tomlkit.parse(second_value)
|
|
assert _snapshot(removed, path) is None
|
|
for result in (first, second, removed):
|
|
assert result["profiles"] == source["profiles"]
|
|
assert '# user settings' in result.as_string()
|
|
assert '# profile' in result.as_string()
|
|
|
|
|
|
def test_persistent_provider_is_complete_and_preserves_unrelated_toml(tmp_path: Path) -> None:
|
|
path: Final = tmp_path / "config.toml"
|
|
path.write_text(
|
|
'# user settings\nmodel = "old-model" # starting model\n'
|
|
'model_provider = "openai"\nprofile = "work"\n'
|
|
'[model_providers.litellm]\nname = "old gateway"\n'
|
|
'base_url = "https://old.example.com/v1"\nenv_key = "OLD_KEY"\n'
|
|
'experimental_bearer_token = "sk-old"\nrequires_openai_auth = true\n'
|
|
'[model_providers.litellm.auth]\ncommand = "old-token-helper"\n'
|
|
'[model_providers.other]\nname = "Keep me" # other provider\n'
|
|
'[profiles.work]\nmodel = "work-model"\n'
|
|
'[[hooks.Stop]]\nhooks = [{type = "command", command = "echo done"}]\n'
|
|
)
|
|
original: Final = tomlkit.parse(path.read_text())
|
|
configure_codex_settings(GATEWAY, KEY, MODEL, path)
|
|
configured: Final = tomlkit.parse(path.read_text())
|
|
assert configured["model"] == MODEL
|
|
assert configured["model_provider"] == "litellm"
|
|
assert "profile" not in configured
|
|
assert configured["model_providers"]["litellm"] == {
|
|
"name": "LiteLLM proxy",
|
|
"base_url": GATEWAY + "/v1",
|
|
"wire_api": "responses",
|
|
"supports_websockets": False,
|
|
"requires_openai_auth": False,
|
|
"http_headers": {"Authorization": "Bearer " + KEY},
|
|
}
|
|
assert configured["model_providers"]["other"] == original["model_providers"]["other"]
|
|
assert configured["profiles"] == original["profiles"]
|
|
assert configured["hooks"] == original["hooks"]
|
|
assert "# user settings" in path.read_text()
|
|
assert "# other provider" in path.read_text()
|
|
assert KEY not in codex_configure_state_path(path).read_text()
|
|
assert stat.S_IMODE(path.stat().st_mode) == 0o600
|
|
assert stat.S_IMODE(codex_configure_state_path(path).stat().st_mode) == 0o600
|
|
assert stat.S_IMODE(codex_configure_state_path(path).parent.stat().st_mode) == 0o700
|
|
outcome: Final = unconfigure_codex_settings(path)
|
|
assert not outcome.kept and not outcome.file_removed
|
|
assert tomlkit.parse(path.read_text()) == original
|
|
assert "# starting model" in path.read_text()
|
|
assert "# other provider" in path.read_text()
|
|
assert not codex_configure_state_path(path).exists()
|
|
|
|
|
|
@pytest.mark.parametrize("original", [None, "", "# my preferences\n", '[model_providers]\n'])
|
|
def test_undo_distinguishes_missing_empty_and_existing_tables(tmp_path: Path, original: str | None) -> None:
|
|
path: Final = tmp_path / "config.toml"
|
|
if original is not None:
|
|
path.write_text(original)
|
|
configure_codex_settings(GATEWAY, KEY, MODEL, path)
|
|
outcome: Final = unconfigure_codex_settings(path)
|
|
assert outcome.file_removed == (original is None)
|
|
assert path.exists() == (original is not None)
|
|
if original is not None:
|
|
assert tomlkit.parse(path.read_text()) == tomlkit.parse(original)
|
|
assert original.strip() in path.read_text()
|
|
|
|
|
|
def test_repeat_setup_preserves_original_and_undo_keeps_user_edits(tmp_path: Path) -> None:
|
|
path: Final = tmp_path / "config.toml"
|
|
path.write_text('model = "original"\nmodel_provider = "openai"\n')
|
|
configure_codex_settings(GATEWAY, KEY, MODEL, path)
|
|
configure_codex_settings(GATEWAY + "/second", "sk-second", "second-model", path)
|
|
assert tomlkit.parse(path.read_text())["model"] == "second-model"
|
|
path.write_text(path.read_text().replace('model = "second-model"', 'model = "my-custom-model"'))
|
|
outcome: Final = unconfigure_codex_settings(path)
|
|
assert outcome.kept == ("model",)
|
|
assert tomlkit.parse(path.read_text()) == {"model": "my-custom-model", "model_provider": "openai"}
|
|
|
|
|
|
def test_repeat_setup_restores_the_user_value_it_displaced(tmp_path: Path) -> None:
|
|
path: Final = tmp_path / "config.toml"
|
|
path.write_text('model = "original"\n')
|
|
configure_codex_settings(GATEWAY, KEY, MODEL, path)
|
|
path.write_text(path.read_text().replace('model = "gateway-codex-model"', 'model = "user-edited"'))
|
|
configure_codex_settings(GATEWAY, "sk-rotated", "third-model", path)
|
|
unconfigure_codex_settings(path)
|
|
assert tomlkit.parse(path.read_text()) == {"model": "user-edited"}
|
|
|
|
|
|
def test_undo_keeps_provider_credentials_and_endpoint_together(tmp_path: Path) -> None:
|
|
path: Final = tmp_path / "config.toml"
|
|
path.write_text('[model_providers.litellm]\nbase_url = "https://old.example.com/v1"\n'
|
|
'http_headers = { Authorization = "Bearer old-key" }\n')
|
|
configure_codex_settings(GATEWAY, KEY, MODEL, path)
|
|
path.write_text(path.read_text().replace(GATEWAY, "https://user.example.com"))
|
|
outcome: Final = unconfigure_codex_settings(path)
|
|
provider: Final = tomlkit.parse(path.read_text())["model_providers"]["litellm"]
|
|
assert outcome.kept == ("model_providers.litellm",)
|
|
assert provider["base_url"] == "https://user.example.com/v1"
|
|
assert provider["http_headers"] == {"Authorization": "Bearer " + KEY}
|
|
assert "old-key" not in path.read_text()
|
|
|
|
|
|
def test_user_deleted_config_is_not_recreated_to_restore_profile(tmp_path: Path) -> None:
|
|
path: Final = tmp_path / "config.toml"
|
|
path.write_text('profile = "old-profile"\n')
|
|
configure_codex_settings(GATEWAY, KEY, MODEL, path)
|
|
path.unlink()
|
|
outcome: Final = unconfigure_codex_settings(path)
|
|
assert outcome.file_removed and outcome.restored == ()
|
|
assert not path.exists()
|
|
|
|
|
|
def test_user_comment_in_new_config_survives_undo(tmp_path: Path) -> None:
|
|
path: Final = tmp_path / "config.toml"
|
|
configure_codex_settings(GATEWAY, KEY, MODEL, path)
|
|
path.write_text("# keep my note\n" + path.read_text())
|
|
assert not unconfigure_codex_settings(path).file_removed
|
|
assert "# keep my note" in path.read_text()
|
|
|
|
|
|
def test_code_home_and_symlink_aliases_share_receipt_and_write_target(tmp_path: Path) -> None:
|
|
target: Final = tmp_path / "real-config.toml"
|
|
target.write_text('model = "old"\n')
|
|
custom_home: Final = tmp_path / "codex-home"
|
|
custom_home.mkdir()
|
|
alias: Final = codex_config_path({"CODEX_HOME": str(custom_home)})
|
|
alias.symlink_to(target)
|
|
configure_codex_settings(GATEWAY, KEY, MODEL, alias)
|
|
assert alias.is_symlink()
|
|
assert codex_configure_state_path(alias) == codex_configure_state_path(target)
|
|
assert tomlkit.parse(target.read_text())["model"] == MODEL
|
|
unconfigure_codex_settings(target)
|
|
assert alias.is_symlink()
|
|
assert tomlkit.parse(alias.read_text()) == {"model": "old"}
|
|
|
|
|
|
@pytest.mark.parametrize("invalid", [
|
|
'token = "sk-secret\n',
|
|
'model_providers = "sk-secret"\n',
|
|
'[model_providers]\nlitellm = "sk-secret"\n',
|
|
])
|
|
def test_invalid_settings_are_unchanged_and_errors_hide_content(tmp_path: Path, invalid: str) -> None:
|
|
path: Final = tmp_path / "config.toml"
|
|
path.write_text(invalid)
|
|
with pytest.raises(CodexSettingsError) as caught:
|
|
configure_codex_settings(GATEWAY, KEY, MODEL, path)
|
|
assert "sk-secret" not in str(caught.value)
|
|
assert path.read_text() == invalid
|
|
assert not codex_configure_state_path(path).exists()
|
|
|
|
|
|
def test_invalid_receipt_fails_preflight_before_settings_change(tmp_path: Path) -> None:
|
|
path: Final = tmp_path / "config.toml"
|
|
path.write_text('model = "keep"\n')
|
|
state: Final = codex_configure_state_path(path)
|
|
state.parent.mkdir()
|
|
state.write_text('{"previous": "sk-secret"}')
|
|
with pytest.raises(CodexSettingsError) as caught:
|
|
preflight_codex_settings(path)
|
|
assert "sk-secret" not in str(caught.value)
|
|
assert path.read_text() == 'model = "keep"\n'
|
|
|
|
|
|
@pytest.mark.parametrize("configured_before", [False, True])
|
|
@pytest.mark.parametrize("failed_target", ["receipt", "settings"])
|
|
def test_failed_commit_restores_receipt_and_cleans_private_staging(
|
|
tmp_path: Path, configured_before: bool, failed_target: str
|
|
) -> None:
|
|
path: Final = tmp_path / "config.toml"
|
|
path.write_text('model = "old"\n')
|
|
state: Final = codex_configure_state_path(path)
|
|
if configured_before:
|
|
configure_codex_settings(GATEWAY, KEY, MODEL, path)
|
|
before: Final = path.read_bytes()
|
|
receipt_before: Final = state.read_bytes() if state.exists() else None
|
|
|
|
def failing_commit(staged: str, destination: str) -> None:
|
|
if destination == str(state if failed_target == "receipt" else path.resolve()):
|
|
raise OSError("sk-secret OS error")
|
|
commit_staged_json(staged, destination)
|
|
|
|
with pytest.raises(CodexSettingsError) as caught:
|
|
configure_codex_settings(GATEWAY, "sk-replacement", "new-model", path, commit=failing_commit)
|
|
assert "sk-secret" not in str(caught.value)
|
|
assert path.read_bytes() == before
|
|
assert (state.read_bytes() if state.exists() else None) == receipt_before
|
|
assert not tuple(tmp_path.rglob(".tmp-*"))
|
|
if configured_before:
|
|
unconfigure_codex_settings(path)
|
|
assert tomlkit.parse(path.read_text()) == {"model": "old"}
|
|
|
|
|
|
def test_failed_undo_keeps_the_settings_and_receipt_for_retry(tmp_path: Path) -> None:
|
|
path: Final = tmp_path / "config.toml"
|
|
path.write_text('model = "old"\n')
|
|
configure_codex_settings(GATEWAY, KEY, MODEL, path)
|
|
before: Final = path.read_bytes()
|
|
|
|
def fail(staged: str, destination: str) -> None:
|
|
raise OSError("cannot replace")
|
|
|
|
with pytest.raises(CodexSettingsError):
|
|
unconfigure_codex_settings(path, commit=fail)
|
|
assert path.read_bytes() == before
|
|
assert codex_configure_state_path(path).exists()
|
|
assert not tuple(tmp_path.rglob(".tmp-*"))
|
|
unconfigure_codex_settings(path)
|
|
assert tomlkit.parse(path.read_text()) == {"model": "old"}
|
|
|
|
|
|
def test_wrapper_and_persistent_provider_agree_except_credential_source(tmp_path: Path) -> None:
|
|
path: Final = tmp_path / "config.toml"
|
|
configure_codex_settings(GATEWAY, KEY, MODEL, path)
|
|
provider: Final = tomlkit.parse(path.read_text())["model_providers"]["litellm"]
|
|
args: Final = agent_launch_args("codex", GATEWAY)
|
|
overrides: Final = dict(argument.split("=", 1) for argument in args[1::2])
|
|
for field in ("name", "base_url", "wire_api", "supports_websockets", "requires_openai_auth"):
|
|
assert json.loads(overrides[f"model_providers.litellm.{field}"]) == provider[field]
|
|
assert overrides["model_providers.litellm.http_headers"] == "{}"
|
|
assert overrides["model_providers.litellm.env_key"] == '"OPENAI_API_KEY"'
|
|
|
|
|
|
@pytest.mark.parametrize("version", [
|
|
"codex-cli 0.129.0", "codex-cli 0.129.1", "codex-cli 0.130.0", "codex-cli 1.0.0",
|
|
" \ncodex-cli 0.129.0\n",
|
|
])
|
|
def test_version_guard_accepts_the_fixed_release_and_newer_stable_versions(version: str) -> None:
|
|
assert codex_settings_module.require_safe_codex(version=lambda: version) is None
|
|
|
|
|
|
@pytest.mark.parametrize("version", [
|
|
None, "", "codex-cli 0.99.0", "codex-cli 0.128.99", "codex-cli 0.129.0-alpha.1",
|
|
"codex-cli 1.0.0-beta.1", "0.129.0", "codex-cli 0.129.0 extra", "unparseable-sk-version-secret",
|
|
])
|
|
def test_version_guard_refuses_missing_unsafe_or_unrecognized_versions(version: str | None) -> None:
|
|
with pytest.raises(CodexSettingsError) as caught:
|
|
codex_settings_module.require_safe_codex(version=lambda: version)
|
|
assert "0.129.0" in str(caught.value)
|
|
assert "sk-version-secret" not in str(caught.value)
|
|
|
|
|
|
@pytest.mark.parametrize("output,returncode", [
|
|
(None, 0),
|
|
("codex-cli 0.129.0\n", 7),
|
|
])
|
|
def test_version_probe_handles_missing_or_failed_executable(
|
|
fake_codex_version: Callable[[str | None, int], Path], output: str | None, returncode: int
|
|
) -> None:
|
|
fake_codex_version(output, returncode)
|
|
assert codex_settings_module._codex_version() is None
|
|
|
|
|
|
@pytest.mark.parametrize("output,returncode", [
|
|
(None, 0),
|
|
("codex-cli 0.128.0\n", 0),
|
|
("codex-cli 0.129.0-alpha.1\n", 0),
|
|
("unparseable-sk-version-secret\n", 0),
|
|
("codex-cli 0.129.0\n", 7),
|
|
])
|
|
def test_writer_checks_the_installed_codex_before_replacing_a_key_or_receipt(
|
|
tmp_path: Path, fake_codex_version: Callable[[str | None, int], Path],
|
|
output: str | None, returncode: int,
|
|
) -> None:
|
|
path: Final = tmp_path / "config.toml"
|
|
path.write_text('model = "original"\n')
|
|
configure_codex_settings(GATEWAY, "sk-existing-gateway", MODEL, path)
|
|
state: Final = codex_configure_state_path(path)
|
|
before: Final = (path.read_bytes(), state.read_bytes())
|
|
fake_codex_version(output, returncode)
|
|
with pytest.raises(CodexSettingsError) as caught:
|
|
configure_codex_settings(GATEWAY, KEY, "replacement-model", path)
|
|
assert "0.129.0" in str(caught.value)
|
|
assert KEY not in str(caught.value) and "sk-version-secret" not in str(caught.value)
|
|
assert (path.read_bytes(), state.read_bytes()) == before
|
|
assert not tuple(tmp_path.rglob(".tmp-*"))
|
|
|
|
|
|
def test_undo_does_not_require_codex_to_remain_installed(
|
|
tmp_path: Path, fake_codex_version: Callable[[str | None, int], Path]
|
|
) -> None:
|
|
path: Final = tmp_path / "config.toml"
|
|
original: Final = 'model = "original"\n'
|
|
path.write_text(original)
|
|
configure_codex_settings(GATEWAY, KEY, MODEL, path)
|
|
fake_codex_version(None, 0)
|
|
outcome: Final = unconfigure_codex_settings(path)
|
|
assert outcome.restored and not outcome.kept
|
|
assert tomlkit.parse(path.read_text()) == tomlkit.parse(original)
|
|
assert not codex_configure_state_path(path).exists()
|