diff --git a/headroom/backends/litellm.py b/headroom/backends/litellm.py index 36987b3e5..08e972908 100644 --- a/headroom/backends/litellm.py +++ b/headroom/backends/litellm.py @@ -445,7 +445,12 @@ def _anthropic_usage_from_litellm(litellm_usage: Any) -> dict[str, Any]: prompt_tokens = int(getattr(litellm_usage, "prompt_tokens", 0) or 0) usage: dict[str, Any] = { "input_tokens": max(prompt_tokens - cache_read - cache_write, 0), - "output_tokens": getattr(litellm_usage, "completion_tokens", 0), + # None-guard like the other fields: LiteLLM's Usage always carries the + # completion_tokens attribute, so the getattr default never fires, but a + # provider can leave it None. Emitting output_tokens=None would break the + # RequestOutcome int contract downstream (e.g. prometheus does + # tokens_output_total += output_tokens -> TypeError). + "output_tokens": int(getattr(litellm_usage, "completion_tokens", 0) or 0), } if cache_read or cache_write: usage["cache_read_input_tokens"] = cache_read diff --git a/tests/test_litellm_nonstream_cache_usage.py b/tests/test_litellm_nonstream_cache_usage.py index 872456bcf..1943669b6 100644 --- a/tests/test_litellm_nonstream_cache_usage.py +++ b/tests/test_litellm_nonstream_cache_usage.py @@ -72,3 +72,15 @@ def test_input_tokens_never_negative() -> None: ) ) assert usage["input_tokens"] == 0 + + +def test_output_tokens_none_coerced_to_zero() -> None: + # A provider can carry the completion_tokens attribute but leave it None. + # The mapping must emit an int (0), not None, so RequestOutcome's int + # contract holds downstream (prometheus does tokens_output_total += + # output_tokens, which would raise TypeError on None). + usage = _anthropic_usage_from_litellm( + SimpleNamespace(prompt_tokens=100, completion_tokens=None) + ) + assert usage["output_tokens"] == 0 + assert isinstance(usage["output_tokens"], int)