diff --git a/litellm/llms/sap/chat/transformation.py b/litellm/llms/sap/chat/transformation.py old mode 100755 new mode 100644 index 544aa6c891b..84e69e6ef23 --- a/litellm/llms/sap/chat/transformation.py +++ b/litellm/llms/sap/chat/transformation.py @@ -4,12 +4,13 @@ Translate from OpenAI's `/v1/chat/completions` to SAP Generative AI Hub's Orches from collections.abc import AsyncIterator, Iterator from functools import cached_property -from typing import TYPE_CHECKING, Any, Final, Union +from typing import TYPE_CHECKING, Any, Final, Union, cast import httpx +from pydantic import BaseModel, ConfigDict, ValidationError import litellm -from litellm.types.llms.openai import AllMessageValues +from litellm.types.llms.openai import AllMessageValues, ChatCompletionThinkingBlock from litellm.types.utils import ModelResponse from ...openai.chat.gpt_transformation import OpenAIGPTConfig @@ -55,6 +56,69 @@ def validate_dict(data: dict, model) -> dict: return model(**data).model_dump(by_alias=True, exclude_unset=True) +class _SAPThoughtBlock(BaseModel): + model_config = ConfigDict(frozen=True, extra="ignore") + + content: str = "" + signature: str | None = None + + +class _SAPMessageWithThoughts(BaseModel): + model_config = ConfigDict(frozen=True, extra="ignore") + + reasoning_content: tuple[_SAPThoughtBlock, ...] + thinking_blocks: tuple[ChatCompletionThinkingBlock, ...] = () + + +class _SAPChoiceWithMessage(BaseModel): + model_config = ConfigDict(frozen=True, extra="ignore") + + message: _SAPMessageWithThoughts + + +def _thinking_block(block: _SAPThoughtBlock) -> ChatCompletionThinkingBlock: + return ChatCompletionThinkingBlock(type="thinking", thinking=block.content, signature=block.signature) + + +def _parse_choice_with_thoughts(choice: object) -> _SAPChoiceWithMessage | None: + try: + return _SAPChoiceWithMessage.model_validate(choice) + except ValidationError: + return None + + +def _normalize_choice(choice: object) -> object: + parsed: Final = _parse_choice_with_thoughts(choice) + if parsed is None: + return choice + + blocks: Final = tuple(_thinking_block(block) for block in parsed.message.reasoning_content) + reasoning_text: Final = "".join(block.content for block in parsed.message.reasoning_content) + choice_dict: Final = cast(dict[str, object], choice) + message: Final = cast(dict[str, object], choice_dict["message"]) + + return { + **choice_dict, + "message": { + **message, + "reasoning_content": reasoning_text or None, + "thinking_blocks": [*parsed.message.thinking_blocks, *blocks], + }, + } + + +def _normalize_final_result(final_result: object) -> object: + if not isinstance(final_result, dict): + return final_result + + result: Final = cast(dict[str, object], final_result) + choices: Final = result.get("choices") + if not isinstance(choices, list): + return result + + return {**result, "choices": [_normalize_choice(choice) for choice in cast(list[object], choices)]} + + def _messages_to_sap_template(messages: list[dict[str, str]]) -> list: # type: ignore[type-arg] template: Final = [] for message in messages: @@ -391,7 +455,8 @@ class GenAIHubOrchestrationConfig(OpenAIGPTConfig): original_response=raw_response.text, additional_args={"complete_input_dict": request_data}, ) - response = ModelResponse.model_validate(raw_response.json()["final_result"]) + raw_body: Final = cast(dict[str, object], raw_response.json()) + response = ModelResponse.model_validate(_normalize_final_result(raw_body["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/test_litellm/llms/sap/chat/test_sap_transformation.py b/tests/test_litellm/llms/sap/chat/test_sap_transformation.py index 3601bdd0d5e..f3a841426ca 100644 --- a/tests/test_litellm/llms/sap/chat/test_sap_transformation.py +++ b/tests/test_litellm/llms/sap/chat/test_sap_transformation.py @@ -639,3 +639,102 @@ class TestSAPTransformationIntegration: config["config"]["modules"][1]["translation"]["input"]["type"] == "sap_document_translation" ) + + +class TestTransformResponseGeminiThoughtSignatures: + """Gemini 3.x returns list-shaped reasoning_content (signed thought blocks) on tool-call follow-ups.""" + + @staticmethod + def _transform(message): + from unittest.mock import MagicMock + + from litellm.llms.sap.chat.transformation import GenAIHubOrchestrationConfig + from litellm.types.utils import ModelResponse + + raw_response = MagicMock() + raw_response.json.return_value = { + "final_result": { + "id": "test-id", + "object": "chat.completion", + "created": 1, + "model": "gemini-3.5-flash", + "choices": [ + {"index": 0, "message": message, "finish_reason": "stop"}, + ], + } + } + raw_response.text = '{"final_result": {...}}' + + return GenAIHubOrchestrationConfig().transform_response( + model="gemini-3.5-flash", + raw_response=raw_response, + model_response=ModelResponse(id="test", model="test"), + logging_obj=MagicMock(), + request_data={}, + messages=[{"role": "user", "content": "test"}], + optional_params={}, + litellm_params={}, + encoding=None, + ) + + def test_list_shaped_reasoning_content_is_flattened_and_signatures_kept(self): + result = self._transform( + { + "role": "assistant", + "content": "Ticket ABC-123 is open.", + "reasoning_content": [ + {"content": "Checking the ticket. ", "signature": "sig-one"}, + {"content": "It is open.", "signature": "sig-two"}, + ], + } + ) + + message = result.choices[0].message + assert message.content == "Ticket ABC-123 is open." + assert message.reasoning_content == "Checking the ticket. It is open." + assert [block["signature"] for block in message.thinking_blocks] == [ + "sig-one", + "sig-two", + ] + assert [block["thinking"] for block in message.thinking_blocks] == [ + "Checking the ticket. ", + "It is open.", + ] + assert all(block["type"] == "thinking" for block in message.thinking_blocks) + + def test_signature_only_block_with_empty_text(self): + result = self._transform( + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "get_ticket_status", + "arguments": '{"ticket_id": "ABC-123"}', + }, + } + ], + "reasoning_content": [{"content": "", "signature": "CtMHAdHtim920NjKZTfLS9W/gDXBihg="}], + } + ) + + message = result.choices[0].message + assert getattr(message, "reasoning_content", None) is None + assert message.thinking_blocks[0]["signature"] == "CtMHAdHtim920NjKZTfLS9W/gDXBihg=" + assert message.tool_calls[0].function.name == "get_ticket_status" + + def test_string_reasoning_content_is_untouched(self): + result = self._transform( + { + "role": "assistant", + "content": "hi", + "reasoning_content": "plain gemini 2.5 style reasoning", + } + ) + + message = result.choices[0].message + assert message.reasoning_content == "plain gemini 2.5 style reasoning" + assert getattr(message, "thinking_blocks", None) is None