fix(deps): make litellm optional on Python 3.14 (#956) (#993)

## Description

`litellm` is a hard dependency and its metadata caps `Requires-Python
>=3.10,<3.14`, so `pip install headroom-ai` is unsatisfiable on Python
3.14. But litellm is only used for model registry / pricing / non-core
providers — all lazily imported behind `ImportError` guards — never on
the core compression or Anthropic proxy path. Refs #956 (install half).

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)

## Changes Made

- Add a `python_version < '3.14'` marker to both litellm declarations
(core deps + dev extra); installs unchanged on <=3.13, skipped on 3.14
(matches the existing rapidocr/tomli marker pattern).

## Testing

- [x] Unit tests pass (`pytest`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
$ pytest tests/test_litellm_optional.py -q
2 passed in 0.10s

$ python3.14 -m pip install dist/headroom_ai-0.25.0-cp310-abi3-linux_x86_64.whl
Successfully installed headroom-ai-0.25.0 ...   # litellm NOT installed
$ python3.14 -c "import importlib.util as u; print(u.find_spec('litellm') is not None)"
False
```

## Real Behavior Proof

- Environment: fresh venv on CPython 3.14.5, Linux
- Exact command / steps: built the abi3 wheel, `pip install` it on
Python 3.14, then `import headroom` + start the proxy + send a
compressible request
- Observed result: install exits 0 with litellm skipped; `import
headroom` works; the proxy compresses (29913 -> 27626 tokens). Stock
0.25.0 cannot install on 3.14 at all.
- Not tested: litellm-backed features on 3.14 (intentionally unavailable
there until litellm supports 3.14)

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Eyal Mizrachi 2026-06-16 22:11:12 -04:00 committed by GitHub
parent addebdb29c
commit b2f04e4ef7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 60 additions and 2 deletions

View file

@ -49,7 +49,11 @@ dependencies = [
# Core: lightweight compression (SmartCrusher, ContentRouter, CCR, TOIN)
"tiktoken>=0.5.0", # Tokenizer for all compressors
"pydantic>=2.0.0", # Config and data models
"litellm>=1.86.2,<2.0", # Model registry, pricing, and provider support
# litellm's own metadata pins requires-python <3.14, and headroom only uses it for
# model registry / pricing / non-core providers — all lazily imported and
# ImportError-guarded. Marking it 3.14-optional lets headroom install on Python 3.14
# (core compression + the Anthropic proxy path never import litellm). See GH #956.
"litellm>=1.86.2,<2.0; python_version < '3.14'", # model registry, pricing, providers (lazy)
"click>=8.1.0", # CLI framework
"rich>=13.0.0", # Rich terminal output
"opentelemetry-api>=1.24.0", # Safe no-op OTEL API for instrumentation
@ -228,7 +232,7 @@ dev = [
"pre-commit>=3.0.0",
"openai>=1.0.0",
"anthropic>=0.18.0",
"litellm>=1.86.2,<2.0",
"litellm>=1.86.2,<2.0; python_version < '3.14'", # see core deps note (GH #956)
"fastapi>=0.100.0",
"uvicorn>=0.23.0,<1.0",
"httpx[http2]>=0.24.0",

View file

@ -0,0 +1,54 @@
"""litellm must be optional on Python 3.14 (GH #956).
litellm's metadata pins requires-python <3.14, so a hard dependency makes
`pip install headroom-ai` unsatisfiable on 3.14. headroom only uses litellm for
model registry / pricing / non-core providers (all lazily imported and
ImportError-guarded), so every litellm requirement must carry a marker that
skips it on 3.14, and the core paths must degrade gracefully without it.
"""
from __future__ import annotations
from pathlib import Path
import pytest
try:
import tomllib
except ModuleNotFoundError: # Python 3.10
import tomli as tomllib # type: ignore[no-redef]
from packaging.requirements import Requirement
PYPROJECT = Path(__file__).resolve().parents[1] / "pyproject.toml"
def _litellm_requirements() -> list[Requirement]:
data = tomllib.loads(PYPROJECT.read_text(encoding="utf-8"))
specs = list(data["project"].get("dependencies", []))
for extra in data["project"].get("optional-dependencies", {}).values():
specs.extend(extra)
return [r for spec in specs if (r := Requirement(spec)).name == "litellm"]
def test_every_litellm_requirement_is_skipped_on_py314() -> None:
reqs = _litellm_requirements()
assert reqs, "expected litellm to be declared in pyproject"
for r in reqs:
assert r.marker is not None, f"{r}: litellm must carry a python_version marker (GH #956)"
assert not r.marker.evaluate({"python_version": "3.14"}), (
f"{r}: must be skipped on Python 3.14"
)
assert r.marker.evaluate({"python_version": "3.13"}), (
f"{r}: must still install on Python 3.13"
)
def test_proxy_cost_degrades_without_litellm(monkeypatch: pytest.MonkeyPatch) -> None:
# With litellm absent (its state on 3.14), the proxy cost path must return
# None rather than raise.
from headroom.proxy import cost
monkeypatch.setattr(cost, "LITELLM_AVAILABLE", False)
monkeypatch.setattr(cost, "litellm", None)
assert cost._get_litellm_module() is None