From 0440a334f47ecfae307f5c4d5527c011b27e1000 Mon Sep 17 00:00:00 2001 From: apex-mochen <2756823972@qq.com> Date: Sun, 11 Oct 2026 00:04:00 +0800 Subject: [PATCH 1/5] fix(otel): record tool calls as GenAI parts and preserve tool_calls finish_reason (#45796) Root cause: _transform_messages_to_otel_semantic_conventions dropped tool calls entirely - "tool_calls" in msg is False for ModelResponse Message objects (pydantic), and even for plain dicts the key was copied at the top level instead of being converted to GenAI tool_call parts. The streaming chunk builder also defaulted the aggregated finish_reason to "stop" when no chunk surfaced an explicit finish_reason, so single-chunk tool-call streams were logged with finish_reason="stop" instead of "tool_calls". Fix: - opentelemetry.py: normalize non-dict messages via _to_dict; convert tool_calls into GenAI parts {"type": "tool_call", id, name, arguments}. - streaming_chunk_builder_utils.py: track whether any chunk carried tool calls and fall back to "tool_calls" when finish_reason is still "stop". Signed-off-by: apex-mochen <2756823972@qq.com> --- litellm/integrations/opentelemetry.py | 26 ++++++++++++++-- .../streaming_chunk_builder_utils.py | 30 ++++++++++++++++--- 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index 90383ddb60d..148b43c203a 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -2783,6 +2783,15 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): transformed: Final = [] 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 + role = msg.get("role", "user") content = msg.get("content", "") parts = [] @@ -2797,11 +2806,24 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): else: parts.append({"type": "text", "content": str(part)}) + # 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 []: + 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) + 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) + parts.append({"type": "tool_call", "id": tc_id, "name": fn_name, "arguments": fn_arguments}) + transformed_msg = {"role": role, "parts": parts} if "id" in msg: transformed_msg["id"] = msg["id"] - if "tool_calls" in msg: - transformed_msg["tool_calls"] = msg["tool_calls"] if "tool_call_id" in msg: transformed_msg["tool_call_id"] = msg["tool_call_id"] transformed.append(transformed_msg) diff --git a/litellm/litellm_core_utils/streaming_chunk_builder_utils.py b/litellm/litellm_core_utils/streaming_chunk_builder_utils.py index 2ceb0042f1f..b12cc49be18 100644 --- a/litellm/litellm_core_utils/streaming_chunk_builder_utils.py +++ b/litellm/litellm_core_utils/streaming_chunk_builder_utils.py @@ -412,15 +412,37 @@ class ChunkProcessor: role: Final = ChunkProcessor._get_role_from_chunks(chunks) finish_reason = "stop" + saw_tool_call = False 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) + if delta is not None: + delta_tool_calls = ( + delta.get("tool_calls") if isinstance(delta, dict) else getattr(delta, "tool_calls", None) + ) + if delta_tool_calls: + saw_tool_call = True + message = choice.get("message") if isinstance(choice, dict) else getattr(choice, "message", None) + if message is not None: + msg_tool_calls = ( + message.get("tool_calls") if isinstance(message, dict) else getattr(message, "tool_calls", None) + ) + if msg_tool_calls: + saw_tool_call = True chunk_finish_reason = None - if hasattr(chunk["choices"][0], "finish_reason"): - chunk_finish_reason = chunk["choices"][0].finish_reason - elif "finish_reason" in chunk["choices"][0]: - chunk_finish_reason = chunk["choices"][0]["finish_reason"] + if hasattr(choice, "finish_reason"): + chunk_finish_reason = choice.finish_reason + elif "finish_reason" in choice: + chunk_finish_reason = choice["finish_reason"] if chunk_finish_reason is not None: finish_reason = chunk_finish_reason + if finish_reason == "stop" and saw_tool_call: + # A tool-calling stream whose chunks never surfaced an explicit + # finish_reason must aggregate to "tool_calls" (OpenAI spec), + # not the default "stop" - otherwise callbacks (e.g. OpenTelemetry) + # record a wrong finish reason for single-chunk streams. (litellm#45796) + finish_reason = "tool_calls" # Initialize the response dictionary response = ModelResponse( From f8de7a731241d59109892f49841fe278ff6ce0cd Mon Sep 17 00:00:00 2001 From: dingdangmao <2756823972@qq.com> Date: Sun, 11 Oct 2026 11:53:50 +0800 Subject: [PATCH 2/5] test(otel): cover tool-call parts and streamed finish-reason aggregation Regression tests for #45796: - `_transform_messages_to_otel_semantic_conventions` emits GenAI `{"type": "tool_call", id/name/arguments}` parts for both dict and pydantic messages (previously dropped for non-dict objects and never emitted as parts at all). - `ChunkProcessor.build_base_response` aggregates a stream whose chunks carried tool-call deltas but no explicit finish_reason to "tool_calls" instead of the default "stop". Signed-off-by: apex-mochen <2756823972@qq.com> --- .../integrations/test_otel_tool_call_parts.py | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 tests/unit/integrations/test_otel_tool_call_parts.py diff --git a/tests/unit/integrations/test_otel_tool_call_parts.py b/tests/unit/integrations/test_otel_tool_call_parts.py new file mode 100644 index 00000000000..52bed152314 --- /dev/null +++ b/tests/unit/integrations/test_otel_tool_call_parts.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- +"""Regression tests for #45796: OTEL tool-call parts + streamed finish reason. + +Covered behavior: +- ``_transform_messages_to_otel_semantic_conventions`` emits GenAI + ``{"type": "tool_call", "id", "name", "arguments"}`` parts for assistant + messages carrying ``tool_calls`` — for plain dicts and for Pydantic + message objects (which used to be skipped because ``"tool_calls" in msg`` + is False for non-dict objects). +- Plain string ``content`` still emits a text part (no regression). +- ``ChunkProcessor.build_base_response`` aggregates a stream whose chunks + carried tool-call deltas but no explicit ``finish_reason`` to + ``"tool_calls"`` instead of the default ``"stop"``. +""" +from pydantic import BaseModel + +from litellm.integrations.opentelemetry import OpenTelemetry +from litellm.litellm_core_utils.streaming_chunk_builder_utils import ChunkProcessor + + +class _FakeAssistantMessage(BaseModel): + role: str = "assistant" + content: str | None = None + tool_calls: list | None = None + + +class TestOtelToolCallParts: + def test_dict_message_with_tool_calls_emits_genai_tool_call_parts(self): + msg = { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": {"name": "get_weather", "arguments": '{"city": "SF"}'}, + } + ], + } + out = OpenTelemetry()._transform_messages_to_otel_semantic_conventions([msg])[0] + assert out["parts"] == [ + {"type": "tool_call", "id": "call_1", "name": "get_weather", "arguments": '{"city": "SF"}'} + ] + assert "tool_calls" not in out + + def test_pydantic_message_with_tool_calls_emits_genai_tool_call_parts(self): + msg = _FakeAssistantMessage( + tool_calls=[ + { + "id": "call_2", + "type": "function", + "function": {"name": "lookup", "arguments": "{}"}, + } + ] + ) + out = OpenTelemetry()._transform_messages_to_otel_semantic_conventions([msg])[0] + assert out["parts"] == [ + {"type": "tool_call", "id": "call_2", "name": "lookup", "arguments": "{}"} + ] + + def test_string_content_still_emits_text_part(self): + out = OpenTelemetry()._transform_messages_to_otel_semantic_conventions( + [{"role": "assistant", "content": "hello"}] + )[0] + assert out["parts"] == [{"type": "text", "content": "hello"}] + + def test_streamed_tool_call_without_explicit_finish_reason_aggregates_to_tool_calls(self): + chunks = [ + { + "object": "chat.completion.chunk", + "created": 0, + "model": "mock", + "choices": [{"index": 0, "delta": {"role": "assistant", "content": None}}], + }, + { + "choices": [ + { + "index": 0, + "delta": { + "tool_calls": [ + { + "id": "c1", + "type": "function", + "function": {"name": "f", "arguments": "{}"}, + } + ] + }, + } + ] + }, + ] + resp = ChunkProcessor(chunks).build_base_response(chunks) + assert resp.choices[0].finish_reason == "tool_calls" From 087b08ec177a854a046d6ad6c1e480534f914331 Mon Sep 17 00:00:00 2001 From: apex-mochen <2756823972@qq.com> Date: Mon, 12 Oct 2026 08:59:51 +0800 Subject: [PATCH 3/5] fix(otel): suppress LIT010 for finish-reason aggregation locals saw_tool_call and the final finish_reason rebind are both intentional accumulators; annotate them as rebind-ok so the type-discipline gate sees no growth (LIT010 ceiling). Signed-off-by: apex-mochen <2756823972@qq.com> --- litellm/litellm_core_utils/streaming_chunk_builder_utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/litellm/litellm_core_utils/streaming_chunk_builder_utils.py b/litellm/litellm_core_utils/streaming_chunk_builder_utils.py index b12cc49be18..45929387419 100644 --- a/litellm/litellm_core_utils/streaming_chunk_builder_utils.py +++ b/litellm/litellm_core_utils/streaming_chunk_builder_utils.py @@ -412,7 +412,7 @@ class ChunkProcessor: role: Final = ChunkProcessor._get_role_from_chunks(chunks) finish_reason = "stop" - saw_tool_call = False + saw_tool_call = False # rebind-ok: flipped to True inside the loop when a tool-call delta or message is seen for chunk in chunks: if "choices" in chunk and len(chunk["choices"]) > 0: choice = chunk["choices"][0] @@ -442,7 +442,7 @@ class ChunkProcessor: # finish_reason must aggregate to "tool_calls" (OpenAI spec), # not the default "stop" - otherwise callbacks (e.g. OpenTelemetry) # record a wrong finish reason for single-chunk streams. (litellm#45796) - finish_reason = "tool_calls" + finish_reason = "tool_calls" # rebind-ok: aggregated finish reason for tool-calling streams (litellm#45796) # Initialize the response dictionary response = ModelResponse( 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 4/5] 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: From a8f6b07eb5fc5ece9537f66d247fec528d6c53f2 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 14:35:24 +0800 Subject: [PATCH 5/5] fix(otel): make type-discipline suppressions survive ruff formatting - Keep mutable-ok / cast-ok suppressions on the same line as the annotated or cast expression (shorten reasons to stay under the 120-col ruff limit) so LIT013 does not report them as dead. - Resolve the Responses-API content loop via cast() to a Sequence[object] variable instead of an inline conditional, which surfaced a reportUnknownArgumentType on the merge-base delta. - Return type stays list[...] (mutable-ok) to match the OTELGenAISemconvMixin base signature; only the parameter widens to Sequence. - type-discipline gate: no LIT rule grew past merge-base; basedpyright per-rule deltas are all negative. Signed-off-by: apex-mochen <2756823972@qq.com> --- litellm/integrations/opentelemetry.py | 45 ++++++++++--------- .../streaming_chunk_builder_utils.py | 6 +-- 2 files changed, 27 insertions(+), 24 deletions(-) diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index d15b1c6a5bd..1533a88cf74 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -1,7 +1,7 @@ import os import threading from collections import OrderedDict -from collections.abc import Callable, Iterable, Mapping +from collections.abc import Callable, Iterable, Mapping, Sequence from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass, field from datetime import datetime @@ -2768,8 +2768,8 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): span.set_attribute(key, primitive_value) def _transform_messages_to_otel_semantic_conventions( - self, messages: list[dict[str, object]] | str - ) -> list[dict[str, object]]: + self, messages: Sequence[dict[str, object]] | str + ) -> list[dict[str, object]]: # mutable-ok: match mixin base return """ Transforms LiteLLM/OpenAI style messages into OTEL GenAI 1.38 compliant format. OTEL expects a 'parts' array instead of a single 'content' string. @@ -2783,7 +2783,7 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): } ] - transformed: Final[list[dict[str, object]]] = [] + transformed: Final = [] 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 @@ -2791,17 +2791,17 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): normalized = self._to_dict(msg) if normalized is None: continue - msg = cast(dict[str, object], normalized) + msg = cast(dict[str, object], normalized) # cast-ok: _to_dict guarantees a dict-like payload role = msg.get("role", "user") content = msg.get("content", "") - parts: list[dict[str, object]] = [] + parts: list[dict[str, object]] = [] # mutable-ok: local accumulator returned read-only if isinstance(content, str): parts.append({"type": "text", "content": content}) elif isinstance(content, list): # Handle multi-modal content if necessary - for part in cast(list[object], content): + for part in cast(list[object], content): # cast-ok: content already narrowed to list if isinstance(part, dict): parts.append(part) else: @@ -2810,14 +2810,14 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): # Record tool calls as GenAI tool_call parts (OpenLLMetry-compatible) # so id / name / arguments are not dropped from the trace. raw_tool_calls = msg.get("tool_calls") - tool_calls: list[object] = raw_tool_calls if isinstance(raw_tool_calls, list) else [] + tool_calls: Sequence[object] = raw_tool_calls if isinstance(raw_tool_calls, list) else () for tc in tool_calls: if isinstance(tc, dict): - tc_dict = cast(dict[str, object], tc) + tc_dict = cast(dict[str, object], tc) # cast-ok: tc narrowed to dict tc_id = tc_dict.get("id") fn = tc_dict.get("function") if isinstance(fn, dict): - fn_dict = cast(dict[str, object], fn) + fn_dict = cast(dict[str, object], fn) # cast-ok: fn narrowed to dict fn_name = fn_dict.get("name") fn_arguments = fn_dict.get("arguments") else: @@ -2827,17 +2827,17 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): # 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)) + tc_id = cast(object, getattr(tc, "id", None)) # cast-ok: getattr returns object + fn = cast(object, getattr(tc, "function", None)) # cast-ok: getattr returns object 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)) + fn_name = cast(object, getattr(fn, "name", None)) # cast-ok: getattr returns object + fn_arguments = cast(object, getattr(fn, "arguments", None)) # cast-ok: getattr returns object parts.append({"type": "tool_call", "id": tc_id, "name": fn_name, "arguments": fn_arguments}) - transformed_msg: dict[str, object] = {"role": role, "parts": parts} + transformed_msg: dict[str, object] = {"role": role, "parts": parts} # mutable-ok: key-assigned below if "id" in msg: transformed_msg["id"] = msg["id"] if "tool_call_id" in msg: @@ -2904,21 +2904,24 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): This method converts them to the same ``{"role": ..., "parts": [...]}`` format used by ``_transform_choices_to_otel_semantic_conventions``. """ - transformed: Final[list[dict[str, object]]] = [] + transformed: Final = [] for raw_item in output: item = self._to_dict(raw_item) if item is None: continue - item = cast(dict[str, object], item) + item = cast(dict[str, object], item) # cast-ok: _to_dict guarantees a dict-like payload if item.get("type") == "message": role = item.get("role", "assistant") - parts: list[dict[str, object]] = [] + parts: list[dict[str, object]] = [] # mutable-ok: local accumulator returned read-only raw_content_list = item.get("content", []) - for raw_content in cast(list[object], raw_content_list) if isinstance(raw_content_list, list) else []: + content_items = cast( # cast-ok: narrowed to list above + Sequence[object], raw_content_list if isinstance(raw_content_list, list) else () + ) + for raw_content in content_items: content = self._to_dict(raw_content) if content is None: continue - content = cast(dict[str, object], content) + content = cast(dict[str, object], content) # cast-ok: _to_dict guarantees a dict-like payload if content.get("type") == "output_text": text = content.get("text", "") if text: @@ -2927,7 +2930,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[str, object] = { + part: dict[str, object] = { # mutable-ok: assembled then key-assigned before append "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 9b961839a5c..d98103e0652 100644 --- a/litellm/litellm_core_utils/streaming_chunk_builder_utils.py +++ b/litellm/litellm_core_utils/streaming_chunk_builder_utils.py @@ -389,7 +389,7 @@ class ChunkProcessor: 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") + return cast(dict[str, object], payload).get("tool_calls") # cast-ok: payload narrowed to dict if isinstance(payload, Delta): return payload.tool_calls return None @@ -457,9 +457,9 @@ class ChunkProcessor: if hasattr(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)) + chunk_finish_reason = cast(str, getattr(choice, "finish_reason", None)) # cast-ok: dynamic attr elif "finish_reason" in choice: - chunk_finish_reason = cast(str, choice["finish_reason"]) + chunk_finish_reason = cast(str, choice["finish_reason"]) # cast-ok: key checked if chunk_finish_reason is not None: finish_reason = chunk_finish_reason if finish_reason == "stop" and saw_tool_call: