mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-13 23:11:40 +00:00
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:
parent
bbc6e3feea
commit
0ef6f77bc9
2 changed files with 167 additions and 3 deletions
71
litellm/llms/sap/chat/transformation.py
Executable file → Normal file
71
litellm/llms/sap/chat/transformation.py
Executable file → Normal 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 ... ```
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue