mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
## Description
Fixes the cost dashboard reporting `$0.00` for every call when the
upstream model is `MiniMax-M3` (Anthropic-compatible endpoint served
from the `MiniMax` provider).
Two root causes in `headroom/pricing/litellm_pricing.py`:
1. **`resolve_litellm_model()` had no `minimax/` provider prefix.**
LiteLLM's community pricing database stores MiniMax-M3 only under
`minimax/MiniMax-M3`. The resolver never tried that prefix, so callers
in `proxy/cost.py`, `proxy/savings_tracker.py`, and `perf/analyzer.py`
silently fell back to the unresolved name.
2. **The prefix check was case-sensitive.** MiniMax's model name uses
mixed case (`MiniMax-M3`), but every existing prefix pattern (`claude-`,
`gpt-`, `o1-`, …) was lowercase, so even after adding `"minimax-"` the
bare `MiniMax-M3` wouldn't match.
This PR fixes both, plus adds a `_register_minimax_pricing()` helper
that pre-populates `litellm.model_cost["MiniMax-M3"]` from
`minimax/MiniMax-M3` at module load — a safety net so `estimate_cost()`
(which doesn't know the `minimax/` prefix internally) succeeds even on a
cold resolver cache or if LiteLLM drops the prefixed entry in a future
release.
Net change: **+97 / −1 lines across 2 files** (one production file + one
test file).
Closes #
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
## Changes Made
- **Production** (`headroom/pricing/litellm_pricing.py`):
- Add `"minimax-": "minimax/"` to the provider-prefix table in
`_resolve_litellm_model_uncached()` so the resolver knows about the
MiniMax provider.
- Compute `model_lower = model.lower()` and match prefixes against it
instead of `model`, so the mixed-case bare name `MiniMax-M3` resolves
correctly. The existing prefixes (`claude-`, `gpt-`, `o1-`, `o3-`,
`o4-`, `gemini-`) are already lowercase patterns matched against
canonical lowercase names (`claude-sonnet-4-5-…`, `gpt-4o`,
`gemini-2.0-flash`) — no regression.
- Add `_register_minimax_pricing()`: if `minimax/MiniMax-M3` is in
`litellm.model_cost` and `MiniMax-M3` is not, copy the pricing dict
under the bare key. No-op on older LiteLLM (entry missing) or when the
user has already customised `MiniMax-M3`.
- Invoke `_register_minimax_pricing()` once at module import.
- **Tests** (`tests/test_pricing_litellm.py`):
- Add `test_litellm_minimax_mixed_case_with_provider_prefix` — verifies
`resolve_litellm_model("MiniMax-M3")` returns `"minimax/MiniMax-M3"` via
the case-insensitive prefix match.
- Add `test_litellm_minimax_preregistration_safety_net` — verifies the
pre-registration populates the bare `MiniMax-M3` key, that
`estimate_cost()` returns the correct dollar figure (`0.84` for 1M in +
100k out), and that a user-customised bare entry is never clobbered.
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed
### Test Output
```text
$ uv run pytest tests/test_pricing_litellm.py -v
============================= test session starts ==============================
platform darwin -- Python 3.11.15, pytest-9.0.3, pluggy-1.6.0
configfile: pyproject.toml
plugins: anyio-4.12.1, langsmith-0.8.0, asyncio-1.3.0, cov-7.0.0
collected 7 items
tests/test_pricing_litellm.py::test_litellm_helpers_when_dependency_is_unavailable PASSED [ 14%]
tests/test_pricing_litellm.py::test_litellm_model_pricing_exact_match_and_defaults PASSED [ 28%]
tests/test_pricing_litellm.py::test_litellm_model_pricing_uses_provider_prefixes PASSED [ 42%]
tests/test_pricing_litellm.py::test_litellm_model_pricing_uses_aliases_and_zero_cost_defaults PASSED [ 57%]
tests/test_pricing_litellm.py::test_litellm_model_pricing_returns_none_for_unknown_models PASSED [ 71%]
tests/test_pricing_litellm.py::test_litellm_minimax_mixed_case_with_provider_prefix PASSED [ 85%]
tests/test_pricing_litellm.py::test_litellm_minimax_preregistration_safety_net PASSED [100%]
============================== 7 passed in 1.07s ===============================
$ uv run ruff check headroom/pricing/litellm_pricing.py tests/test_pricing_litellm.py
All checks passed!
$ uv run mypy headroom/pricing/litellm_pricing.py
Success: no issues found in 1 source file
```
Manual reproducer (matches the PR writeup):
```text
$ uv run python -c "
from headroom.pricing.litellm_pricing import resolve_litellm_model, estimate_cost
import litellm
print('resolve_litellm_model(MiniMax-M3):', resolve_litellm_model('MiniMax-M3'))
print('MiniMax-M3 in litellm.model_cost :', 'MiniMax-M3' in litellm.model_cost)
print('estimate_cost (1M in, 100k out): ', estimate_cost('MiniMax-M3', 1_000_000, 100_000))
"
resolve_litellm_model(MiniMax-M3): minimax/MiniMax-M3
MiniMax-M3 in litellm.model_cost : True
estimate_cost (1M in, 100k out): 0.84
```
## Real Behavior Proof
- Environment: macOS Darwin 25.5.0, Python 3.11.15, `headroom-ai`
installed editable via `uv` from this branch, `litellm` pulled from PyPI
on first run.
- Exact command / steps: after `git checkout fix/minimax-pricing && uv
sync --all-extras --dev`, run (1) `uv run python -c "from
headroom.pricing.litellm_pricing import resolve_litellm_model,
estimate_cost; import litellm;
print(resolve_litellm_model('MiniMax-M3'), 'MiniMax-M3' in
litellm.model_cost, estimate_cost('MiniMax-M3', 1_000_000, 100_000))"`,
then (2) `uv run pytest tests/test_pricing_litellm.py -v`, then (3) `uv
run ruff check headroom/pricing/litellm_pricing.py
tests/test_pricing_litellm.py`, then (4) `uv run mypy
headroom/pricing/litellm_pricing.py`.
- Observed result: (1) `resolve_litellm_model('MiniMax-M3')` returns
`minimax/MiniMax-M3` (was `MiniMax-M3`, unresolved); `'MiniMax-M3' in
litellm.model_cost` is `True` (proves `_register_minimax_pricing()`
ran); `estimate_cost('MiniMax-M3', 1_000_000, 100_000)` returns `0.84`
(matches `$0.60/M in × 1M + $2.40/M out × 0.1M`). (2) All 7 tests in
`tests/test_pricing_litellm.py` pass (5 pre-existing + 2 new
MiniMax-specific). (3) `ruff` reports `All checks passed!`. (4) `mypy`
reports `Success: no issues found in 1 source file`.
- Not tested: end-to-end through the running proxy against a live
`MiniMax-M3` endpoint — no Anthropic-compatible key configured in this
environment. The reproducer exercises the exact code path the proxy's
cost accumulator uses, but I did not point the proxy at a real upstream.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation — *N/A: no
user-facing docs reference `litellm_pricing.py` directly; the only
public API affected (`estimate_cost`) now returns correct values for a
previously-unsupported model.*
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable — *N/A: this repo
doesn't appear to use CHANGELOG.md (not present at repo root).*
## Additional Notes
- **Why both fixes are needed.** `estimate_cost()` calls
`get_model_pricing()` directly, and `get_model_pricing()` has its own
hardcoded prefix list `["openai/", "anthropic/", "google/", "mistral/",
"deepseek/"]` that does **not** include `minimax/`. So the prefix
resolver alone is not enough for `estimate_cost("MiniMax-M3")` to return
a non-`None` number — the pre-registration step is what makes the bare
name resolve. The prefix resolver change matters for the proxy's
cost/savings/perf code paths that call `resolve_litellm_model()` and
then look up the prefixed string themselves.
- **Why case-insensitive matching is safe.** All existing prefixes are
lowercase patterns matched against already-lowercase canonical model
names — lower-casing before `startswith()` is a no-op for them. Only the
new `"minimax-"` entry uses a mixed-case input.
- **Pricing drift note.** `_register_minimax_pricing()` mirrors upstream
LiteLLM (input $0.60/M, output $2.40/M, cache read $0.12/M as of
2026-06). Re-check after LiteLLM updates; the function already
short-circuits when the user has customised the entry.
- **Did not run** the full test suite, only
`tests/test_pricing_litellm.py`. Wider CI will catch anything I missed.
---------
Co-authored-by: Shreyas S K <shreyassk@Shreyass-MacBook-Air.local>
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
167 lines
6.6 KiB
Python
167 lines
6.6 KiB
Python
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
|
|
from headroom.pricing import litellm_pricing
|
|
|
|
|
|
def test_litellm_helpers_when_dependency_is_unavailable(monkeypatch) -> None:
|
|
monkeypatch.setattr(litellm_pricing, "LITELLM_AVAILABLE", False)
|
|
monkeypatch.setattr(litellm_pricing, "litellm", None)
|
|
|
|
assert litellm_pricing.get_litellm_model_cost() == {}
|
|
assert litellm_pricing.get_model_pricing("gpt-4o") is None
|
|
assert litellm_pricing.estimate_cost("gpt-4o", input_tokens=1, output_tokens=1) is None
|
|
assert litellm_pricing.list_available_models() == []
|
|
|
|
|
|
def test_litellm_model_pricing_exact_match_and_defaults(monkeypatch) -> None:
|
|
fake_litellm = SimpleNamespace(
|
|
model_cost={
|
|
"gpt-4o": {
|
|
"input_cost_per_token": 0.0000025,
|
|
"output_cost_per_token": 0.00001,
|
|
"max_tokens": 128000,
|
|
}
|
|
}
|
|
)
|
|
monkeypatch.setattr(litellm_pricing, "LITELLM_AVAILABLE", True)
|
|
monkeypatch.setattr(litellm_pricing, "litellm", fake_litellm)
|
|
|
|
assert litellm_pricing.get_litellm_model_cost() == fake_litellm.model_cost
|
|
pricing = litellm_pricing.get_model_pricing("gpt-4o")
|
|
assert pricing is not None
|
|
assert pricing.model == "gpt-4o"
|
|
assert pricing.input_cost_per_1m == 2.5
|
|
assert pricing.output_cost_per_1m == 10.0
|
|
assert pricing.max_tokens == 128000
|
|
assert pricing.max_input_tokens is None
|
|
assert pricing.max_output_tokens is None
|
|
assert pricing.supports_vision is False
|
|
assert pricing.supports_function_calling is False
|
|
assert (
|
|
litellm_pricing.estimate_cost("gpt-4o", input_tokens=200_000, output_tokens=300_000) == 3.5
|
|
)
|
|
assert litellm_pricing.list_available_models() == ["gpt-4o"]
|
|
|
|
|
|
def test_litellm_model_pricing_uses_provider_prefixes(monkeypatch) -> None:
|
|
fake_litellm = SimpleNamespace(
|
|
model_cost={
|
|
"openai/gpt-4o-mini": {
|
|
"input_cost_per_token": 0.00000015,
|
|
"output_cost_per_token": 0.0000006,
|
|
"supports_vision": True,
|
|
"supports_function_calling": True,
|
|
"max_input_tokens": 64000,
|
|
"max_output_tokens": 16000,
|
|
}
|
|
}
|
|
)
|
|
monkeypatch.setattr(litellm_pricing, "LITELLM_AVAILABLE", True)
|
|
monkeypatch.setattr(litellm_pricing, "litellm", fake_litellm)
|
|
|
|
pricing = litellm_pricing.get_model_pricing("gpt-4o-mini")
|
|
assert pricing is not None
|
|
assert pricing.input_cost_per_1m == 0.15
|
|
assert pricing.output_cost_per_1m == 0.6
|
|
assert pricing.max_input_tokens == 64000
|
|
assert pricing.max_output_tokens == 16000
|
|
assert pricing.supports_vision is True
|
|
assert pricing.supports_function_calling is True
|
|
|
|
|
|
def test_litellm_model_pricing_uses_aliases_and_zero_cost_defaults(monkeypatch) -> None:
|
|
fake_litellm = SimpleNamespace(
|
|
model_cost={
|
|
"claude-sonnet-4-20250514": {
|
|
"input_cost_per_token": None,
|
|
"output_cost_per_token": None,
|
|
}
|
|
}
|
|
)
|
|
monkeypatch.setattr(litellm_pricing, "LITELLM_AVAILABLE", True)
|
|
monkeypatch.setattr(litellm_pricing, "litellm", fake_litellm)
|
|
|
|
pricing = litellm_pricing.get_model_pricing("claude-3-5-sonnet-20241022")
|
|
assert pricing is not None
|
|
assert pricing.model == "claude-3-5-sonnet-20241022"
|
|
assert pricing.input_cost_per_1m == 0
|
|
assert pricing.output_cost_per_1m == 0
|
|
assert litellm_pricing.estimate_cost("claude-3-5-sonnet-20241022", input_tokens=1) == 0
|
|
|
|
|
|
def test_litellm_model_pricing_returns_none_for_unknown_models(monkeypatch) -> None:
|
|
monkeypatch.setattr(litellm_pricing, "LITELLM_AVAILABLE", True)
|
|
monkeypatch.setattr(litellm_pricing, "litellm", SimpleNamespace(model_cost={}))
|
|
assert litellm_pricing.get_model_pricing("missing") is None
|
|
|
|
|
|
def test_litellm_minimax_mixed_case_with_provider_prefix(monkeypatch) -> None:
|
|
"""MiniMax-M3 must resolve via the `minimax/` prefix even though its
|
|
model name uses mixed case.
|
|
|
|
`resolve_litellm_model()` is what callers in `proxy/cost.py`,
|
|
`proxy/savings_tracker.py`, and `perf/analyzer.py` use to get a
|
|
key LiteLLM's own cost DB recognises. The upstream DB only stores
|
|
the entry under `minimax/MiniMax-M3`, so bare `MiniMax-M3` would
|
|
otherwise miss and the resolver would return the input unchanged.
|
|
"""
|
|
|
|
def fake_cost_per_token(
|
|
model: str, prompt_tokens: int = 0, completion_tokens: int = 0
|
|
) -> tuple[float, float]:
|
|
if model in fake_litellm.model_cost:
|
|
entry = fake_litellm.model_cost[model]
|
|
return (
|
|
entry["input_cost_per_token"] * prompt_tokens,
|
|
entry["output_cost_per_token"] * completion_tokens,
|
|
)
|
|
raise KeyError(f"unknown model: {model}")
|
|
|
|
fake_litellm = SimpleNamespace(
|
|
model_cost={
|
|
"minimax/MiniMax-M3": {
|
|
"input_cost_per_token": 0.0000006,
|
|
"output_cost_per_token": 0.0000024,
|
|
}
|
|
},
|
|
cost_per_token=fake_cost_per_token,
|
|
)
|
|
monkeypatch.setattr(litellm_pricing, "LITELLM_AVAILABLE", True)
|
|
monkeypatch.setattr(litellm_pricing, "litellm", fake_litellm)
|
|
|
|
# Bare mixed-case name resolves via the case-insensitive `minimax-` prefix.
|
|
assert litellm_pricing.resolve_litellm_model("MiniMax-M3") == "minimax/MiniMax-M3"
|
|
|
|
|
|
def test_litellm_minimax_preregistration_safety_net(monkeypatch) -> None:
|
|
"""When LiteLLM only ships the prefixed `minimax/MiniMax-M3` entry, the
|
|
module-load pre-registration should also expose the bare `MiniMax-M3`
|
|
key so `estimate_cost()` works on a cold resolver cache (since
|
|
`get_model_pricing` does not know about the `minimax/` prefix).
|
|
"""
|
|
fake_litellm = SimpleNamespace(
|
|
model_cost={
|
|
"minimax/MiniMax-M3": {
|
|
"input_cost_per_token": 0.0000006,
|
|
"output_cost_per_token": 0.0000024,
|
|
}
|
|
}
|
|
)
|
|
monkeypatch.setattr(litellm_pricing, "LITELLM_AVAILABLE", True)
|
|
monkeypatch.setattr(litellm_pricing, "litellm", fake_litellm)
|
|
|
|
litellm_pricing._register_minimax_pricing()
|
|
|
|
assert "MiniMax-M3" in fake_litellm.model_cost
|
|
assert fake_litellm.model_cost["MiniMax-M3"]["input_cost_per_token"] == 0.0000006
|
|
# After pre-registration, bare-name estimate_cost works end-to-end.
|
|
assert (
|
|
litellm_pricing.estimate_cost("MiniMax-M3", input_tokens=1_000_000, output_tokens=100_000)
|
|
== 0.84
|
|
)
|
|
# Pre-registration must not clobber a user-customised bare entry.
|
|
fake_litellm.model_cost["MiniMax-M3"] = {"customised": True}
|
|
litellm_pricing._register_minimax_pricing()
|
|
assert fake_litellm.model_cost["MiniMax-M3"] == {"customised": True}
|