diff --git a/litellm/integrations/otel/__init__.py b/litellm/integrations/otel/__init__.py index d7627d4d63d..c1d159a9526 100644 --- a/litellm/integrations/otel/__init__.py +++ b/litellm/integrations/otel/__init__.py @@ -58,6 +58,7 @@ from litellm.integrations.otel.model.semconv import ( Metric, Network, NetworkTransport, + OpenAI, RpcSystem, Server, resolve_operation, @@ -102,6 +103,7 @@ __all__ = [ "Metric", "Network", "NetworkTransport", + "OpenAI", "OpenTelemetryV2Config", "ProxyRequestSpanData", "RequestContext", diff --git a/litellm/integrations/otel/mappers/genai.py b/litellm/integrations/otel/mappers/genai.py index 4e07d090edd..95bacc231bf 100644 --- a/litellm/integrations/otel/mappers/genai.py +++ b/litellm/integrations/otel/mappers/genai.py @@ -34,6 +34,7 @@ from litellm.integrations.otel.model.semconv import ( GenAI, JsonRpc, LiteLLM, + OpenAI, RpcSystem, Server, ) @@ -63,6 +64,8 @@ class GenAIMapper: GenAI.RESPONSE_ID: lambda d: d.response_id, GenAI.RESPONSE_FINISH_REASONS: lambda d: list(d.finish_reasons) if d.finish_reasons else None, GenAI.RESPONSE_TIME_TO_FIRST_CHUNK: lambda d: d.time_to_first_chunk_seconds, + OpenAI.REQUEST_SERVICE_TIER: lambda d: d.requested_service_tier, + OpenAI.RESPONSE_SERVICE_TIER: lambda d: d.served_service_tier, GenAI.USAGE_INPUT_TOKENS: lambda d: d.usage.input_tokens, GenAI.USAGE_OUTPUT_TOKENS: lambda d: d.usage.output_tokens, GenAI.USAGE_CACHE_CREATION_INPUT_TOKENS: lambda d: d.usage.cache_creation_input_tokens, diff --git a/litellm/integrations/otel/model/payloads.py b/litellm/integrations/otel/model/payloads.py index 7a62ed6aed9..35a111e085e 100644 --- a/litellm/integrations/otel/model/payloads.py +++ b/litellm/integrations/otel/model/payloads.py @@ -36,6 +36,7 @@ from litellm.integrations.otel.model.utils import ( as_str_tuple, ) from litellm.integrations.otel.routing import RoutingAttributeValue, routing_decision_attributes +from litellm.litellm_core_utils.service_tier_utils import get_requested_service_tier, get_served_service_tier # ``RequestIdentity`` and the request-metadata translation now live in # :mod:`metadata`; re-exported here so existing ``model.payloads`` imports keep @@ -450,6 +451,8 @@ class LLMCallSpanData: routing_attributes: Mapping[str, RoutingAttributeValue] = field(default_factory=lambda: MappingProxyType({})) cache_hit: bool | None = None saved_cache_cost: float | None = None + requested_service_tier: str | None = None + served_service_tier: str | None = None @classmethod def from_standard_logging_payload( @@ -531,6 +534,8 @@ class LLMCallSpanData: routing_attributes=routing_decision_attributes(decision), cache_hit=cache_hit, saved_cache_cost=as_float(payload.get("saved_cache_cost")) if cache_hit else None, # pyright: ignore[reportUnknownMemberType] # TypedDict .get on a str key is partially unknown + requested_service_tier=get_requested_service_tier(payload), + served_service_tier=get_served_service_tier(payload), ) diff --git a/litellm/integrations/otel/model/semconv.py b/litellm/integrations/otel/model/semconv.py index 8d186a2715a..5a5568dba1a 100644 --- a/litellm/integrations/otel/model/semconv.py +++ b/litellm/integrations/otel/model/semconv.py @@ -133,6 +133,14 @@ class GenAI: PROMPT_NAME: Final = "gen_ai.prompt.name" +class OpenAI: + """OpenAI-namespace keys from the GenAI semconv registry. Carries the service tier for every + provider, not only OpenAI.""" + + REQUEST_SERVICE_TIER: Final = "openai.request.service_tier" + RESPONSE_SERVICE_TIER: Final = "openai.response.service_tier" + + class MCP: """OTel GenAI MCP (Model Context Protocol) span-attribute keys. diff --git a/tests/unit/integrations/otel/test_otel_v2_emitter.py b/tests/unit/integrations/otel/test_otel_v2_emitter.py index 775b1ad38de..269512d1b6b 100644 --- a/tests/unit/integrations/otel/test_otel_v2_emitter.py +++ b/tests/unit/integrations/otel/test_otel_v2_emitter.py @@ -183,6 +183,64 @@ def test_llm_call_span_golden(): assert span.status.status_code is StatusCode.UNSET +_REQUEST_TIER: Final = "openai.request.service_tier" +_RESPONSE_TIER: Final = "openai.response.service_tier" + + +@pytest.mark.parametrize( + ("overrides", "expected_request_tier", "expected_response_tier"), + [ + pytest.param( + { + "model_parameters": {"service_tier": "flex"}, + "response": {"id": "resp_1", "service_tier": "flex", "choices": [{"finish_reason": "stop"}]}, + }, + "flex", + "flex", + id="requested-and-served", + ), + pytest.param( + { + "model_parameters": {"service_tier": "auto"}, + "response": {"id": "resp_1", "service_tier": "priority", "choices": [{"finish_reason": "stop"}]}, + }, + "auto", + "priority", + id="provider-picks-the-tier", + ), + pytest.param( + {"metadata": {"usage_object": {"service_tier": "standard"}}}, + None, + "standard", + id="served-tier-on-usage", + ), + pytest.param( + {"model_parameters": {"service_tier": "priority"}}, + "priority", + None, + id="requested-only", + ), + pytest.param( + {"model_parameters": {"service_tier": "not-a-tier"}}, + None, + None, + id="unknown-requested-tier", + ), + pytest.param({}, None, None, id="no-tier"), + ], +) +def test_llm_call_span_service_tier( + overrides: Mapping[str, object], expected_request_tier: str | None, expected_response_tier: str | None +) -> None: + engine, exporter = _engine() + + engine.emit(SpanRole.LLM_CALL, LLMCallSpanData.from_standard_logging_payload(_payload(**overrides))) + + (span,) = exporter.get_finished_spans() + assert span.attributes.get(_REQUEST_TIER) == expected_request_tier + assert span.attributes.get(_RESPONSE_TIER) == expected_response_tier + + def test_legacy_dual_emit_on(): engine, exporter = _engine(legacy_compat=True) engine.emit(SpanRole.LLM_CALL, LLMCallSpanData.from_standard_logging_payload(_payload())) diff --git a/tests/unit/integrations/otel/test_otel_v2_sources_of_truth.py b/tests/unit/integrations/otel/test_otel_v2_sources_of_truth.py index 2d0b4701c9f..8ca5464789b 100644 --- a/tests/unit/integrations/otel/test_otel_v2_sources_of_truth.py +++ b/tests/unit/integrations/otel/test_otel_v2_sources_of_truth.py @@ -159,11 +159,11 @@ def _all_constants(cls): def test_attribute_keys_are_unique_across_namespaces(): - from litellm.integrations.otel import MCP, Client, JsonRpc, LiteLLMError, Network + from litellm.integrations.otel import MCP, Client, JsonRpc, LiteLLMError, Network, OpenAI # prefixes are allowed to be substrings; exact keys must not collide. exact = set() - for cls in (GenAI, Error, LiteLLMError, Server, HTTP, DB, MCP, JsonRpc, Network, Client): + for cls in (GenAI, Error, LiteLLMError, Server, HTTP, DB, MCP, JsonRpc, Network, Client, OpenAI): for key in _all_constants(cls): assert key not in exact, f"duplicate attribute key {key}" exact.add(key)