fix(completion): record missing provider usage on the response's hidden params

This commit is contained in:
Mrun25 2026-10-16 18:35:38 +05:30
parent 4e2dd65549
commit a96543658b
3 changed files with 146 additions and 0 deletions

View file

@ -800,6 +800,15 @@ def convert_to_model_response_object(
if "usage" in response_object and response_object["usage"] is not None:
usage_object: Final = litellm.Usage(**response_object["usage"])
setattr(model_response_object, "usage", usage_object)
else:
# The provider sent no usage (missing or explicit null). The
# pre-attached empty Usage() keeps zero-filled counts as the
# default for downstream consumers, but a missing block would
# be indistinguishable from a real zero - record the absence
# so callers can tell the two apart.
cast( # cast-ok: hidden_params is normalized to a dict in the function prelude
dict[str, object], hidden_params
).update({"usage_missing": True})
if "created" in response_object:
model_response_object.created = safe_convert_created_field(response_object["created"])

View file

@ -2968,3 +2968,73 @@ def test_convert_to_model_response_object_function_output():
assert result.usage.completion_tokens_details == CompletionTokensDetailsWrapper(reasoning_tokens=0)
assert result._hidden_params is not None
COMPLETION_RESPONSE_WITHOUT_USAGE = {
"id": "chatcmpl-no-usage",
"created": 1784657740,
"model": "gpt-5.6",
"object": "chat.completion",
"choices": [
{
"finish_reason": "stop",
"index": 0,
"message": {"role": "assistant", "content": "Hello"},
}
],
}
COMPLETION_RESPONSE_NULL_USAGE = {**COMPLETION_RESPONSE_WITHOUT_USAGE, "usage": None}
COMPLETION_RESPONSE_ZERO_USAGE = {
**COMPLETION_RESPONSE_WITHOUT_USAGE,
"usage": {"completion_tokens": 0, "prompt_tokens": 0, "total_tokens": 0},
}
def test_convert_to_model_response_object_marks_missing_usage():
# main.py's completion() pre-attaches an empty Usage() before the provider
# reply is parsed; without a marker, a missing block looks like a real 0.
model_response: Final = ModelResponse()
setattr(model_response, "usage", litellm.Usage())
result: Final = convert_to_model_response_object(
response_object=COMPLETION_RESPONSE_WITHOUT_USAGE,
model_response_object=model_response,
response_type="completion",
)
assert result.hidden_params["usage_missing"] is True
# the zero-filled default stays intact for downstream consumers
assert result.usage.prompt_tokens == 0
assert result.usage.completion_tokens == 0
def test_convert_to_model_response_object_marks_null_usage():
model_response: Final = ModelResponse()
setattr(model_response, "usage", litellm.Usage())
result: Final = convert_to_model_response_object(
response_object=COMPLETION_RESPONSE_NULL_USAGE,
model_response_object=model_response,
response_type="completion",
)
assert result.hidden_params["usage_missing"] is True
assert result.usage.total_tokens == 0
def test_convert_to_model_response_object_real_zero_usage_not_marked():
model_response: Final = ModelResponse()
setattr(model_response, "usage", litellm.Usage())
result: Final = convert_to_model_response_object(
response_object=COMPLETION_RESPONSE_ZERO_USAGE,
model_response_object=model_response,
response_type="completion",
)
assert "usage_missing" not in result.hidden_params
assert result.usage.prompt_tokens == 0
assert result.usage.completion_tokens == 0
assert result.usage.total_tokens == 0

View file

@ -8732,3 +8732,70 @@ async def test_atranscription_ignores_a_non_string_base_url_when_api_base_is_set
assert route.called
assert response.text == "four score"
def test_completion_marks_usage_missing_when_provider_omits_usage():
# Regression test for #45586: a usage-less provider response must be
# distinguishable from a real 0-token one.
api_base: Final = "http://localhost:12347/v1"
response_body: Final = {
"id": "chatcmpl-missing-usage",
"created": 1784657740,
"model": "gpt-5.6",
"object": "chat.completion",
"choices": [
{
"finish_reason": "stop",
"index": 0,
"message": {"role": "assistant", "content": "Hello"},
}
],
}
with respx.mock(assert_all_called=False) as router:
mock_route: Final = router.post(url__regex=rf"{api_base}/chat/completions.*").mock(
return_value=httpx.Response(status_code=200, json=response_body)
)
response: Final = litellm.completion(
model="openai/gpt-5.6",
messages=[{"role": "user", "content": "Hello"}],
api_base=api_base,
api_key="fake_openai_api_key",
)
assert mock_route.called
assert response.hidden_params["usage_missing"] is True
# the zero-filled default stays intact for downstream consumers
assert response.usage.prompt_tokens == 0
assert response.usage.completion_tokens == 0
def test_completion_real_zero_usage_is_not_marked_missing():
api_base: Final = "http://localhost:12348/v1"
response_body: Final = {
"id": "chatcmpl-zero-usage",
"created": 1784657740,
"model": "gpt-5.6",
"object": "chat.completion",
"choices": [
{
"finish_reason": "stop",
"index": 0,
"message": {"role": "assistant", "content": ""},
}
],
"usage": {"completion_tokens": 0, "prompt_tokens": 0, "total_tokens": 0},
}
with respx.mock(assert_all_called=False) as router:
mock_route: Final = router.post(url__regex=rf"{api_base}/chat/completions.*").mock(
return_value=httpx.Response(status_code=200, json=response_body)
)
response: Final = litellm.completion(
model="openai/gpt-5.6",
messages=[{"role": "user", "content": "Hello"}],
api_base=api_base,
api_key="fake_openai_api_key",
)
assert mock_route.called
assert "usage_missing" not in response.hidden_params
assert response.usage.prompt_tokens == 0
assert response.usage.completion_tokens == 0
assert response.usage.total_tokens == 0