fix(otel): record sampling params explicitly set to zero on spans

The OTel and Arize integrations gated gen_ai.request.temperature,
top_p and max_tokens on truthiness, so a request that explicitly set
any of them to 0 produced a span with the attribute missing entirely.
temperature=0 is the common case for deterministic output, which made
it indistinguishable from never setting temperature at all.

Gate on `is not None` instead, matching what the semconv path in the
same module and the langtrace and traceloop integrations already do.

Verified against a live proxy: with temperature=0 and max_tokens=16 in
one request, the non-zero max_tokens was recorded before and after
while the zero temperature only appears after the change.
This commit is contained in:
singularity-14 2026-10-01 09:23:12 +05:30
parent ae05f7d2c1
commit 2cc5033532
4 changed files with 95 additions and 29 deletions

View file

@ -529,12 +529,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)))

View file

@ -2469,29 +2469,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

View file

@ -181,6 +181,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).

View file

@ -402,6 +402,47 @@ class TestOpenTelemetryCostBreakdown(unittest.TestCase):
assert ("gen_ai.cost.original_cost", 0.004) not 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"""