From cbf9d87ca676d4ce28941e328ea1639b028fdd86 Mon Sep 17 00:00:00 2001 From: Nausherwan Korai <230245508+naushkorai@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:52:56 +0000 Subject: [PATCH] fix(otel): address CI type/format checks, credential registry, and 100% coverage --- litellm/integrations/otel/README.md | 4 +-- litellm/integrations/otel/logger.py | 1 + litellm/integrations/otel/model/metadata.py | 29 ++++++++++++++++--- litellm/integrations/otel/model/payloads.py | 3 +- litellm/proxy/auth/auth_utils.py | 2 +- .../otel/test_otel_v2_vendor_mappers.py | 11 +++++++ .../test_callback_management_endpoints.py | 1 + .../proxy/test_credential_slot_registry.py | 2 ++ .../src/components/callback_info_helpers.tsx | 3 +- 9 files changed, 47 insertions(+), 9 deletions(-) diff --git a/litellm/integrations/otel/README.md b/litellm/integrations/otel/README.md index 06c2da117b5..28ef489cfc5 100644 --- a/litellm/integrations/otel/README.md +++ b/litellm/integrations/otel/README.md @@ -322,7 +322,7 @@ nothing here imports outside it: global set with `excluded_services` in their own `callback_vars`; `"none"` means no datastore spans are excluded, while an omitted value follows the global set. These destination overrides require OTel v2 and are accepted only - on `langfuse_otel`, `arize`, `weave_otel` and `newrelic` callbacks. + on `langfuse_otel`, `arize`, `weave_otel`, `newrelic` and `mixpanel` callbacks. - [`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. @@ -341,7 +341,7 @@ nothing here imports outside it: - `genai` — the canonical OpenTelemetry GenAI vocabulary, always present. - `legacy` — an additional vocabulary using the older semconv-ai / Traceloop attribute key names, for backends that read those. - - `openinference`, `langfuse`, `weave`, `langtrace` — vendor vocabularies. + - `openinference`, `langfuse`, `weave`, `langtrace`, `mixpanel` — vendor vocabularies. - `resolve_mappers(names)` turns config names into mapper instances. ### Plumbing (`plumbing/`) diff --git a/litellm/integrations/otel/logger.py b/litellm/integrations/otel/logger.py index 5092b248eac..108813aea92 100644 --- a/litellm/integrations/otel/logger.py +++ b/litellm/integrations/otel/logger.py @@ -600,6 +600,7 @@ class OpenTelemetryV2(CustomLogger): trace=call.trace, session_id=call.session_id, metadata_keys=tuple(self.config.baggage_metadata_keys), + request_metadata=call.user_id_metadata, ) end_time_ns: Final = to_ns(end_time) if carrier is not None and carrier.span is not None: diff --git a/litellm/integrations/otel/model/metadata.py b/litellm/integrations/otel/model/metadata.py index 483bad2505d..b23751046ee 100644 --- a/litellm/integrations/otel/model/metadata.py +++ b/litellm/integrations/otel/model/metadata.py @@ -109,7 +109,11 @@ class RequestIdentity: ) @classmethod - def from_payload(cls, payload: StandardLoggingPayload) -> RequestIdentity: + def from_payload( + cls, + payload: StandardLoggingPayload, + request_metadata: Mapping[str, str] = MappingProxyType({}), + ) -> RequestIdentity: """Parse caller identity out of a closed request's payload metadata. ``provider_model`` is resolved here too (see :func:`resolve_provider_model`) @@ -119,7 +123,7 @@ class RequestIdentity: raw_meta: Final = cast(Mapping[str, object], payload.get("metadata") or {}) payload_view: Final = as_str_mapping(payload) params: Final = as_str_mapping(payload_view.get("model_parameters")) if payload_view is not None else None - metadata: Final = MappingProxyType(dict(flatten_metadata(raw_meta))) + metadata: Final = MappingProxyType({**request_metadata, **dict(flatten_metadata(raw_meta))}) return cls( call_id=as_str(payload.get("litellm_call_id")) or as_str(payload.get("id")), # StandardLoggingMetadata's canonical key is ``user_api_key_team_id``; @@ -201,7 +205,11 @@ class RequestContext: return self.identity.provider_model @classmethod - def from_standard_logging_payload(cls, payload: StandardLoggingPayload) -> RequestContext: + def from_standard_logging_payload( + cls, + payload: StandardLoggingPayload, + request_metadata: Mapping[str, str] = MappingProxyType({}), + ) -> RequestContext: raw_meta: Final = cast(Mapping[str, object], payload.get("metadata") or {}) hidden: Final = cast(Mapping[str, object], payload.get("hidden_params") or {}) raw_response: Final = payload.get("response") @@ -216,7 +224,7 @@ class RequestContext: model_group=model_group, model_id=as_str(payload.get("model_id")) or _model_info_id(raw_meta.get("model_info")), api_base=as_str(payload.get("api_base")) or as_str(hidden.get("api_base")), - identity=RequestIdentity.from_payload(payload), + identity=RequestIdentity.from_payload(payload, request_metadata=request_metadata), ) @@ -283,6 +291,7 @@ class LLMCallEvent: completion_start_seconds: float | None trace: TraceControls session_id: str | None + user_id_metadata: Mapping[str, str] = field(default_factory=lambda: MappingProxyType({})) @classmethod def from_dict(cls, kwargs: Mapping[str, object]) -> LLMCallEvent: @@ -306,9 +315,21 @@ class LLMCallEvent: completion_start_seconds=_epoch_seconds(kwargs.get("completion_start_time")), trace=trace, session_id=caller_session_id(kwargs, trace), + user_id_metadata=caller_user_id_metadata(kwargs), ) +def caller_user_id_metadata(kwargs: Mapping[str, object]) -> Mapping[str, str]: + params: Final = as_str_mapping(kwargs.get("litellm_params")) + if params is None: + return MappingProxyType({}) + bodies: Final = ( + *(flatten_metadata(meta) if (meta := as_str_mapping(params.get("metadata"))) is not None else ()), + *(flatten_metadata(lm_meta) if (lm_meta := as_str_mapping(params.get("litellm_metadata"))) is not None else ()), + ) + return MappingProxyType({key: value for key, value in bodies if key in _USER_ID_METADATA_KEYS}) + + def _epoch_seconds(value: object) -> float | None: return to_seconds(value) if isinstance(value, (datetime, float, int, str)) and not isinstance(value, bool) else None diff --git a/litellm/integrations/otel/model/payloads.py b/litellm/integrations/otel/model/payloads.py index 9c62c45e489..32dbc06de4a 100644 --- a/litellm/integrations/otel/model/payloads.py +++ b/litellm/integrations/otel/model/payloads.py @@ -458,13 +458,14 @@ class LLMCallSpanData: session_id: str | None = None, *, metadata_keys: tuple[str, ...] = (), + request_metadata: Mapping[str, str] = MappingProxyType({}), ) -> LLMCallSpanData: params: Final = cast(Mapping[str, object], payload.get("model_parameters") or {}) cache_hit: Final = as_bool(payload.get("cache_hit")) is True # pyright: ignore[reportUnknownMemberType] # TypedDict .get on a str key is partially unknown # The single parse of the request's metadata — the request-vs-provider # model split, the response model, api base, and identity all come from # here rather than being re-derived from the raw payload dicts. - context: Final = RequestContext.from_standard_logging_payload(payload) + context: Final = RequestContext.from_standard_logging_payload(payload, request_metadata=request_metadata) metadata: Final = payload.get("metadata") decision: Final = metadata["routing_decision"] if metadata and "routing_decision" in metadata else None # Normalize ``response`` to a dict once so the content/id reads below are a diff --git a/litellm/proxy/auth/auth_utils.py b/litellm/proxy/auth/auth_utils.py index aa02828e0d0..40085ea66e0 100644 --- a/litellm/proxy/auth/auth_utils.py +++ b/litellm/proxy/auth/auth_utils.py @@ -1922,7 +1922,7 @@ def get_end_user_id_from_request_body( # Check 5: 'metadata.user_id' or 'metadata.user.id' in request_body (another common pattern) metadata_dict: Final = _as_dict(request_body.get("metadata")) user_id_str = _coerce_user_id_to_str(metadata_dict.get("user_id")) or _coerce_user_id_to_str( - metadata_dict.get("user.id") + metadata_dict.get("user.id") # pyright: ignore[reportUnknownMemberType,reportUnknownArgumentType] # _as_dict returns untyped dict ) if user_id_str: return user_id_str diff --git a/tests/unit/integrations/otel/test_otel_v2_vendor_mappers.py b/tests/unit/integrations/otel/test_otel_v2_vendor_mappers.py index 29b0e2472ce..ee36e229e9c 100644 --- a/tests/unit/integrations/otel/test_otel_v2_vendor_mappers.py +++ b/tests/unit/integrations/otel/test_otel_v2_vendor_mappers.py @@ -856,3 +856,14 @@ def test_mixpanel_mapper_stamps_user_id_and_omits_when_absent(): ) assert MixpanelMapper().map(mcp_span) == {"user.id": "mcp_user"} assert MixpanelMapper().map(_llm_call()) == {} + + from litellm.integrations.otel.model.metadata import LLMCallEvent + + sdk_event = LLMCallEvent.from_dict( + { + "litellm_params": {"metadata": {"user.id": "sdk_user_789", "ignored_key": "x"}}, + "standard_logging_object": {"id": "c3", "model": "gpt-4o", "metadata": {}}, + } + ) + assert sdk_event.user_id_metadata == {"user.id": "sdk_user_789"} + assert LLMCallEvent.from_dict({}).user_id_metadata == {} diff --git a/tests/unit/proxy/management_endpoints/test_callback_management_endpoints.py b/tests/unit/proxy/management_endpoints/test_callback_management_endpoints.py index f0c41fe058c..a08f0a78558 100644 --- a/tests/unit/proxy/management_endpoints/test_callback_management_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_callback_management_endpoints.py @@ -542,6 +542,7 @@ class TestMixpanelCallbackValidation: with pytest.raises(HTTPException) as exc: raise_on_invalid_key_logging_config(self._key_metadata({"mixpanel_region": "eu"})) assert "requires mixpanel_project_token" in str(exc.value.detail) + _validate_team_callback(self._team_data({})) _validate_team_callback(self._team_data({"mixpanel_project_token": "tok", "mixpanel_region": "in"})) raise_on_invalid_key_logging_config( self._key_metadata({"mixpanel_project_token": "tok", "mixpanel_region": "EU"}) diff --git a/tests/unit/proxy/test_credential_slot_registry.py b/tests/unit/proxy/test_credential_slot_registry.py index 6332038feb8..07d102f5a52 100644 --- a/tests/unit/proxy/test_credential_slot_registry.py +++ b/tests/unit/proxy/test_credential_slot_registry.py @@ -102,6 +102,8 @@ CALLBACK_PARAM_CLASSIFICATION: Final[Mapping[str, Classification]] = MappingProx "newrelic_region": NotSecret("region name"), "signoz_ingestion_key": Unplanted(), "signoz_ingestion_endpoint": NotSecret("sink endpoint URL"), + "mixpanel_project_token": Unplanted(), + "mixpanel_region": NotSecret("region name"), "turn_off_message_logging": NotSecret("boolean logging switch"), "litellm_disabled_callbacks": NotSecret("list of callback names"), } diff --git a/ui/litellm-dashboard/src/components/callback_info_helpers.tsx b/ui/litellm-dashboard/src/components/callback_info_helpers.tsx index ec6780b9e8f..8760a99d365 100644 --- a/ui/litellm-dashboard/src/components/callback_info_helpers.tsx +++ b/ui/litellm-dashboard/src/components/callback_info_helpers.tsx @@ -100,7 +100,8 @@ export const CALLBACK_CONFIGS: CallbackConfig[] = [ mixpanel_project_token: "password", mixpanel_region: "text", }, - description: "Mixpanel Agent Intelligence OpenTelemetry Integration. Setup: https://docs.mixpanel.com/docs/agent-intelligence", + description: + "Mixpanel Agent Intelligence OpenTelemetry Integration. Setup: https://docs.mixpanel.com/docs/agent-intelligence", }, { id: "newrelic",