fix(sap): denormalization added

This commit is contained in:
Yamac Ay 2026-10-20 13:48:59 +02:00
parent 247aa59f16
commit b88c8eafc3
No known key found for this signature in database
GPG key ID: D113B438819CA628
3 changed files with 109 additions and 13 deletions

View file

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

View file

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

View file

@ -1141,4 +1141,74 @@ class TestMessagesToSapTemplateReasoningContent:
assistant_msg = result[1]
assert assistant_msg["reasoning_content"] == [
{"content": "Simple arithmetic.", "signature": "sig1"}
]
]
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)