From 02792a3cb919224f4a74c791ff8429d0302b0f65 Mon Sep 17 00:00:00 2001 From: Yamac Ay Date: Tue, 20 Oct 2026 12:55:06 +0200 Subject: [PATCH 1/5] fix(sap): reasoning_content normalized additionally, support for reasoning_effort, thinking, denormalization using thinking_blocks --- litellm/llms/sap/chat/handler.py | 114 +++++- litellm/llms/sap/chat/models.py | 8 + litellm/llms/sap/chat/transformation.py | 11 +- .../llms/sap/chat/test_sap_transformation.py | 338 ++++++++++++++++++ 4 files changed, 449 insertions(+), 22 deletions(-) diff --git a/litellm/llms/sap/chat/handler.py b/litellm/llms/sap/chat/handler.py index b4f4d4faddb..028c0273420 100755 --- a/litellm/llms/sap/chat/handler.py +++ b/litellm/llms/sap/chat/handler.py @@ -23,15 +23,89 @@ class GenAIHubOrchestrationError(BaseLLMException): self.message = message -# ------------------------------- -# Stream parsing helpers -# ------------------------------- +# ---------------------------------- +# Parsing and transformation helpers +# ---------------------------------- def _now_ts() -> int: return int(time.time()) +def normalize_choice(choice: dict[str, object]) -> dict[str, object]: + """Normalize a single choice dict (message or delta shape) in isolation.""" + for key in ("message", "delta"): + carrier = choice.get(key) + if not isinstance(carrier, dict): + continue + reasoning_content = carrier.get("reasoning_content") + if not isinstance(reasoning_content, list): + continue + thinking_blocks: Final[ + list[dict[str, object]] + ] = [ # mutable-ok: consumed immediately by the two carrier assignments below; never escapes as mutable + { + "type": "thinking", + "thinking": item.get("content") or "", + "signature": item.get("signature"), + } + for item in reasoning_content + if isinstance(item, dict) + ] + carrier["thinking_blocks"] = thinking_blocks + carrier["reasoning_content"] = ( + "\n".join(str(block["thinking"]) for block in thinking_blocks if block["thinking"]) or None + ) + return choice + + +def normalize_reasoning_content(raw: dict[str, object]) -> dict[str, object]: + """Transform reasoning_content from SAP Orchestration format [{"content": ..., "signature": ...}] to Optional[str] and a thinking_blocks list.""" + choices: Final[list[object]] = list( + raw.get("choices") or [] + ) # mutable-ok: intermediate wire list, immediately consumed by the comprehension below + normalized_choices: Final[ + list[dict[str, object]] + ] = [ # mutable-ok: wire list passed to pydantic model_validate via normalized_result + normalize_choice(choice) for choice in choices if isinstance(choice, dict) + ] + normalized_result: Final[dict[str, object]] = { + **raw, + "choices": normalized_choices, + } # mutable-ok: wire dict consumed by pydantic model_validate + return normalized_result + + +def denormalize_reasoning_content(message: dict[str, object]) -> dict[str, object]: + """Inverse of normalize_reasoning_content: rebuild list[ReasoningBlock] from thinking_blocks. + + LiteLLM normalizes SAP's reasoning_content list into a str + thinking_blocks before + returning a ModelResponse. When that response is replayed as a history message the dict + must be converted back to the SAP shape before SAPAssistantMessage validation. + + If thinking_blocks is absent but reasoning_content is a plain str (e.g. from a + non-SAP provider), drop the field: we cannot reconstruct SAP ReasoningBlocks without + the original signatures. + """ + if message.get("role") != "assistant": + return message + thinking_blocks = message.get("thinking_blocks") + if isinstance(thinking_blocks, list): + reasoning_content: Final[ + list[dict[str, str | None]] + ] = [ # mutable-ok: wire list passed to validate_dict which expects list[ReasoningBlock] + {"content": block.get("thinking") or "", "signature": block.get("signature")} + for block in thinking_blocks + if isinstance(block, dict) + ] + return {k: v for k, v in message.items() if k not in ("thinking_blocks", "reasoning_content")} | { + "reasoning_content": reasoning_content + } + if isinstance(message.get("reasoning_content"), str): + return {k: v for k, v in message.items() if k not in ("thinking_blocks", "reasoning_content")} + return message + + def _is_terminal_chunk(chunk: OpenAIChatCompletionChunk) -> bool: """OpenAI-shaped chunk is terminal if any choice has a non-None finish_reason.""" try: @@ -56,20 +130,22 @@ class _StreamParser: return None return OpenAIChatCompletionChunk.model_validate( - { - "id": orc.get("id") or evt.get("request_id") or "stream-chunk", - "object": orc.get("object") or "chat.completion.chunk", - "created": orc.get("created") or evt.get("created") or _now_ts(), - "model": orc.get("model") or "unknown", - "choices": [ - { - "index": c.get("index", 0), - "delta": c.get("delta") or {}, - "finish_reason": c.get("finish_reason"), - } - for c in (orc.get("choices") or []) - ], - } + normalize_reasoning_content( + { + "id": orc.get("id") or evt.get("request_id") or "stream-chunk", + "object": orc.get("object") or "chat.completion.chunk", + "created": orc.get("created") or evt.get("created") or _now_ts(), + "model": orc.get("model") or "unknown", + "choices": [ + { + "index": choice.get("index", 0), + "delta": choice.get("delta") or {}, + "finish_reason": choice.get("finish_reason"), + } + for choice in (orc.get("choices") or []) + ], + } + ) ) @staticmethod @@ -93,7 +169,7 @@ class _StreamParser: # ensure it looks like an OpenAI chunk if "object" not in fr: fr["object"] = "chat.completion.chunk" - return OpenAIChatCompletionChunk.model_validate(fr) + return OpenAIChatCompletionChunk.model_validate(normalize_reasoning_content(fr)) # Orchestration incremental delta if "orchestration_result" in event_obj: @@ -101,7 +177,7 @@ class _StreamParser: # Already an OpenAI-like chunk if "choices" in event_obj and "object" in event_obj: - return OpenAIChatCompletionChunk.model_validate(event_obj) + return OpenAIChatCompletionChunk.model_validate(normalize_reasoning_content(event_obj)) # Unknown / heartbeat / metrics return None diff --git a/litellm/llms/sap/chat/models.py b/litellm/llms/sap/chat/models.py index f2b31d4f9b0..ec8ec6a6ecc 100644 --- a/litellm/llms/sap/chat/models.py +++ b/litellm/llms/sap/chat/models.py @@ -133,11 +133,19 @@ class SAPUserMessage(LiteLLMBaseModel): content: str | TextContent | ImageContent | list[TextContent | ImageContent] +class ReasoningBlock( + LiteLLMBaseModel +): # frozen-ok: adding model_config would override LiteLLMBaseModel's defer_build setting + content: str | None = None + signature: str | None = None + + class SAPAssistantMessage(LiteLLMBaseModel): role: Literal["assistant"] = "assistant" content: str | TextContent | list[TextContent] = "" refusal: str = "" tool_calls: list[MessageToolCall] = Field(default=[]) + reasoning_content: list[ReasoningBlock] | None = None class SAPToolChatMessage(LiteLLMBaseModel): diff --git a/litellm/llms/sap/chat/transformation.py b/litellm/llms/sap/chat/transformation.py index 8a7baf18e8f..1c9732c2b75 100755 --- a/litellm/llms/sap/chat/transformation.py +++ b/litellm/llms/sap/chat/transformation.py @@ -30,6 +30,8 @@ from .handler import ( AsyncSAPStreamIterator, GenAIHubOrchestrationError, SAPStreamIterator, + denormalize_reasoning_content, + normalize_reasoning_content, ) from .models import ( ChatCompletionTool, @@ -105,8 +107,9 @@ def _messages_to_sap_template(messages: list[AllMessageValues]) -> list: template: Final = [] for message in messages: folded_message = _fold_message_cache_control(message) - message_role_class = _message_role_mapping(str(folded_message["role"])) - validated_message = validate_dict(folded_message, message_role_class) + denormalized_message = denormalize_reasoning_content(folded_message) + message_role_class = _message_role_mapping(str(denormalized_message["role"])) + validated_message = validate_dict(denormalized_message, message_role_class) template.append(validated_message) return template @@ -226,7 +229,9 @@ class GenAIHubOrchestrationConfig(OpenAIGPTConfig): "functions", "extra_headers", "parallel_tool_calls", + "reasoning_effort", "response_format", + "thinking", "timeout", ] # Remove response_format for providers that don't support it on SAP GenAI Hub @@ -407,7 +412,7 @@ class GenAIHubOrchestrationConfig(OpenAIGPTConfig): original_response=raw_response.text, additional_args={"complete_input_dict": request_data}, ) - response = ModelResponse.model_validate(raw_response.json()["final_result"]) + response = ModelResponse.model_validate(normalize_reasoning_content(raw_response.json()["final_result"])) # Strip markdown code blocks if JSON response_format was used with Anthropic models # SAP GenAI Hub with Anthropic models sometimes wraps JSON in ```json ... ``` diff --git a/tests/unit/llms/sap/chat/test_sap_transformation.py b/tests/unit/llms/sap/chat/test_sap_transformation.py index 41e75b7bfc7..6b60261e8d0 100644 --- a/tests/unit/llms/sap/chat/test_sap_transformation.py +++ b/tests/unit/llms/sap/chat/test_sap_transformation.py @@ -923,3 +923,341 @@ class TestCacheControl: content = self._template(body)[0]["content"] assert "cache_control" not in content[0] assert content[1].get("cache_control") == {"type": "ephemeral"} + +class TestNormalizeReasoningContent: + """Unit tests for normalize_reasoning_content.""" + + from litellm.llms.sap.chat.handler import normalize_reasoning_content + + _normalize_reasoning_content = staticmethod(normalize_reasoning_content) + + def test_list_reasoning_content_mapped_to_thinking_blocks(self): + """List-shaped reasoning_content is converted to thinking_blocks.""" + raw = { + "choices": [{ + "message": { + "role": "assistant", + "content": "Latin.", + "reasoning_content": [ + {"content": "Romans spoke Latin.", "signature": "sig1"}, + {"content": "That is well known.", "signature": "sig2"}, + ], + } + }] + } + out = self._normalize_reasoning_content(raw) + msg = out["choices"][0]["message"] + assert msg["thinking_blocks"] == [ + {"type": "thinking", "thinking": "Romans spoke Latin.", "signature": "sig1"}, + {"type": "thinking", "thinking": "That is well known.", "signature": "sig2"}, + ] + assert msg["reasoning_content"] == "Romans spoke Latin.\nThat is well known." + + def test_string_reasoning_content_unchanged(self): + """String reasoning_content is left as-is (already the right type).""" + raw = { + "choices": [{ + "message": { + "role": "assistant", + "content": "42", + "reasoning_content": "I thought about it.", + } + }] + } + out = self._normalize_reasoning_content(raw) + msg = out["choices"][0]["message"] + assert msg["reasoning_content"] == "I thought about it." + assert "thinking_blocks" not in msg + + def test_no_reasoning_content_unchanged(self): + """A message without reasoning_content is not modified.""" + raw = {"choices": [{"message": {"role": "assistant", "content": "Hi."}}]} + out = self._normalize_reasoning_content(raw) + assert out == raw + + def test_empty_list_reasoning_content_sets_none(self): + """An empty list produces None for reasoning_content and empty thinking_blocks.""" + raw = {"choices": [{"message": {"reasoning_content": []}}]} + out = self._normalize_reasoning_content(raw) + msg = out["choices"][0]["message"] + assert msg["thinking_blocks"] == [] + assert msg["reasoning_content"] is None + + def test_multiple_choices_all_normalized(self): + """All choices in the response are normalized.""" + raw = { + "choices": [ + {"message": {"reasoning_content": [{"content": "thought A", "signature": None}]}}, + {"message": {"reasoning_content": [{"content": "thought B", "signature": "s"}]}}, + ] + } + out = self._normalize_reasoning_content(raw) + assert out["choices"][0]["message"]["reasoning_content"] == "thought A" + assert out["choices"][1]["message"]["reasoning_content"] == "thought B" + + + def test_null_content_in_block_uses_empty_string(self): + """Explicit null content value must not leak None into thinking field.""" + raw = { + "choices": [{ + "message": { + "reasoning_content": [{"content": None, "signature": "s"}], + } + }] + } + out = self._normalize_reasoning_content(raw) + block = out["choices"][0]["message"]["thinking_blocks"][0] + assert block["thinking"] == "" + assert out["choices"][0]["message"]["reasoning_content"] is None + + def test_transform_response_normalizes_list_reasoning_content(self): + """Production path: transform_response must produce a ModelResponse + with thinking_blocks populated when the raw payload carries a + list-shaped reasoning_content. + """ + import json + from unittest.mock import MagicMock + + from litellm.llms.sap.chat.transformation import GenAIHubOrchestrationConfig + + config = GenAIHubOrchestrationConfig() + + final_result = { + "id": "chatcmpl-test", + "object": "chat.completion", + "created": 1700000000, + "model": "gemini-test", + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": "The answer is 42.", + "reasoning_content": [ + {"content": "Let me think.", "signature": "sig1"}, + {"content": "Yes, 42.", "signature": "sig2"}, + ], + }, + "finish_reason": "stop", + }], + "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}, + } + + raw_response = MagicMock() + raw_response.text = json.dumps({"final_result": final_result}) + raw_response.json.return_value = {"final_result": final_result} + + response = config.transform_response( + model="gemini-test", + raw_response=raw_response, + model_response=MagicMock(), + logging_obj=MagicMock(), + api_key="test", + request_data={}, + messages=[], + optional_params={}, + litellm_params={}, + encoding=None, + ) + + choice = response.choices[0] + assert hasattr(choice.message, "thinking_blocks"), "thinking_blocks missing from message" + assert choice.message.thinking_blocks == [ + {"type": "thinking", "thinking": "Let me think.", "signature": "sig1"}, + {"type": "thinking", "thinking": "Yes, 42.", "signature": "sig2"}, + ] + assert choice.message.reasoning_content == "Let me think.\nYes, 42." + + +class TestNormalizeChoice: + """Unit tests for normalize_choice (message and delta shapes).""" + + from litellm.llms.sap.chat.handler import normalize_choice, normalize_reasoning_content + + _normalize_choice = staticmethod(normalize_choice) + _normalize_reasoning_content = staticmethod(normalize_reasoning_content) + + def test_message_list_reasoning_content_normalized(self): + choice = { + "index": 0, + "message": {"role": "assistant", "content": "hi", "reasoning_content": [{"content": "thought", "signature": "s1"}]}, + "finish_reason": "stop", + } + result = self._normalize_choice(choice) + msg = result["message"] + assert msg["reasoning_content"] == "thought" + assert msg["thinking_blocks"] == [{"type": "thinking", "thinking": "thought", "signature": "s1"}] + + def test_delta_list_reasoning_content_normalized(self): + choice = { + "index": 0, + "delta": {"role": "assistant", "reasoning_content": [{"content": "delta thought", "signature": None}]}, + "finish_reason": None, + } + result = self._normalize_choice(choice) + delta = result["delta"] + assert delta["reasoning_content"] == "delta thought" + assert delta["thinking_blocks"][0]["thinking"] == "delta thought" + + def test_string_reasoning_content_unchanged(self): + choice = {"index": 0, "message": {"reasoning_content": "already a string"}} + assert self._normalize_choice(choice) == choice + + def test_no_reasoning_content_unchanged(self): + choice = {"index": 0, "delta": {"content": "hello"}} + assert self._normalize_choice(choice) == choice + + def test_normalize_reasoning_content_covers_delta_path(self): + raw = { + "id": "c1", + "object": "chat.completion.chunk", + "choices": [ + {"index": 0, "delta": {"reasoning_content": [{"content": "stream thought", "signature": "sig"}]}, "finish_reason": None} + ], + } + result = self._normalize_reasoning_content(raw) + delta = result["choices"][0]["delta"] + assert delta["reasoning_content"] == "stream thought" + assert delta["thinking_blocks"][0]["type"] == "thinking" + + +class TestMessagesToSapTemplateReasoningContent: + """_messages_to_sap_template must forward reasoning_content on assistant turns.""" + + def test_assistant_message_with_reasoning_content_is_preserved(self): + from litellm.llms.sap.chat.transformation import _messages_to_sap_template + + messages = [ + {"role": "user", "content": "What is 22?"}, + { + "role": "assistant", + "content": "4", + "reasoning_content": [ + {"content": "Simple arithmetic.", "signature": "sig1"} + ], + }, + {"role": "user", "content": "Are you sure?"}, + ] + result = _messages_to_sap_template(messages) + assistant_msg = result[1] + assert assistant_msg["reasoning_content"] == [ + {"content": "Simple arithmetic.", "signature": "sig1"} + ] + + def test_litellm_normalized_assistant_message_round_trips_signature(self): + """After a non-streaming SAP call, the ModelResponse has reasoning_content: str + and thinking_blocks: list (LiteLLM native format). Replaying it as a history + message must not raise and must preserve the signature. + """ + from litellm.llms.sap.chat.transformation import _messages_to_sap_template + + signature = "EtwHCkABGAIqQKNbQn0Uz==" + messages = [ + {"role": "user", "content": "Translate it."}, + { + "role": "assistant", + "content": "The text is Lorem Ipsum.", + "reasoning_content": "Lorem ipsum is pseudo-Latin...", + "thinking_blocks": [ + {"type": "thinking", "thinking": "Lorem ipsum is pseudo-Latin...", "signature": signature} + ], + }, + {"role": "user", "content": "Summarise it."}, + ] + + result = _messages_to_sap_template(messages) + assistant = next(m for m in result if m.get("role") == "assistant") + + reasoning = assistant.get("reasoning_content") + assert isinstance(reasoning, list) + assert any(block.get("signature") == signature for block in reasoning) + + def test_streaming_assembled_assistant_message_round_trips_signature(self): + """After a streaming SAP call, normalize_reasoning_content runs on every chunk. + The assembled assistant message has the same LiteLLM-native shape as the + non-streaming case. Verify the signature survives the round-trip. + """ + from litellm.llms.sap.chat.handler import normalize_reasoning_content + from litellm.llms.sap.chat.transformation import _messages_to_sap_template + + signature = "EtwHCkABGAIqQKNbQn0Uz==" + thinking_text = "Lorem ipsum is pseudo-Latin..." + sap_final_result = { + "id": "msg_stream_01", + "object": "chat.completion", + "model": "gpt-4o", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "The text is Lorem Ipsum.", + "reasoning_content": [{"content": thinking_text, "signature": signature}], + }, + "finish_reason": "stop", + } + ], + } + normalized = normalize_reasoning_content(sap_final_result) + assembled_message = {"role": "assistant", **normalized["choices"][0]["message"]} + + messages = [ + {"role": "user", "content": "Translate it."}, + assembled_message, + {"role": "user", "content": "Summarize it."}, + ] + + result = _messages_to_sap_template(messages) + assistant = next(m for m in result if m.get("role") == "assistant") + + reasoning = assistant.get("reasoning_content") + assert isinstance(reasoning, list) + assert any(block.get("signature") == signature for block in reasoning) + + +class TestDenormalizeReasoningContent: + from litellm.llms.sap.chat.handler import denormalize_reasoning_content + + _denormalize = staticmethod(denormalize_reasoning_content) + + def test_thinking_blocks_rebuilt_into_sap_reasoning_content(self): + message = { + "role": "assistant", + "content": "hello", + "thinking_blocks": [{"type": "thinking", "thinking": "step one", "signature": "sig1"}], + "reasoning_content": "step one", + } + result = self._denormalize(message) + assert result["reasoning_content"] == [{"content": "step one", "signature": "sig1"}] + assert "thinking_blocks" not in result + + def test_string_reasoning_content_without_thinking_blocks_is_dropped(self): + message = { + "role": "assistant", + "content": "hello", + "reasoning_content": "some thought from another provider", + } + result = self._denormalize(message) + assert "reasoning_content" not in result + assert "thinking_blocks" not in result + assert result["content"] == "hello" + + def test_non_assistant_message_is_returned_unchanged(self): + message = {"role": "user", "content": "hi", "reasoning_content": "should stay"} + assert self._denormalize(message) is message + + def test_assistant_without_reasoning_fields_is_returned_unchanged(self): + message = {"role": "assistant", "content": "hi"} + assert self._denormalize(message) is message + + def test_validate_dict_accepts_denormalized_message_from_string_reasoning_content(self): + from litellm.llms.sap.chat.models import SAPAssistantMessage + from litellm.llms.sap.chat.transformation import validate_dict + + message = { + "role": "assistant", + "content": "done", + "reasoning_content": "raw string from openai o-series", + } + result = validate_dict(self._denormalize(message), SAPAssistantMessage) + assert result["role"] == "assistant" + assert "reasoning_content" not in result or result.get("reasoning_content") is None From 879d622670fed8d160040f8742650f85e03a3e5d Mon Sep 17 00:00:00 2001 From: Yamac Ay Date: Fri, 23 Oct 2026 12:30:16 +0200 Subject: [PATCH 2/5] suppressed type discipline errors --- litellm/llms/sap/chat/handler.py | 102 ++++++++++++++++-------- litellm/llms/sap/chat/models.py | 10 ++- litellm/llms/sap/chat/transformation.py | 8 +- 3 files changed, 80 insertions(+), 40 deletions(-) diff --git a/litellm/llms/sap/chat/handler.py b/litellm/llms/sap/chat/handler.py index 028c0273420..f0e8c12abe8 100755 --- a/litellm/llms/sap/chat/handler.py +++ b/litellm/llms/sap/chat/handler.py @@ -3,7 +3,7 @@ from __future__ import annotations import json import time from collections.abc import AsyncIterator, Iterator -from typing import Final +from typing import Final, cast # noqa: TID251 # untyped dict keys are casted explicitly import httpx @@ -32,7 +32,9 @@ def _now_ts() -> int: return int(time.time()) -def normalize_choice(choice: dict[str, object]) -> dict[str, object]: +def normalize_choice( + choice: dict[str, object], # mutable-ok: wire dicts from httpx JSON; callers own them +) -> dict[str, object]: # mutable-ok: wire dicts from httpx JSON; callers own them """Normalize a single choice dict (message or delta shape) in isolation.""" for key in ("message", "delta"): carrier = choice.get(key) @@ -41,15 +43,18 @@ def normalize_choice(choice: dict[str, object]) -> dict[str, object]: reasoning_content = carrier.get("reasoning_content") if not isinstance(reasoning_content, list): continue - thinking_blocks: Final[ - list[dict[str, object]] - ] = [ # mutable-ok: consumed immediately by the two carrier assignments below; never escapes as mutable + thinking_blocks: list[dict[str, object]] = [ # mutable-ok: consumed immediately below; never escapes as mutable { "type": "thinking", - "thinking": item.get("content") or "", - "signature": item.get("signature"), + "thinking": cast(dict[str, object], item).get("content") # cast-ok: isinstance(item, dict) guard below + or "", + "signature": cast(dict[str, object], item).get( # cast-ok: isinstance(item, dict) guard below + "signature" + ), } - for item in reasoning_content + for item in cast( # cast-ok: isinstance(reasoning_content, list) guard above + list[object], reasoning_content + ) if isinstance(item, dict) ] carrier["thinking_blocks"] = thinking_blocks @@ -59,24 +64,28 @@ def normalize_choice(choice: dict[str, object]) -> dict[str, object]: return choice -def normalize_reasoning_content(raw: dict[str, object]) -> dict[str, object]: +def normalize_reasoning_content( + raw: dict[str, object], # mutable-ok: wire dicts from httpx JSON; callers own them +) -> dict[str, object]: # mutable-ok: wire dicts from httpx JSON; callers own them """Transform reasoning_content from SAP Orchestration format [{"content": ..., "signature": ...}] to Optional[str] and a thinking_blocks list.""" - choices: Final[list[object]] = list( - raw.get("choices") or [] - ) # mutable-ok: intermediate wire list, immediately consumed by the comprehension below - normalized_choices: Final[ - list[dict[str, object]] - ] = [ # mutable-ok: wire list passed to pydantic model_validate via normalized_result - normalize_choice(choice) for choice in choices if isinstance(choice, dict) + choices: Final[list[object]] = list( # mutable-ok: wire list from httpx JSON + cast(list[object], raw.get("choices") or []) # cast-ok: wire JSON; choices is always a list + ) + normalized_choices: Final[list[dict[str, object]]] = [ # mutable-ok: wire list passed to pydantic model_validate + normalize_choice(cast(dict[str, object], choice)) # cast-ok: isinstance(choice, dict) guard below + for choice in choices + if isinstance(choice, dict) ] - normalized_result: Final[dict[str, object]] = { + normalized_result: Final[dict[str, object]] = { # mutable-ok: wire dict consumed by pydantic model_validate **raw, "choices": normalized_choices, - } # mutable-ok: wire dict consumed by pydantic model_validate + } return normalized_result -def denormalize_reasoning_content(message: dict[str, object]) -> dict[str, object]: +def denormalize_reasoning_content( + message: dict[str, object], # mutable-ok: wire dicts from httpx JSON; callers own them +) -> dict[str, object]: # mutable-ok: wire dicts from httpx JSON; callers own them """Inverse of normalize_reasoning_content: rebuild list[ReasoningBlock] from thinking_blocks. LiteLLM normalizes SAP's reasoning_content list into a str + thinking_blocks before @@ -89,13 +98,21 @@ def denormalize_reasoning_content(message: dict[str, object]) -> dict[str, objec """ if message.get("role") != "assistant": return message - thinking_blocks = message.get("thinking_blocks") + thinking_blocks: Final = message.get("thinking_blocks") if isinstance(thinking_blocks, list): - reasoning_content: Final[ - list[dict[str, str | None]] - ] = [ # mutable-ok: wire list passed to validate_dict which expects list[ReasoningBlock] - {"content": block.get("thinking") or "", "signature": block.get("signature")} - for block in thinking_blocks + reasoning_content: Final[list[dict[str, str | None]]] = [ # mutable-ok: wire list passed to validate_dict + { + "content": str( + cast(dict[str, object], block).get("thinking") or "" # cast-ok: isinstance(block, dict) guard below + ), + "signature": cast( # cast-ok: narrows to str|None + str | None, + cast(dict[str, object], block).get( # cast-ok: isinstance(block, dict) guard; narrows dict + "signature" + ), + ), + } + for block in cast(list[object], thinking_blocks) # cast-ok: isinstance(thinking_blocks, list) guard above if isinstance(block, dict) ] return {k: v for k, v in message.items() if k not in ("thinking_blocks", "reasoning_content")} | { @@ -121,18 +138,22 @@ class _StreamParser: """Normalize orchestration streaming events into OpenAI-like chunks.""" @staticmethod - def _from_orchestration_result(evt: dict) -> OpenAIChatCompletionChunk | None: + def _from_orchestration_result(evt: dict[str, object]) -> OpenAIChatCompletionChunk | None: """ Accepts orchestration_result shape and maps it to an OpenAI-like *chunk*. """ - orc: Final = evt.get("orchestration_result") or {} + orc: Final[dict[str, object]] = cast( # cast-ok: wire JSON; orchestration_result is always a dict + dict[str, object], evt.get("orchestration_result") or {} + ) if not orc: return None return OpenAIChatCompletionChunk.model_validate( normalize_reasoning_content( { - "id": orc.get("id") or evt.get("request_id") or "stream-chunk", + "id": orc.get("id") + or cast(str | None, evt.get("request_id")) # cast-ok: wire JSON string field + or "stream-chunk", "object": orc.get("object") or "chat.completion.chunk", "created": orc.get("created") or evt.get("created") or _now_ts(), "model": orc.get("model") or "unknown", @@ -142,14 +163,16 @@ class _StreamParser: "delta": choice.get("delta") or {}, "finish_reason": choice.get("finish_reason"), } - for choice in (orc.get("choices") or []) + for choice in cast( # cast-ok: wire JSON; choices is always list[dict] + list[dict[str, object]], orc.get("choices") or [] + ) ], } ) ) @staticmethod - def to_openai_chunk(event_obj: dict) -> OpenAIChatCompletionChunk | None: + def to_openai_chunk(event_obj: dict[str, object]) -> OpenAIChatCompletionChunk | None: """ Accepts: - {"final_result": } (IMPORTANT: this is just another chunk, NOT terminal) @@ -169,7 +192,11 @@ class _StreamParser: # ensure it looks like an OpenAI chunk if "object" not in fr: fr["object"] = "chat.completion.chunk" - return OpenAIChatCompletionChunk.model_validate(normalize_reasoning_content(fr)) + return OpenAIChatCompletionChunk.model_validate( + normalize_reasoning_content( + cast(dict[str, object], fr) # cast-ok: final_result wire JSON is always a dict + ) + ) # Orchestration incremental delta if "orchestration_result" in event_obj: @@ -177,7 +204,11 @@ class _StreamParser: # Already an OpenAI-like chunk if "choices" in event_obj and "object" in event_obj: - return OpenAIChatCompletionChunk.model_validate(normalize_reasoning_content(event_obj)) + return OpenAIChatCompletionChunk.model_validate( + normalize_reasoning_content( + cast(dict[str, object], event_obj) # cast-ok: "choices" and "object" keys confirm dict shape + ) + ) # Unknown / heartbeat / metrics return None @@ -324,7 +355,12 @@ class AsyncSAPStreamIterator: # ------------------------------- class GenAIHubOrchestration(BaseLLMHTTPHandler): def _add_stream_param_to_request_body(self, data: dict, provider_config: BaseConfig, fake_stream: bool): - if data.get("config", {}).get("stream", None) is not None: + if ( + cast( # cast-ok: config is always a dict when present + dict[str, object], data.get("config") or {} + ).get("stream") + is not None + ): data["config"]["stream"]["enabled"] = True else: data["config"]["stream"] = {"enabled": True} diff --git a/litellm/llms/sap/chat/models.py b/litellm/llms/sap/chat/models.py index ec8ec6a6ecc..a1954e86b2a 100644 --- a/litellm/llms/sap/chat/models.py +++ b/litellm/llms/sap/chat/models.py @@ -133,9 +133,9 @@ class SAPUserMessage(LiteLLMBaseModel): content: str | TextContent | ImageContent | list[TextContent | ImageContent] -class ReasoningBlock( +class ReasoningBlock( # frozen-ok: adding model_config would override LiteLLMBaseModel's defer_build setting LiteLLMBaseModel -): # frozen-ok: adding model_config would override LiteLLMBaseModel's defer_build setting +): content: str | None = None signature: str | None = None @@ -144,8 +144,10 @@ class SAPAssistantMessage(LiteLLMBaseModel): role: Literal["assistant"] = "assistant" content: str | TextContent | list[TextContent] = "" refusal: str = "" - tool_calls: list[MessageToolCall] = Field(default=[]) - reasoning_content: list[ReasoningBlock] | None = None + tool_calls: list[MessageToolCall] = Field( # mutable-ok: pydantic field; callers append tool calls to this list + default=[] + ) + reasoning_content: list[ReasoningBlock] | None = None # mutable-ok: list of reasoning blocks from SAP wire format class SAPToolChatMessage(LiteLLMBaseModel): diff --git a/litellm/llms/sap/chat/transformation.py b/litellm/llms/sap/chat/transformation.py index 1c9732c2b75..5b6a06edbb1 100755 --- a/litellm/llms/sap/chat/transformation.py +++ b/litellm/llms/sap/chat/transformation.py @@ -107,7 +107,7 @@ def _messages_to_sap_template(messages: list[AllMessageValues]) -> list: template: Final = [] for message in messages: folded_message = _fold_message_cache_control(message) - denormalized_message = denormalize_reasoning_content(folded_message) + denormalized_message = denormalize_reasoning_content(dict(folded_message)) message_role_class = _message_role_mapping(str(denormalized_message["role"])) validated_message = validate_dict(denormalized_message, message_role_class) template.append(validated_message) @@ -412,7 +412,9 @@ class GenAIHubOrchestrationConfig(OpenAIGPTConfig): original_response=raw_response.text, additional_args={"complete_input_dict": request_data}, ) - response = ModelResponse.model_validate(normalize_reasoning_content(raw_response.json()["final_result"])) + response: Final[ModelResponse] = ModelResponse.model_validate( + normalize_reasoning_content(raw_response.json()["final_result"]) + ) # Strip markdown code blocks if JSON response_format was used with Anthropic models # SAP GenAI Hub with Anthropic models sometimes wraps JSON in ```json ... ``` @@ -421,7 +423,7 @@ class GenAIHubOrchestrationConfig(OpenAIGPTConfig): response_format: Final = optional_params.get("response_format", {}) if response_format.get("type") in ("json_object", "json_schema"): if model.startswith("anthropic"): - response = self._strip_markdown_json(response) + return self._strip_markdown_json(response) return response From a99067947a1daeaaf8d9edbe37e0788be754ef8e Mon Sep 17 00:00:00 2001 From: Yamac Ay Date: Fri, 23 Oct 2026 14:07:53 +0200 Subject: [PATCH 3/5] type check --- litellm/llms/sap/chat/handler.py | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/litellm/llms/sap/chat/handler.py b/litellm/llms/sap/chat/handler.py index f0e8c12abe8..8d86d064bb9 100755 --- a/litellm/llms/sap/chat/handler.py +++ b/litellm/llms/sap/chat/handler.py @@ -188,15 +188,14 @@ class _StreamParser: # FINAL RESULT IS *NOT* TERMINAL: treat it as the next chunk if "final_result" in event_obj: - fr: Final = event_obj["final_result"] or {} - # ensure it looks like an OpenAI chunk - if "object" not in fr: - fr["object"] = "chat.completion.chunk" - return OpenAIChatCompletionChunk.model_validate( - normalize_reasoning_content( - cast(dict[str, object], fr) # cast-ok: final_result wire JSON is always a dict - ) - ) + fr_raw: Final = event_obj["final_result"] or {} + if not isinstance(fr_raw, dict): + return None + fr: Final[dict[str, object]] = { # mutable-ok: wire dict consumed by model_validate + **fr_raw, + **(({"object": "chat.completion.chunk"}) if "object" not in fr_raw else {}), + } + return OpenAIChatCompletionChunk.model_validate(normalize_reasoning_content(fr)) # Orchestration incremental delta if "orchestration_result" in event_obj: @@ -204,11 +203,7 @@ class _StreamParser: # Already an OpenAI-like chunk if "choices" in event_obj and "object" in event_obj: - return OpenAIChatCompletionChunk.model_validate( - normalize_reasoning_content( - cast(dict[str, object], event_obj) # cast-ok: "choices" and "object" keys confirm dict shape - ) - ) + return OpenAIChatCompletionChunk.model_validate(normalize_reasoning_content(event_obj)) # Unknown / heartbeat / metrics return None From bfa55155ef29fc961d533ccefa69f0a0f0a04957 Mon Sep 17 00:00:00 2001 From: Yamac Ay Date: Wed, 28 Oct 2026 16:17:52 +0100 Subject: [PATCH 4/5] fix(sap): handle linting issues better --- litellm/llms/sap/chat/handler.py | 44 +++++++++++--------------------- 1 file changed, 15 insertions(+), 29 deletions(-) diff --git a/litellm/llms/sap/chat/handler.py b/litellm/llms/sap/chat/handler.py index 8d86d064bb9..21c657d0e3d 100755 --- a/litellm/llms/sap/chat/handler.py +++ b/litellm/llms/sap/chat/handler.py @@ -3,7 +3,7 @@ from __future__ import annotations import json import time from collections.abc import AsyncIterator, Iterator -from typing import Final, cast # noqa: TID251 # untyped dict keys are casted explicitly +from typing import Final import httpx @@ -46,15 +46,12 @@ def normalize_choice( thinking_blocks: list[dict[str, object]] = [ # mutable-ok: consumed immediately below; never escapes as mutable { "type": "thinking", - "thinking": cast(dict[str, object], item).get("content") # cast-ok: isinstance(item, dict) guard below - or "", - "signature": cast(dict[str, object], item).get( # cast-ok: isinstance(item, dict) guard below + "thinking": item.get("content") or "", # pyright: ignore[reportUnknownMemberType] # isinstance guard in comprehension filter; pyright can't narrow + "signature": item.get( # pyright: ignore[reportUnknownMemberType] # isinstance guard in comprehension filter; pyright can't narrow "signature" ), } - for item in cast( # cast-ok: isinstance(reasoning_content, list) guard above - list[object], reasoning_content - ) + for item in reasoning_content # pyright: ignore[reportUnknownVariableType] # isinstance(reasoning_content, list) guard above; pyright can't narrow into comprehension if isinstance(item, dict) ] carrier["thinking_blocks"] = thinking_blocks @@ -69,10 +66,10 @@ def normalize_reasoning_content( ) -> dict[str, object]: # mutable-ok: wire dicts from httpx JSON; callers own them """Transform reasoning_content from SAP Orchestration format [{"content": ..., "signature": ...}] to Optional[str] and a thinking_blocks list.""" choices: Final[list[object]] = list( # mutable-ok: wire list from httpx JSON - cast(list[object], raw.get("choices") or []) # cast-ok: wire JSON; choices is always a list + raw.get("choices") or [] # pyright: ignore[reportArgumentType, reportUnknownArgumentType] # wire JSON; choices is always a list ) normalized_choices: Final[list[dict[str, object]]] = [ # mutable-ok: wire list passed to pydantic model_validate - normalize_choice(cast(dict[str, object], choice)) # cast-ok: isinstance(choice, dict) guard below + normalize_choice(choice) # pyright: ignore[reportUnknownArgumentType] # isinstance guard in comprehension filter; pyright can't narrow for choice in choices if isinstance(choice, dict) ] @@ -103,16 +100,13 @@ def denormalize_reasoning_content( reasoning_content: Final[list[dict[str, str | None]]] = [ # mutable-ok: wire list passed to validate_dict { "content": str( - cast(dict[str, object], block).get("thinking") or "" # cast-ok: isinstance(block, dict) guard below + block.get("thinking") or "" # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # isinstance guard in comprehension filter; pyright can't narrow ), - "signature": cast( # cast-ok: narrows to str|None - str | None, - cast(dict[str, object], block).get( # cast-ok: isinstance(block, dict) guard; narrows dict - "signature" - ), + "signature": block.get( # pyright: ignore[reportUnknownMemberType] # isinstance guard in comprehension filter; pyright can't narrow + "signature" ), } - for block in cast(list[object], thinking_blocks) # cast-ok: isinstance(thinking_blocks, list) guard above + for block in thinking_blocks # pyright: ignore[reportUnknownVariableType] # isinstance(thinking_blocks, list) guard above; pyright can't narrow into comprehension if isinstance(block, dict) ] return {k: v for k, v in message.items() if k not in ("thinking_blocks", "reasoning_content")} | { @@ -142,9 +136,7 @@ class _StreamParser: """ Accepts orchestration_result shape and maps it to an OpenAI-like *chunk*. """ - orc: Final[dict[str, object]] = cast( # cast-ok: wire JSON; orchestration_result is always a dict - dict[str, object], evt.get("orchestration_result") or {} - ) + orc: Final[dict[str, object]] = evt.get("orchestration_result") or {} # pyright: ignore[reportAssignmentType] # wire JSON; orchestration_result is always a dict if not orc: return None @@ -152,7 +144,7 @@ class _StreamParser: normalize_reasoning_content( { "id": orc.get("id") - or cast(str | None, evt.get("request_id")) # cast-ok: wire JSON string field + or evt.get("request_id") # pyright: ignore[reportUnknownMemberType] # wire JSON string field or "stream-chunk", "object": orc.get("object") or "chat.completion.chunk", "created": orc.get("created") or evt.get("created") or _now_ts(), @@ -163,9 +155,7 @@ class _StreamParser: "delta": choice.get("delta") or {}, "finish_reason": choice.get("finish_reason"), } - for choice in cast( # cast-ok: wire JSON; choices is always list[dict] - list[dict[str, object]], orc.get("choices") or [] - ) + for choice in orc.get("choices") or [] # pyright: ignore[reportUnknownVariableType, reportGeneralTypeIssues] # wire JSON; choices is always list[dict] ], } ) @@ -350,12 +340,8 @@ class AsyncSAPStreamIterator: # ------------------------------- class GenAIHubOrchestration(BaseLLMHTTPHandler): def _add_stream_param_to_request_body(self, data: dict, provider_config: BaseConfig, fake_stream: bool): - if ( - cast( # cast-ok: config is always a dict when present - dict[str, object], data.get("config") or {} - ).get("stream") - is not None - ): + config = data.get("config") or {} # pyright: ignore[reportUnknownVariableType] # wire JSON; config is always a dict when present + if isinstance(config, dict) and config.get("stream") is not None: data["config"]["stream"]["enabled"] = True else: data["config"]["stream"] = {"enabled": True} From cc26d6dfcfefc9719d995dcae81d7b76151c59fd Mon Sep 17 00:00:00 2001 From: Yamac Ay Date: Tue, 3 Nov 2026 13:35:41 +0100 Subject: [PATCH 5/5] empty commit