fix(sap): normalize list-shaped reasoning_content from Gemini 3.x thought signatures

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-08-05 05:18:28 +00:00 committed by GitHub
parent bbc6e3feea
commit 0ef6f77bc9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 167 additions and 3 deletions

71
litellm/llms/sap/chat/transformation.py Executable file → Normal file
View file

@ -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 ... ```

View file

@ -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