diff --git a/litellm/integrations/arize/_utils.py b/litellm/integrations/arize/_utils.py index f719dacd148..90efa7d6395 100644 --- a/litellm/integrations/arize/_utils.py +++ b/litellm/integrations/arize/_utils.py @@ -532,12 +532,14 @@ def _set_request_attributes( litellm_params.get("custom_llm_provider", "Unknown"), ) - if optional_params.get("max_tokens"): - safe_set_attribute(span, "llm.request.max_tokens", optional_params.get("max_tokens")) - if optional_params.get("temperature"): - safe_set_attribute(span, "llm.request.temperature", optional_params.get("temperature")) - if optional_params.get("top_p"): - safe_set_attribute(span, "llm.request.top_p", optional_params.get("top_p")) + for source_key, request_attribute in ( + ("max_tokens", "llm.request.max_tokens"), + ("temperature", "llm.request.temperature"), + ("top_p", "llm.request.top_p"), + ): + request_value = optional_params.get(source_key) + if request_value is not None: + safe_set_attribute(span, request_attribute, request_value) safe_set_attribute(span, "llm.is_streaming", str(optional_params.get("stream", False))) diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index a9bd93e6420..a521ec6f34e 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -2535,29 +2535,14 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): value=provider_name, ) - # The maximum number of tokens the LLM generates for a request. - if optional_params.get("max_tokens"): - self.safe_set_attribute( - span=span, - key=SpanAttributes.LLM_REQUEST_MAX_TOKENS.value, - value=optional_params.get("max_tokens"), - ) - - # The temperature setting for the LLM request. - if optional_params.get("temperature"): - self.safe_set_attribute( - span=span, - key=SpanAttributes.LLM_REQUEST_TEMPERATURE.value, - value=optional_params.get("temperature"), - ) - - # The top_p sampling setting for the LLM request. - if optional_params.get("top_p"): - self.safe_set_attribute( - span=span, - key=SpanAttributes.LLM_REQUEST_TOP_P.value, - value=optional_params.get("top_p"), - ) + for source_key, request_attribute in ( + ("max_tokens", SpanAttributes.LLM_REQUEST_MAX_TOKENS.value), + ("temperature", SpanAttributes.LLM_REQUEST_TEMPERATURE.value), + ("top_p", SpanAttributes.LLM_REQUEST_TOP_P.value), + ): + request_value = optional_params.get(source_key) + if request_value is not None: + self.safe_set_attribute(span=span, key=request_attribute, value=request_value) if self._gen_ai_semconv_latest_experimental: # Semconv emits gen_ai.request.stream (only when streaming) via diff --git a/tests/unit/integrations/arize/test_arize_utils.py b/tests/unit/integrations/arize/test_arize_utils.py index 31699359bbe..c1860d5e73c 100644 --- a/tests/unit/integrations/arize/test_arize_utils.py +++ b/tests/unit/integrations/arize/test_arize_utils.py @@ -185,6 +185,44 @@ def test_arize_set_attributes(): span.set_attribute.assert_any_call(SpanAttributes.LLM_TOKEN_COUNT_PROMPT, 40) +def test_arize_records_zero_valued_sampling_params(): + """A sampling param the caller set to 0 still has to reach the span. + + temperature=0 is what you send for deterministic output, so dropping it + hides the single most common setting from the trace. + """ + from unittest.mock import MagicMock + + from litellm.types.utils import ModelResponse + + span = MagicMock() + + kwargs = { + "model": "gpt-4o", + "messages": [{"role": "user", "content": "Basic Request Content"}], + "standard_logging_object": { + "model_parameters": {}, + "metadata": {}, + "call_type": "completion", + }, + "optional_params": {"max_tokens": 0, "temperature": 0, "top_p": 0}, + "litellm_params": {"custom_llm_provider": "openai"}, + } + + response_obj = ModelResponse( + usage={"total_tokens": 100, "completion_tokens": 60, "prompt_tokens": 40}, + choices=[Choices(message={"role": "assistant", "content": "Basic Response Content"})], + model="gpt-4o", + id="chatcmpl-ID", + ) + + ArizeLogger.set_arize_attributes(span, kwargs, response_obj) + + span.set_attribute.assert_any_call("llm.request.temperature", 0) + span.set_attribute.assert_any_call("llm.request.top_p", 0) + span.set_attribute.assert_any_call("llm.request.max_tokens", 0) + + def test_arize_set_attributes_responses_api(): """ Test setting attributes for Responses API with mixed output (reasoning + message). diff --git a/tests/unit/integrations/test_opentelemetry.py b/tests/unit/integrations/test_opentelemetry.py index 5f749e951b1..ddb07e02b72 100644 --- a/tests/unit/integrations/test_opentelemetry.py +++ b/tests/unit/integrations/test_opentelemetry.py @@ -584,6 +584,47 @@ class TestOpenTelemetryCostBreakdown(unittest.TestCase): assert all(not key.startswith("gen_ai.cost.") for key, _value in call_args_list) +class TestOpenTelemetryZeroValuedRequestParams(unittest.TestCase): + def test_zero_valued_sampling_params_reach_the_span(self): + """A sampling param the caller set to 0 still has to reach the span. + + temperature=0 is what you send for deterministic output, so dropping it + hides the single most common setting from the trace. The semconv path + already records these, so the legacy path has to agree. + """ + otel = OpenTelemetry() + mock_span = MagicMock() + + kwargs = { + "model": "gpt-4", + "messages": [{"role": "user", "content": "Hello"}], + "optional_params": {"max_tokens": 0, "temperature": 0, "top_p": 0}, + "litellm_params": {"custom_llm_provider": "openai"}, + "standard_logging_object": { + "id": "test-id", + "call_type": "completion", + "metadata": {}, + }, + } + + response_obj = { + "id": "test-response-id", + "model": "gpt-4", + "choices": [], + "usage": {"prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30}, + } + + otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj) + + from litellm.proxy._types import SpanAttributes + + recorded = dict(call[0] for call in mock_span.set_attribute.call_args_list if len(call[0]) == 2) + + assert recorded.get(SpanAttributes.LLM_REQUEST_TEMPERATURE.value) == 0 + assert recorded.get(SpanAttributes.LLM_REQUEST_TOP_P.value) == 0 + assert recorded.get(SpanAttributes.LLM_REQUEST_MAX_TOKENS.value) == 0 + + class TestOpenTelemetryProviderInitialization(unittest.TestCase): """Test suite for verifying provider initialization respects existing providers"""