diff --git a/litellm/integrations/otel/README.md b/litellm/integrations/otel/README.md index 338afe04e5e..d71e1575452 100644 --- a/litellm/integrations/otel/README.md +++ b/litellm/integrations/otel/README.md @@ -181,7 +181,12 @@ nothing here imports outside it: free-form metadata is promoted until each sub-key is explicitly allowlisted. - [`baggage.py`](./model/baggage.py) — the single definition of which request-identity values are promoted into Baggage (so child spans inherit them) and under which - attribute keys. + attribute keys. The requested model is promoted as the vendor + `litellm.request.model`, never canonical `gen_ai.request.model`: baggage lands + on every span of the request, and a canonical GenAI attribute on an HTTP, DB or + guardrail span makes GenAI-aware backends read that span as an LLM generation. + `gen_ai.request.model` stays on the LLM-call span only, where the `genai` + mapper stamps it. - [`utils.py`](./model/utils.py) — value coercion, JSON serialization, and extractor-table application, shared across the package. diff --git a/litellm/integrations/otel/model/baggage.py b/litellm/integrations/otel/model/baggage.py index 2be9bb36def..1727e5409eb 100644 --- a/litellm/integrations/otel/model/baggage.py +++ b/litellm/integrations/otel/model/baggage.py @@ -32,19 +32,25 @@ _PROMOTABLE: Final[dict[str, Callable[[RequestIdentity, str | None, tuple[str, . ), LiteLLM.KEY_HASH: lambda identity, model, team_metadata_keys: identity.key_hash, LiteLLM.END_USER: lambda identity, model, team_metadata_keys: identity.end_user, + LiteLLM.REQUEST_MODEL: lambda identity, model, team_metadata_keys: model, GenAI.REQUEST_MODEL: lambda identity, model, team_metadata_keys: model, LiteLLM.PROVIDER_MODEL: lambda identity, model, team_metadata_keys: identity.provider_model, } # Keys promoted by default (a subset of ``_PROMOTABLE``). ``END_USER`` is # promotable but off by default — it identifies an individual user, so stamping -# it onto every span is opt-in via ``config.baggage_promoted_keys``. +# it onto every span is opt-in via ``config.baggage_promoted_keys``. The +# requested model is promoted as the vendor ``litellm.request.model``: baggage +# lands on every span of the request, and canonical ``gen_ai.request.model`` on +# an HTTP/DB/guardrail span makes GenAI-aware backends (e.g. Langfuse) render +# that span as an LLM generation. ``GenAI.REQUEST_MODEL`` stays promotable for +# operators who deliberately want the canonical key everywhere. BAGGAGE_PROMOTED_KEYS: Final[tuple[str, ...]] = ( LiteLLM.TEAM_ID, LiteLLM.TEAM_ALIAS, LiteLLM.TEAM_METADATA, LiteLLM.KEY_HASH, - GenAI.REQUEST_MODEL, + LiteLLM.REQUEST_MODEL, LiteLLM.PROVIDER_MODEL, ) diff --git a/litellm/integrations/otel/model/semconv.py b/litellm/integrations/otel/model/semconv.py index 4ad0cb5d1b4..ad35906908b 100644 --- a/litellm/integrations/otel/model/semconv.py +++ b/litellm/integrations/otel/model/semconv.py @@ -292,6 +292,11 @@ class LiteLLM: # The model string litellm actually sent to the provider (the deployment's # ``litellm_params.model``), distinct from the user-facing ``gen_ai.request.model``. PROVIDER_MODEL: Final = "litellm.provider.model" + # The requested model, promoted onto non-GenAI spans (server, DB, guardrail) + # for correlation. Canonical ``gen_ai.request.model`` stays on GenAI spans + # only, so a GenAI-aware backend can't read an HTTP or DB span as an LLM + # generation. + REQUEST_MODEL: Final = "litellm.request.model" REQUEST_STREAMING: Final = "litellm.request.streaming" TOOLS_DECLARED: Final = "litellm.request.tools.declared" GUARDRAIL_NAME: Final = "litellm.guardrail.name" diff --git a/tests/test_litellm/integrations/otel/test_otel_v2_baggage.py b/tests/test_litellm/integrations/otel/test_otel_v2_baggage.py index b379b8bebc9..046041b085e 100644 --- a/tests/test_litellm/integrations/otel/test_otel_v2_baggage.py +++ b/tests/test_litellm/integrations/otel/test_otel_v2_baggage.py @@ -74,13 +74,54 @@ def test_identity_promoted_onto_every_span(): for span in spans: assert span.attributes.get(LiteLLM.TEAM_ID) == "t1" assert span.attributes.get(LiteLLM.TEAM_ALIAS) == "team one" - assert span.attributes.get(GenAI.REQUEST_MODEL) == "gpt-4o" + assert span.attributes.get(LiteLLM.REQUEST_MODEL) == "gpt-4o" + + +def test_canonical_request_model_stays_off_non_llm_spans(): + """The requested model rides Baggage as the vendor ``litellm.request.model``. + Canonical ``gen_ai.request.model`` must appear only on the LLM-call span, + which the mapper stamps it on; a GenAI-aware backend reading it off the + SERVER, guardrail or service span would render those as LLM generations.""" + engine, exporter = _engine_and_exporter() + data = LLMCallSpanData.from_standard_logging_payload(_payload()) + bag = promoted_baggage(data.identity, data.request_model, BAGGAGE_PROMOTED_KEYS) + assert GenAI.REQUEST_MODEL not in bag + + ctx = ctx_mod.set_request_baggage(bag) + root = engine.start_span(SpanRole.PROXY_REQUEST, "POST /chat/completions", ctx) + root_ctx = ctx_mod.context_from_span(root, ctx) + engine.emit(SpanRole.LLM_CALL, data, parent_context=root_ctx) + engine.emit( + SpanRole.GUARDRAIL, GuardrailSpanData("presidio", status="success"), root_ctx + ) + engine.emit(SpanRole.SERVICE, ServiceSpanData("redis", call_type="set"), root_ctx) + root.end() + + spans = {span.name: span for span in exporter.get_finished_spans()} + llm = spans["chat gpt-4o"] + non_llm = [span for name, span in spans.items() if name != "chat gpt-4o"] + assert len(non_llm) == 3 + assert llm.attributes.get(GenAI.REQUEST_MODEL) == "gpt-4o" + for span in non_llm: + assert GenAI.REQUEST_MODEL not in span.attributes + assert span.attributes.get(LiteLLM.REQUEST_MODEL) == "gpt-4o" + + +def test_canonical_request_model_promotable_when_explicitly_allowlisted(): + """Operators who want the canonical key everywhere can still opt in by + listing it in ``baggage_promoted_keys``.""" + data = LLMCallSpanData.from_standard_logging_payload(_payload()) + bag = promoted_baggage( + data.identity, data.request_model, (GenAI.REQUEST_MODEL, LiteLLM.TEAM_ID) + ) + assert bag[GenAI.REQUEST_MODEL] == "gpt-4o" + assert LiteLLM.REQUEST_MODEL not in bag def test_team_metadata_promoted_only_for_allowlisted_subkeys(): """Allowlisted team-metadata sub-keys are promoted (JSON) onto every span; non-allowlisted sub-keys are excluded, alongside the provider/underlying - model name and the user-facing ``gen_ai.request.model``.""" + model name and the user-facing ``litellm.request.model``.""" import json engine, exporter = _engine_and_exporter() @@ -99,7 +140,7 @@ def test_team_metadata_promoted_only_for_allowlisted_subkeys(): assert json.loads(span.attributes[LiteLLM.TEAM_METADATA]) == {"tier": "gold"} # provider model is distinct from the user-facing request model assert span.attributes.get(LiteLLM.PROVIDER_MODEL) == "azure/my-deployment" - assert span.attributes.get(GenAI.REQUEST_MODEL) == "gpt-4o" + assert span.attributes.get(LiteLLM.REQUEST_MODEL) == "gpt-4o" def test_team_metadata_not_promoted_by_default(): diff --git a/tests/test_litellm/integrations/otel/test_otel_v2_sources_of_truth.py b/tests/test_litellm/integrations/otel/test_otel_v2_sources_of_truth.py index baa72b5a7fe..bd7ffd69359 100644 --- a/tests/test_litellm/integrations/otel/test_otel_v2_sources_of_truth.py +++ b/tests/test_litellm/integrations/otel/test_otel_v2_sources_of_truth.py @@ -895,7 +895,9 @@ def test_promoted_baggage_is_bounded_allowlist(): promoted = promoted_baggage(identity, "gpt-4o", BAGGAGE_PROMOTED_KEYS) assert promoted[LiteLLM.TEAM_ID] == "t1" assert promoted[LiteLLM.TEAM_ALIAS] == "team one" - assert promoted[GenAI.REQUEST_MODEL] == "gpt-4o" + assert promoted[LiteLLM.REQUEST_MODEL] == "gpt-4o" + # canonical gen_ai.* is not promoted: it would mark non-GenAI spans as LLM ops + assert GenAI.REQUEST_MODEL not in promoted # allowlisted metadata sub-key is promoted under the litellm.metadata.* prefix assert promoted[f"{LiteLLM.METADATA_PREFIX}user_api_key_org_id"] == "org1" # full metadata blob is NOT promoted