headroom/tests/test_pricing_litellm_model_resolution.py
Yossi Ovadia eb5b5e4198
fix: Vertex model pricing shows $0.00 for versioned model names and vertex:anthropic provider (#2517)
## Description

Two bugs cause `$0.00` cost display for Vertex AI users in headroom's
dashboard:

1. **Model name resolution** — Vertex appends `@YYYYMMDD` version tags
at runtime (e.g. `claude-haiku-4-5@20251001`). LiteLLM's database stores
bare names without version suffixes, so every versioned model missed the
lookup.

2. **Prefix cache savings** — the provider match checks `provider ==
"anthropic"` but Vertex traffic is tagged `provider ==
"vertex:anthropic"`, so cache read savings computed as $0.00. This bug
is **not** addressed by #2516.

Fixes #2515

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- `headroom/pricing/litellm_model_resolution.py`: strip `@YYYYMMDD`
suffix before lookup; add `vertex_ai/` to `MODEL_PREFIX_RULES` for
Claude models; apply prefix rules to both original and bare names
- `headroom/proxy/cost.py`: extend provider match to include
`vertex:anthropic` alongside `anthropic` for prefix cache savings
- `tests/test_pricing_litellm_model_resolution.py`: 4 new tests covering
suffix stripping, versioned model resolution, pricing lookup, and
end-to-end resolve

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
$ python -m pytest tests/test_pricing_litellm_model_resolution.py -v
collected 10 items

tests/test_pricing_litellm_model_resolution.py::test_prefix_rule_matches_case_insensitively PASSED
tests/test_pricing_litellm_model_resolution.py::test_resolution_candidates_try_bare_then_matching_prefix_then_alias PASSED
tests/test_pricing_litellm_model_resolution.py::test_pricing_lookup_candidates_include_provider_prefixes_and_aliases PASSED
tests/test_pricing_litellm_model_resolution.py::test_retired_claude_3_sonnet_aliases_to_sonnet_tier_not_haiku PASSED
tests/test_pricing_litellm_model_resolution.py::test_resolve_litellm_model_name_returns_first_known_candidate PASSED
tests/test_pricing_litellm_model_resolution.py::test_resolve_litellm_model_name_returns_original_when_unknown PASSED
tests/test_pricing_litellm_model_resolution.py::test_strip_vertex_version_suffix PASSED
tests/test_pricing_litellm_model_resolution.py::test_resolution_candidates_vertex_versioned_models PASSED
tests/test_pricing_litellm_model_resolution.py::test_pricing_lookup_candidates_vertex_versioned_models PASSED
tests/test_pricing_litellm_model_resolution.py::test_vertex_versioned_model_resolves_to_known_key PASSED

10 passed in 1.23s
```

## Real Behavior Proof

- Environment: macOS arm64, Python 3.11.13, headroom 0.32.1, Claude Code
2.1.211, `CLAUDE_CODE_USE_VERTEX=1`, persistent local proxy
- Exact command / steps: `python3 -c "from
headroom.pricing.litellm_model_resolution import resolution_candidates;
import litellm; m='claude-haiku-4-5@20251001'; [print(c,
litellm.model_cost.get(c,{}).get('input_cost_per_token',0)*1e6) for c in
resolution_candidates(m)]"`
- Observed result: before fix all versioned Vertex models returned
$0.00; after fix `claude-haiku-4-5@20251001`→$1.00/MTok,
`claude-opus-4@20250514`→$15.00/MTok, dashboard "Prefix Cache Impact"
shows Net savings $6.31 (was $0.00). Screenshots in issue #2515.
- Not tested: non-Vertex paths (direct Anthropic, Bedrock, OpenAI) —
changes are additive and guarded by `vertex:anthropic` provider check

## 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] 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

---------

Co-authored-by: JD Davis <jd@jds-macbook-air.tail2a279.ts.net>
2026-08-11 23:03:09 -05:00

123 lines
5 KiB
Python

from __future__ import annotations
from headroom.pricing.litellm_model_resolution import (
MODEL_ALIASES,
LiteLLMModelPrefixRule,
_strip_vertex_version_suffix,
pricing_lookup_candidates,
resolution_candidates,
resolve_litellm_model_name,
)
def test_prefix_rule_matches_case_insensitively() -> None:
rule = LiteLLMModelPrefixRule("minimax-", "minimax/")
assert rule.candidate_for("MiniMax-M3") == "minimax/MiniMax-M3"
assert rule.candidate_for("gpt-4o") is None
def test_resolution_candidates_try_bare_then_matching_prefix_then_alias() -> None:
assert resolution_candidates("gpt-4o") == ("gpt-4o", "openai/gpt-4o")
assert resolution_candidates("MiniMax-M3") == ("MiniMax-M3", "minimax/MiniMax-M3")
retired = "claude-3-5-sonnet-20241022"
candidates = resolution_candidates(retired)
assert candidates[0] == retired
assert f"anthropic/{retired}" in candidates
assert f"vertex_ai/{retired}" not in candidates # no @YYYYMMDD suffix = not Vertex
assert MODEL_ALIASES[retired] in candidates
def test_pricing_lookup_candidates_include_provider_prefixes_and_aliases() -> None:
candidates = pricing_lookup_candidates("claude-3-5-sonnet-20241022")
assert candidates[0] == "claude-3-5-sonnet-20241022"
assert "anthropic/claude-3-5-sonnet-20241022" in candidates
assert "minimax/claude-3-5-sonnet-20241022" in candidates
assert candidates[-1] == MODEL_ALIASES["claude-3-5-sonnet-20241022"]
def test_retired_claude_3_sonnet_aliases_to_sonnet_tier_not_haiku() -> None:
"""Retired claude-3-sonnet must map to a Sonnet-tier price, not Haiku.
claude-3-sonnet-20240229 was a $3/$15-per-1M model; aliasing it to
claude-3-haiku-20240307 ($0.25/$1.25) underpriced its cost/savings ~12x.
"""
alias = MODEL_ALIASES["claude-3-sonnet-20240229"]
assert "haiku" not in alias
# Same-tier target as the other retired-Sonnet aliases.
assert alias == MODEL_ALIASES["claude-3-5-sonnet-20241022"]
assert alias == "claude-sonnet-4-20250514"
def test_resolve_litellm_model_name_returns_first_known_candidate() -> None:
known = {"openai/gpt-4o"}
assert resolve_litellm_model_name("gpt-4o", known.__contains__) == "openai/gpt-4o"
def test_resolve_litellm_model_name_returns_original_when_unknown() -> None:
assert resolve_litellm_model_name("mystery-model", lambda _: False) == "mystery-model"
def test_strip_vertex_version_suffix() -> None:
assert _strip_vertex_version_suffix("claude-haiku-4-5@20251001") == "claude-haiku-4-5"
assert _strip_vertex_version_suffix("claude-opus-4@20250514") == "claude-opus-4"
assert _strip_vertex_version_suffix("claude-sonnet-4-6") == "claude-sonnet-4-6"
assert _strip_vertex_version_suffix("claude-sonnet-4-20250514") == "claude-sonnet-4-20250514"
def test_resolution_candidates_vertex_versioned_models() -> None:
# Vertex appends @YYYYMMDD — bare name and vertex_ai/ must be candidates
candidates = resolution_candidates("claude-haiku-4-5@20251001")
assert "claude-haiku-4-5" in candidates
assert "anthropic/claude-haiku-4-5" in candidates
assert "vertex_ai/claude-haiku-4-5" in candidates # vertex_ai/ only for versioned
candidates = resolution_candidates("claude-opus-4@20250514")
assert "claude-opus-4" in candidates
assert "anthropic/claude-opus-4" in candidates
assert "vertex_ai/claude-opus-4" in candidates
# Non-versioned names should NOT get vertex_ai/ candidates
candidates = resolution_candidates("claude-sonnet-4-6")
assert candidates[0] == "claude-sonnet-4-6"
assert "vertex_ai/claude-sonnet-4-6" not in candidates
assert "anthropic/claude-sonnet-4-6" in candidates
def test_pricing_lookup_candidates_vertex_versioned_models() -> None:
candidates = pricing_lookup_candidates("claude-haiku-4-5@20251001")
# Bare name and vertex_ai/ prefix must both be candidates
assert "claude-haiku-4-5" in candidates
assert "vertex_ai/claude-haiku-4-5" in candidates
assert "anthropic/claude-haiku-4-5" in candidates
candidates = pricing_lookup_candidates("claude-opus-4@20250514")
assert "claude-opus-4" in candidates
assert "vertex_ai/claude-opus-4" in candidates
# Non-versioned names should NOT get vertex_ai/ pricing candidates
candidates = pricing_lookup_candidates("claude-sonnet-4-6")
assert "vertex_ai/claude-sonnet-4-6" not in candidates
assert "anthropic/claude-sonnet-4-6" in candidates
def test_vertex_versioned_model_resolves_to_known_key() -> None:
# Simulate LiteLLM knowing the bare model name (not the versioned one)
known = {"claude-haiku-4-5", "anthropic/claude-sonnet-4-6"}
assert (
resolve_litellm_model_name("claude-haiku-4-5@20251001", known.__contains__)
== "claude-haiku-4-5"
)
assert (
resolve_litellm_model_name("claude-sonnet-4-6", known.__contains__)
== "anthropic/claude-sonnet-4-6"
)
# Unknown versioned model falls back to original
assert (
resolve_litellm_model_name("claude-unknown@20251001", lambda _: False)
== "claude-unknown@20251001"
)