diff --git a/litellm/llms/sap/chat/handler.py b/litellm/llms/sap/chat/handler.py index 67504d99b89..88b22d565f7 100755 --- a/litellm/llms/sap/chat/handler.py +++ b/litellm/llms/sap/chat/handler.py @@ -32,16 +32,6 @@ def _now_ts() -> int: return int(time.time()) -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 []) - normalized_choices: Final[dict[str, object]] = [ - normalize_choice(choice) for choice in choices if isinstance(choice, dict) - ] - normalized_result: Final[dict[str, object]] = {**raw, "choices": normalized_choices} - return normalized_result - - 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"): @@ -67,6 +57,40 @@ def normalize_choice(choice: dict[str, object]) -> dict[str, object]: 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 []) + normalized_choices: Final[dict[str, object]] = [ + normalize_choice(choice) for choice in choices if isinstance(choice, dict) + ] + normalized_result: Final[dict[str, object]] = {**raw, "choices": normalized_choices} + 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 message.get("role") != "assistant": + return message + thinking_blocks = message.get("thinking_blocks") + if not isinstance(thinking_blocks, list): + return message + reasoning_content: Final = [ + {"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} + + def _is_terminal_chunk(chunk: OpenAIChatCompletionChunk) -> bool: """OpenAI-shaped chunk is terminal if any choice has a non-None finish_reason.""" try: diff --git a/litellm/llms/sap/chat/transformation.py b/litellm/llms/sap/chat/transformation.py index 06e6b1535c6..6a70e2a8824 100755 --- a/litellm/llms/sap/chat/transformation.py +++ b/litellm/llms/sap/chat/transformation.py @@ -29,6 +29,7 @@ from .handler import ( GenAIHubOrchestrationError, SAPStreamIterator, normalize_reasoning_content, + denormalize_reasoning_content, ) from .models import ( ChatCompletionTool, @@ -104,8 +105,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 diff --git a/tests/unit/llms/sap/chat/test_sap_transformation.py b/tests/unit/llms/sap/chat/test_sap_transformation.py index e9c87ae8048..22e360b8532 100644 --- a/tests/unit/llms/sap/chat/test_sap_transformation.py +++ b/tests/unit/llms/sap/chat/test_sap_transformation.py @@ -1141,4 +1141,74 @@ class TestMessagesToSapTemplateReasoningContent: assistant_msg = result[1] assert assistant_msg["reasoning_content"] == [ {"content": "Simple arithmetic.", "signature": "sig1"} - ] \ No newline at end of file + ] + + 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) \ No newline at end of file