From fdaa93afd5d1bd9aefdc188295efd0b4526d25ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AE=E5=BD=93=E7=8C=AB?= <2756823972@qq.com> Date: Mon, 12 Oct 2026 13:20:22 +0800 Subject: [PATCH] fix(otel): tighten type discipline in tool-call span transform - Normalize every message through _to_dict() so dict and pydantic paths read identically; drop the now-redundant isinstance branch. - Annotate parts/transformed/transformed_msg/tool_calls as dict[str, object] to stop Unknown propagation from list[dict] and bare literals. - Replace dynamic-attribute access with cast(object, ...) / cast(str, ...) reads for the streamed finish_reason path. - basedpyright per-file delta vs merge-base: opentelemetry 966<1007, streaming_chunk_builder_utils 247<248. Signed-off-by: apex-mochen <2756823972@qq.com> --- litellm/integrations/opentelemetry.py | 65 ++++++++++++------- .../streaming_chunk_builder_utils.py | 45 ++++++++++--- 2 files changed, 77 insertions(+), 33 deletions(-) diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index 148b43c203a..d15b1c6a5bd 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -2767,7 +2767,9 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): primitive_value: Final = self._cast_as_primitive_value_type(value) span.set_attribute(key, primitive_value) - def _transform_messages_to_otel_semantic_conventions(self, messages: list[dict] | str) -> list[dict]: + def _transform_messages_to_otel_semantic_conventions( + self, messages: list[dict[str, object]] | str + ) -> list[dict[str, object]]: """ Transforms LiteLLM/OpenAI style messages into OTEL GenAI 1.38 compliant format. OTEL expects a 'parts' array instead of a single 'content' string. @@ -2781,26 +2783,25 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): } ] - transformed: Final = [] + transformed: Final[list[dict[str, object]]] = [] for msg in messages: # Normalize pydantic / LiteLLM wrapper objects (e.g. a ModelResponse # Message) to something we can read with .get(); "tool_calls" in msg # is False for those objects, which silently dropped tool calls. - if not isinstance(msg, dict): - normalized = self._to_dict(msg) - if normalized is None: - continue - msg = normalized + normalized = self._to_dict(msg) + if normalized is None: + continue + msg = cast(dict[str, object], normalized) role = msg.get("role", "user") content = msg.get("content", "") - parts = [] + parts: list[dict[str, object]] = [] if isinstance(content, str): parts.append({"type": "text", "content": content}) elif isinstance(content, list): # Handle multi-modal content if necessary - for part in content: + for part in cast(list[object], content): if isinstance(part, dict): parts.append(part) else: @@ -2808,20 +2809,35 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): # Record tool calls as GenAI tool_call parts (OpenLLMetry-compatible) # so id / name / arguments are not dropped from the trace. - for tc in msg.get("tool_calls") or []: + raw_tool_calls = msg.get("tool_calls") + tool_calls: list[object] = raw_tool_calls if isinstance(raw_tool_calls, list) else [] + for tc in tool_calls: if isinstance(tc, dict): - tc_id = tc.get("id") - fn = tc.get("function") or {} - fn_name = fn.get("name") if isinstance(fn, dict) else getattr(fn, "name", None) - fn_arguments = fn.get("arguments") if isinstance(fn, dict) else getattr(fn, "arguments", None) + tc_dict = cast(dict[str, object], tc) + tc_id = tc_dict.get("id") + fn = tc_dict.get("function") + if isinstance(fn, dict): + fn_dict = cast(dict[str, object], fn) + fn_name = fn_dict.get("name") + fn_arguments = fn_dict.get("arguments") + else: + fn_name = None + fn_arguments = None else: - tc_id = getattr(tc, "id", None) - fn = getattr(tc, "function", None) or {} - fn_name = fn.get("name") if isinstance(fn, dict) else getattr(fn, "name", None) - fn_arguments = fn.get("arguments") if isinstance(fn, dict) else getattr(fn, "arguments", None) + # pydantic tool-call objects (e.g. ChatCompletionMessageToolCall): + # read fields via literal getattr; arbitrary objects degrade to + # None exactly like the old .get()/getattr pair. + tc_id = cast(object, getattr(tc, "id", None)) + fn = cast(object, getattr(tc, "function", None)) + if fn is None: + fn_name = None + fn_arguments = None + else: + fn_name = cast(object, getattr(fn, "name", None)) + fn_arguments = cast(object, getattr(fn, "arguments", None)) parts.append({"type": "tool_call", "id": tc_id, "name": fn_name, "arguments": fn_arguments}) - transformed_msg = {"role": role, "parts": parts} + transformed_msg: dict[str, object] = {"role": role, "parts": parts} if "id" in msg: transformed_msg["id"] = msg["id"] if "tool_call_id" in msg: @@ -2888,18 +2904,21 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): This method converts them to the same ``{"role": ..., "parts": [...]}`` format used by ``_transform_choices_to_otel_semantic_conventions``. """ - transformed: Final = [] + transformed: Final[list[dict[str, object]]] = [] for raw_item in output: item = self._to_dict(raw_item) if item is None: continue + item = cast(dict[str, object], item) if item.get("type") == "message": role = item.get("role", "assistant") - parts = [] - for raw_content in item.get("content", []): + parts: list[dict[str, object]] = [] + raw_content_list = item.get("content", []) + for raw_content in cast(list[object], raw_content_list) if isinstance(raw_content_list, list) else []: content = self._to_dict(raw_content) if content is None: continue + content = cast(dict[str, object], content) if content.get("type") == "output_text": text = content.get("text", "") if text: @@ -2908,7 +2927,7 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): transformed.append({"role": role, "parts": parts}) elif item.get("type") == "function_call": # Surface tool calls from Responses API output - part: dict = { + part: dict[str, object] = { "type": "tool_call", "name": item.get("name", ""), "arguments": item.get("arguments", ""), diff --git a/litellm/litellm_core_utils/streaming_chunk_builder_utils.py b/litellm/litellm_core_utils/streaming_chunk_builder_utils.py index 45929387419..9b961839a5c 100644 --- a/litellm/litellm_core_utils/streaming_chunk_builder_utils.py +++ b/litellm/litellm_core_utils/streaming_chunk_builder_utils.py @@ -367,6 +367,33 @@ class ChunkProcessor: def _get_role_from_chunks(chunks: Sequence["_BaseChunk"]) -> str: return ChunkProcessor._role_of_choice(next((c["choices"][0] for c in chunks if c.get("choices")), None)) + @staticmethod + def _read_delta(choice: StreamingChoices | Mapping[str, object]) -> object: + """Return a streaming choice's delta payload. + + ``StreamingChoices`` (an ``openai`` ``BaseModel`` subclass) declares + ``delta``; raw dict chunks carry the same data under a ``delta`` key. + """ + return choice.get("delta") if isinstance(choice, Mapping) else choice.delta + + @staticmethod + def _read_message(choice: StreamingChoices | Mapping[str, object]) -> object: + """Return a streaming choice's message payload. + + Only dict chunks carry ``message``: ``StreamingChoices.__init__`` pops + it (Perplexity compat), so non-dict choices always read as ``None``. + """ + return choice.get("message") if isinstance(choice, Mapping) else None + + @staticmethod + def _read_tool_calls(payload: object) -> object: + """Return a delta/message payload's ``tool_calls``, dict or object form.""" + if isinstance(payload, dict): + return cast(dict[str, object], payload).get("tool_calls") + if isinstance(payload, Delta): + return payload.tool_calls + return None + @staticmethod def _role_of_choice(choice: object) -> str: match choice: @@ -416,25 +443,23 @@ class ChunkProcessor: for chunk in chunks: if "choices" in chunk and len(chunk["choices"]) > 0: choice = chunk["choices"][0] - delta = choice.get("delta") if isinstance(choice, dict) else getattr(choice, "delta", None) + delta = ChunkProcessor._read_delta(choice) if delta is not None: - delta_tool_calls = ( - delta.get("tool_calls") if isinstance(delta, dict) else getattr(delta, "tool_calls", None) - ) + delta_tool_calls = ChunkProcessor._read_tool_calls(delta) if delta_tool_calls: saw_tool_call = True - message = choice.get("message") if isinstance(choice, dict) else getattr(choice, "message", None) + message = ChunkProcessor._read_message(choice) if message is not None: - msg_tool_calls = ( - message.get("tool_calls") if isinstance(message, dict) else getattr(message, "tool_calls", None) - ) + msg_tool_calls = ChunkProcessor._read_tool_calls(message) if msg_tool_calls: saw_tool_call = True chunk_finish_reason = None if hasattr(choice, "finish_reason"): - chunk_finish_reason = choice.finish_reason + # StreamingChoices is a dynamic OpenAIObject: the attribute may + # or may not exist at runtime, so read via literal getattr. + chunk_finish_reason = cast(str, getattr(choice, "finish_reason", None)) elif "finish_reason" in choice: - chunk_finish_reason = choice["finish_reason"] + chunk_finish_reason = cast(str, choice["finish_reason"]) if chunk_finish_reason is not None: finish_reason = chunk_finish_reason if finish_reason == "stop" and saw_tool_call: