mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-24 00:52:24 +00:00
Merge pull request #42356 from BerriAI/litellm_cli_footer_version_guard
fix(cli): preserve newer installed status lines during setup
This commit is contained in:
commit
24f616b637
5 changed files with 200 additions and 10 deletions
|
|
@ -22,8 +22,12 @@ from pathlib import Path
|
|||
from types import MappingProxyType
|
||||
from typing import Final, TypeAlias
|
||||
|
||||
import click
|
||||
from filelock import FileLock
|
||||
from packaging.version import InvalidVersion, Version
|
||||
from pydantic import BaseModel, ConfigDict, JsonValue, TypeAdapter, ValidationError
|
||||
|
||||
from litellm._version import version as litellm_version
|
||||
from litellm.litellm_core_utils.private_json import (
|
||||
commit_staged_json,
|
||||
discard_staged_json,
|
||||
|
|
@ -75,6 +79,7 @@ BACKUP_PATH: Final = Path.home() / ".litellm" / "claude_settings_backup.json"
|
|||
AUTOROUTE_BACKUP_PATH: Final = Path.home() / ".litellm" / "autorouter" / "claude_settings_backup.json"
|
||||
CONFIGURE_STATE_PATH: Final = Path.home() / ".litellm" / "claude_configure_state.json"
|
||||
STATUSLINE_SCRIPT_PATH: Final = Path.home() / ".litellm" / "statusline.py"
|
||||
STATUSLINE_VERSION_PREFIX: Final = b"# litellm-statusline-version: "
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
@ -305,11 +310,54 @@ def statusline_command(script_path: Path, platform: str = sys.platform) -> str:
|
|||
return " ".join(quote(token) for token in (sys.executable, str(script_path)))
|
||||
|
||||
|
||||
def install_statusline_script(script_path: Path | None = None) -> str:
|
||||
def _statusline_version(value: str) -> Version | None:
|
||||
try:
|
||||
return Version(value)
|
||||
except InvalidVersion:
|
||||
return None
|
||||
|
||||
|
||||
def _installed_statusline_version(target: Path) -> Version | None:
|
||||
try:
|
||||
with target.open("rb") as script:
|
||||
header: Final = script.readline(256)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
if not header.startswith(STATUSLINE_VERSION_PREFIX):
|
||||
return None
|
||||
try:
|
||||
return _statusline_version(header.removeprefix(STATUSLINE_VERSION_PREFIX).decode("ascii").strip())
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
def install_statusline_script(
|
||||
script_path: Path | None = None,
|
||||
*,
|
||||
package_version: str = litellm_version,
|
||||
write: Callable[[str, bytes], None] = write_private_bytes,
|
||||
) -> str:
|
||||
target: Final = script_path or STATUSLINE_SCRIPT_PATH
|
||||
try:
|
||||
ensure_private_dir(target.parent)
|
||||
write_private_bytes(str(target), Path(statusline_script.__file__).read_bytes())
|
||||
bundled_version: Final = _statusline_version(package_version)
|
||||
with FileLock(str(target) + ".lock", timeout=10, mode=0o600):
|
||||
installed_version: Final = _installed_statusline_version(target)
|
||||
if installed_version is not None and (bundled_version is None or installed_version > bundled_version):
|
||||
cli_version: Final = str(bundled_version) if bundled_version is not None else "unknown"
|
||||
click.echo(
|
||||
f"Keeping the status line from LiteLLM {installed_version}; this CLI is {cli_version}. "
|
||||
"Upgrade the CLI to refresh it.",
|
||||
err=True,
|
||||
)
|
||||
return statusline_command(target)
|
||||
source: Final = Path(statusline_script.__file__).read_bytes()
|
||||
header: Final = (
|
||||
STATUSLINE_VERSION_PREFIX + str(bundled_version).encode("ascii") + b"\n"
|
||||
if bundled_version is not None
|
||||
else b""
|
||||
)
|
||||
write(str(target), header + source)
|
||||
except OSError as e:
|
||||
raise ClaudeSettingsError(f"Could not install the status line script at {target}: {e}") from e
|
||||
return statusline_command(target)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"""Claude Code status line and Codex Stop hook for auto-routed sessions.
|
||||
|
||||
`lite` copies this file verbatim to ~/.litellm/statusline.py and registers it as Claude
|
||||
Code's `statusLine` command and as Codex's `[[hooks.Stop]]` command, so it must stay
|
||||
`lite` copies this file to ~/.litellm/statusline.py with a CLI version header when known and registers
|
||||
it as Claude Code's `statusLine` command and as Codex's `[[hooks.Stop]]` command, so it must stay
|
||||
standard-library only and must never import litellm. Claude Code re-runs it on every
|
||||
status refresh (about every 300ms while typing), so the proxy is asked at most once per
|
||||
TTL per session and every other refresh is served from a small on-disk cache that holds
|
||||
|
|
|
|||
|
|
@ -15,12 +15,14 @@ dependencies = [
|
|||
# When changing a floor, verify it installs + imports on every supported
|
||||
# Python with: `uv pip install --resolution=lowest-direct .`
|
||||
"fastuuid>=0.14.0,<1.0",
|
||||
"filelock>=3.16.1,<4.0",
|
||||
"httpx[http2]>=0.28.0,<1.0",
|
||||
"openai>=2.20.0,<3.0.0",
|
||||
"python-dotenv>=1.0.0,<2.0",
|
||||
"tiktoken>=0.8.0,<1.0; python_version < '3.14'",
|
||||
"tiktoken>=0.12.0,<1.0; python_version >= '3.14'",
|
||||
"importlib-metadata>=8.0.0,<9.0",
|
||||
"packaging>=24.0",
|
||||
"tokenizers>=0.21.0,<1.0",
|
||||
"click>=8.0.0,<9.0",
|
||||
"jinja2>=3.1.6,<4.0",
|
||||
|
|
|
|||
|
|
@ -5,13 +5,16 @@ 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
|
||||
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,
|
||||
|
|
@ -773,7 +776,7 @@ class TestStatusLine:
|
|||
|
||||
script = tmp_path / "lite" / "statusline.py"
|
||||
command = install_statusline_script(script)
|
||||
assert script.read_bytes() == pathlib.Path(statusline_script.__file__).read_bytes()
|
||||
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
|
||||
|
|
@ -783,15 +786,13 @@ class TestStatusLine:
|
|||
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.
|
||||
from litellm.proxy.client.cli.commands import statusline_script
|
||||
|
||||
script = tmp_path / "lite" / "statusline.py"
|
||||
install_statusline_script(script)
|
||||
bundled = pathlib.Path(statusline_script.__file__).read_bytes()
|
||||
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"]
|
||||
assert {child.name for child in script.parent.iterdir()} <= {"statusline.py", "statusline.py.lock"}
|
||||
|
||||
if os.geteuid() != 0:
|
||||
script.parent.chmod(0o500)
|
||||
|
|
@ -802,6 +803,141 @@ class TestStatusLine:
|
|||
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"
|
||||
|
|
|
|||
4
uv.lock
generated
4
uv.lock
generated
|
|
@ -4516,11 +4516,13 @@ dependencies = [
|
|||
{ name = "boto3" },
|
||||
{ name = "click" },
|
||||
{ name = "fastuuid" },
|
||||
{ name = "filelock" },
|
||||
{ name = "httpx", extra = ["http2"] },
|
||||
{ name = "importlib-metadata" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "jsonschema" },
|
||||
{ name = "openai" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "python-dotenv" },
|
||||
|
|
@ -4769,6 +4771,7 @@ requires-dist = [
|
|||
{ name = "fastapi", marker = "extra == 'proxy'", specifier = ">=0.136.3,<1.0" },
|
||||
{ name = "fastapi-sso", marker = "extra == 'proxy'", specifier = ">=0.19.0,<1.0" },
|
||||
{ name = "fastuuid", specifier = ">=0.14.0,<1.0" },
|
||||
{ name = "filelock", specifier = ">=3.16.1,<4.0" },
|
||||
{ name = "google-cloud-aiplatform", marker = "extra == 'google'", specifier = ">=1.133.0,<2.0" },
|
||||
{ name = "google-cloud-aiplatform", marker = "extra == 'proxy-runtime'", specifier = ">=1.133.0,<2.0" },
|
||||
{ name = "google-cloud-iam", marker = "extra == 'extra-proxy'", specifier = ">=2.19.1,<3.0" },
|
||||
|
|
@ -4802,6 +4805,7 @@ requires-dist = [
|
|||
{ name = "numpydoc", marker = "extra == 'utils'", specifier = ">=1.8.0,<2.0" },
|
||||
{ name = "nvidia-riva-client", marker = "extra == 'stt-nvidia-riva'", specifier = ">=2.15.0" },
|
||||
{ name = "openai", specifier = ">=2.20.0,<3.0.0" },
|
||||
{ name = "packaging", specifier = ">=24.0" },
|
||||
{ name = "opentelemetry-api", marker = "extra == 'proxy-runtime'", specifier = "==1.28.0" },
|
||||
{ name = "opentelemetry-exporter-otlp", marker = "extra == 'proxy-runtime'", specifier = "==1.28.0" },
|
||||
{ name = "opentelemetry-instrumentation-fastapi", marker = "extra == 'proxy-runtime'", specifier = "==0.49b0" },
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue