diff --git a/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py b/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py index f3c60679c81..9cad20a1420 100644 --- a/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py +++ b/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py @@ -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"]) diff --git a/tests/unit/litellm_core_utils/llm_response_utils/test_convert_dict_to_response.py b/tests/unit/litellm_core_utils/llm_response_utils/test_convert_dict_to_response.py index 0c601a6b2c8..33544a338b6 100644 --- a/tests/unit/litellm_core_utils/llm_response_utils/test_convert_dict_to_response.py +++ b/tests/unit/litellm_core_utils/llm_response_utils/test_convert_dict_to_response.py @@ -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 diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index 14cea8f0c2c..6860d2af211 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -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