fix(cli): preserve newer installed status lines during setup

This commit is contained in:
Tin Chi Lo 2026-09-21 15:54:41 -07:00
parent 403b4be40e
commit 467d13ebac
6 changed files with 101 additions and 9 deletions

View file

@ -19632,7 +19632,7 @@
}
}
},
"description": "\n Unified rate-limit error.\n\n Every rate-limit condition surfaced by litellm \u2014 whether it originated from\n an upstream LLM provider, a vendor batch endpoint, or one of litellm's own\n proxy-side limiters (parallel-requests, dynamic-rate, batch-rate, budget,\n max-iterations, etc.) \u2014 is raised as an instance of this class.\n\n The :attr:`category` attribute lets callers distinguish the source. See\n :class:`RateLimitErrorCategory` for the available values.\n "
"description": "\nUnified rate-limit error.\n\nEvery rate-limit condition surfaced by litellm \u2014 whether it originated from\nan upstream LLM provider, a vendor batch endpoint, or one of litellm's own\nproxy-side limiters (parallel-requests, dynamic-rate, batch-rate, budget,\nmax-iterations, etc.) \u2014 is raised as an instance of this class.\n\nThe :attr:`category` attribute lets callers distinguish the source. See\n:class:`RateLimitErrorCategory` for the available values.\n"
},
"500": {
"content": {

View file

@ -22,8 +22,11 @@ from pathlib import Path
from types import MappingProxyType
from typing import Final, TypeAlias
import click
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 +78,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,12 +309,37 @@ 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 _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 Version(header.removeprefix(STATUSLINE_VERSION_PREFIX).decode("ascii").strip())
except (InvalidVersion, UnicodeDecodeError):
return None
def install_statusline_script(script_path: Path | None = None, *, package_version: str = litellm_version) -> 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())
except OSError as e:
bundled_version: Final = Version(package_version)
installed_version: Final = _installed_statusline_version(target)
if installed_version is not None and installed_version > bundled_version:
click.echo(
f"Keeping the status line from LiteLLM {installed_version}; this CLI is {bundled_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"
write_private_bytes(str(target), header + source)
except (OSError, InvalidVersion) as e:
raise ClaudeSettingsError(f"Could not install the status line script at {target}: {e}") from e
return statusline_command(target)

View file

@ -1,6 +1,6 @@
"""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
`lite` copies this file with a CLI version header to ~/.litellm/statusline.py 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

View file

@ -21,6 +21,7 @@ dependencies = [
"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",

View file

@ -6,6 +6,7 @@ import stat
import sys
import time
from pathlib import Path
from typing import Final
from unittest.mock import patch
import pytest
@ -773,7 +774,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,11 +784,9 @@ 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
@ -802,6 +801,67 @@ 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
def test_configure_installs_it_and_unconfigure_removes_only_ours(self, tmp_path):
rig = _Rig(tmp_path, {"theme": "dark"})
script = tmp_path / "statusline.py"

2
uv.lock generated
View file

@ -4521,6 +4521,7 @@ dependencies = [
{ name = "jinja2" },
{ name = "jsonschema" },
{ name = "openai" },
{ name = "packaging" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "python-dotenv" },
@ -4802,6 +4803,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" },