fix(sap): normalize gemini reasoning content

This commit is contained in:
Yamac Ay 2026-09-09 16:29:53 +02:00
parent 47b15ffb67
commit 13ddaff73b
No known key found for this signature in database
GPG key ID: D113B438819CA628
2 changed files with 107 additions and 1 deletions

View file

@ -393,7 +393,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"])
final_result = self._normalize_reasoning_content(raw_response.json()["final_result"])
response = ModelResponse.model_validate(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 ... ```
@ -406,6 +407,38 @@ class GenAIHubOrchestrationConfig(OpenAIGPTConfig):
return response
@staticmethod
def _normalize_reasoning_content(raw: dict[str, object]) -> dict[str, object]: # mutable-ok: generic types
"""Normalize list-shaped reasoning_content to the string field litellm expects.
SAP AI Core forwards reasoning tokens from Gemini and other thinking models as:
message.reasoning_content = [{"content": "...", "signature": "..."}, ...]
ModelResponse.reasoning_content is typed Optional[str], so model_validate
raises a ValidationError on a list. Map the blocks to thinking_blocks
(already typed for this shape) and set reasoning_content to the concatenated
text so callers that read the string field still get something useful.
"""
for choice in raw.get("choices", []): # mutable-ok: sentinel default, never mutated
msg = choice.get("message", {}) # mutable-ok: sentinel default, never mutated
rc = msg.get("reasoning_content")
if not isinstance(rc, list):
continue
thinking_blocks = [ # mutable-ok: local accumulator built once and assigned
{ # mutable-ok: each block dict constructed fresh per item
"type": "thinking",
"thinking": item.get("content", ""),
"signature": item.get("signature"),
}
for item in rc
if isinstance(item, dict)
]
msg["thinking_blocks"] = thinking_blocks
msg["reasoning_content"] = (
"\n".join(b["thinking"] for b in thinking_blocks if b["thinking"]) or None
)
return raw
def _strip_markdown_json(self, response: ModelResponse) -> ModelResponse:
"""Strip markdown code block wrapper from JSON content if present.

View file

@ -1,4 +1,5 @@
import warnings
import pytest
from pydantic import ValidationError
@ -639,3 +640,75 @@ class TestSAPTransformationIntegration:
config["config"]["modules"][1]["translation"]["input"]["type"]
== "sap_document_translation"
)
class TestNormalizeReasoningContent:
"""Unit tests for GenAIHubOrchestrationConfig._normalize_reasoning_content."""
from litellm.llms.sap.chat.transformation import GenAIHubOrchestrationConfig
_normalize = staticmethod(GenAIHubOrchestrationConfig._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(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(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(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(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(raw)
assert out["choices"][0]["message"]["reasoning_content"] == "thought A"
assert out["choices"][1]["message"]["reasoning_content"] == "thought B"