mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
fix(sap): openai params updates and normalization rebased
This commit is contained in:
parent
6cf51383bf
commit
22a3f47ba3
4 changed files with 404 additions and 26 deletions
|
|
@ -32,6 +32,44 @@ def _now_ts() -> int:
|
|||
return int(time.time())
|
||||
|
||||
|
||||
def normalize_reasoning_content(raw: dict[str, object]) -> dict[str, object]: # mutable-ok: generic types
|
||||
choices: list[object] = list(
|
||||
raw.get("choices") or []
|
||||
) # explicit cast: raw values are object, pyright can't infer iterability
|
||||
return {
|
||||
**raw,
|
||||
"choices": [normalize_choice(c) for c in choices if isinstance(c, dict)],
|
||||
} # mutable-ok: sentinel default, never mutated
|
||||
|
||||
|
||||
def normalize_choice(choice: dict[str, object]) -> dict[str, object]: # mutable-ok: generic dict from raw JSON
|
||||
for key in ("message", "delta"):
|
||||
carrier = choice.get(key)
|
||||
if not isinstance(carrier, dict):
|
||||
continue
|
||||
rc = carrier.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") or "",
|
||||
"signature": item.get("signature"),
|
||||
}
|
||||
for item in rc
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
return {
|
||||
**choice,
|
||||
key: {
|
||||
**carrier,
|
||||
"thinking_blocks": thinking_blocks,
|
||||
"reasoning_content": ("\n".join(b["thinking"] for b in thinking_blocks if b["thinking"]) or None),
|
||||
},
|
||||
}
|
||||
return choice
|
||||
|
||||
|
||||
def _is_terminal_chunk(chunk: OpenAIChatCompletionChunk) -> bool:
|
||||
"""OpenAI-shaped chunk is terminal if any choice has a non-None finish_reason."""
|
||||
try:
|
||||
|
|
@ -55,22 +93,21 @@ class _StreamParser:
|
|||
if not orc:
|
||||
return None
|
||||
|
||||
return OpenAIChatCompletionChunk.model_validate(
|
||||
{
|
||||
"id": orc.get("id") or evt.get("request_id") or "stream-chunk",
|
||||
"object": orc.get("object") or "chat.completion.chunk",
|
||||
"created": orc.get("created") or evt.get("created") or _now_ts(),
|
||||
"model": orc.get("model") or "unknown",
|
||||
"choices": [
|
||||
{
|
||||
"index": c.get("index", 0),
|
||||
"delta": c.get("delta") or {},
|
||||
"finish_reason": c.get("finish_reason"),
|
||||
}
|
||||
for c in (orc.get("choices") or [])
|
||||
],
|
||||
}
|
||||
)
|
||||
chunk: Final[dict] = { # mutable-ok: local dict built once and passed to model_validate
|
||||
"id": orc.get("id") or evt.get("request_id") or "stream-chunk",
|
||||
"object": orc.get("object") or "chat.completion.chunk",
|
||||
"created": orc.get("created") or evt.get("created") or _now_ts(),
|
||||
"model": orc.get("model") or "unknown",
|
||||
"choices": [
|
||||
{
|
||||
"index": c.get("index", 0),
|
||||
"delta": c.get("delta") or {},
|
||||
"finish_reason": c.get("finish_reason"),
|
||||
}
|
||||
for c in (orc.get("choices") or [])
|
||||
],
|
||||
}
|
||||
return OpenAIChatCompletionChunk.model_validate(normalize_reasoning_content(chunk))
|
||||
|
||||
@staticmethod
|
||||
def to_openai_chunk(event_obj: dict) -> OpenAIChatCompletionChunk | None:
|
||||
|
|
@ -90,10 +127,9 @@ class _StreamParser:
|
|||
# FINAL RESULT IS *NOT* TERMINAL: treat it as the next chunk
|
||||
if "final_result" in event_obj:
|
||||
fr: Final = event_obj["final_result"] or {}
|
||||
# ensure it looks like an OpenAI chunk
|
||||
if "object" not in fr:
|
||||
fr["object"] = "chat.completion.chunk"
|
||||
return OpenAIChatCompletionChunk.model_validate(fr)
|
||||
return OpenAIChatCompletionChunk.model_validate(normalize_reasoning_content(fr))
|
||||
|
||||
# Orchestration incremental delta
|
||||
if "orchestration_result" in event_obj:
|
||||
|
|
@ -101,7 +137,7 @@ class _StreamParser:
|
|||
|
||||
# Already an OpenAI-like chunk
|
||||
if "choices" in event_obj and "object" in event_obj:
|
||||
return OpenAIChatCompletionChunk.model_validate(event_obj)
|
||||
return OpenAIChatCompletionChunk.model_validate(normalize_reasoning_content(event_obj))
|
||||
|
||||
# Unknown / heartbeat / metrics
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -98,11 +98,17 @@ class SAPUserMessage(BaseModel):
|
|||
content: str | TextContent | ImageContent | list[TextContent | ImageContent]
|
||||
|
||||
|
||||
class ReasoningBlock(BaseModel):
|
||||
content: str = ""
|
||||
signature: str = ""
|
||||
|
||||
|
||||
class SAPAssistantMessage(BaseModel):
|
||||
role: Literal["assistant"] = "assistant"
|
||||
content: str = ""
|
||||
refusal: str = ""
|
||||
tool_calls: list[MessageToolCall] = []
|
||||
reasoning_content: list[ReasoningBlock] | None = None
|
||||
|
||||
_content_validator = field_validator("content", mode="before")(validate_different_content)
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,10 @@ from .handler import (
|
|||
AsyncSAPStreamIterator,
|
||||
GenAIHubOrchestrationError,
|
||||
SAPStreamIterator,
|
||||
normalize_reasoning_content,
|
||||
)
|
||||
from .handler import (
|
||||
normalize_choice as _normalize_choice_fn,
|
||||
)
|
||||
from .models import (
|
||||
ChatCompletionTool,
|
||||
|
|
@ -188,6 +192,19 @@ class GenAIHubOrchestrationConfig(OpenAIGPTConfig):
|
|||
def get_config(cls):
|
||||
return super().get_config()
|
||||
|
||||
def map_openai_params(
|
||||
self,
|
||||
non_default_params: dict, # mutable-ok: mirrors base class signature
|
||||
optional_params: dict, # mutable-ok: mirrors base class signature
|
||||
model: str,
|
||||
drop_params: bool,
|
||||
) -> dict: # mutable-ok: mirrors base class signature
|
||||
supported = self.get_supported_openai_params(model)
|
||||
optional_params.update(
|
||||
{p: v for p, v in non_default_params.items() if p in supported}
|
||||
) # mutable-ok: extending caller-provided dict
|
||||
return optional_params
|
||||
|
||||
def get_supported_openai_params(self, model):
|
||||
params: Final = [
|
||||
"frequency_penalty",
|
||||
|
|
@ -213,6 +230,8 @@ class GenAIHubOrchestrationConfig(OpenAIGPTConfig):
|
|||
"parallel_tool_calls",
|
||||
"response_format",
|
||||
"timeout",
|
||||
"reasoning_effort",
|
||||
"thinking",
|
||||
]
|
||||
# Remove response_format for providers that don't support it on SAP GenAI Hub
|
||||
if (
|
||||
|
|
@ -392,7 +411,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 = 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 ... ```
|
||||
|
|
@ -405,13 +425,15 @@ class GenAIHubOrchestrationConfig(OpenAIGPTConfig):
|
|||
|
||||
return response
|
||||
|
||||
def _strip_markdown_json(self, response: ModelResponse) -> ModelResponse:
|
||||
"""Strip markdown code block wrapper from JSON content if present.
|
||||
@staticmethod
|
||||
def _normalize_reasoning_content(raw: dict[str, object]) -> dict[str, object]: # mutable-ok: generic types
|
||||
return normalize_reasoning_content(raw)
|
||||
|
||||
SAP GenAI Hub with Anthropic models sometimes returns JSON wrapped in
|
||||
markdown code blocks (```json ... ```) depending on prompt phrasing.
|
||||
This method strips that wrapper to ensure consistent JSON output.
|
||||
"""
|
||||
@staticmethod
|
||||
def _normalize_choice(choice: dict[str, object]) -> dict[str, object]: # mutable-ok: generic dict from raw JSON
|
||||
return _normalize_choice_fn(choice)
|
||||
|
||||
def _strip_markdown_json(self, response: ModelResponse) -> ModelResponse:
|
||||
import re
|
||||
|
||||
for choice in response.choices or []:
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import warnings
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
|
|
@ -639,3 +640,316 @@ class TestSAPTransformationIntegration:
|
|||
config["config"]["modules"][1]["translation"]["input"]["type"]
|
||||
== "sap_document_translation"
|
||||
)
|
||||
|
||||
|
||||
class TestMapOpenaiParams:
|
||||
"""Unit tests for GenAIHubOrchestrationConfig.map_openai_params."""
|
||||
|
||||
@pytest.fixture
|
||||
def config(self):
|
||||
from litellm.llms.sap.chat.transformation import GenAIHubOrchestrationConfig
|
||||
|
||||
c = GenAIHubOrchestrationConfig.__new__(GenAIHubOrchestrationConfig)
|
||||
return c
|
||||
|
||||
def test_supported_params_pass_through(self, config):
|
||||
result = config.map_openai_params(
|
||||
non_default_params={"temperature": 0.7, "reasoning_effort": "high", "thinking": {"type": "enabled", "budget_tokens": 2000}},
|
||||
optional_params={},
|
||||
model="anthropic--claude-4-sonnet",
|
||||
drop_params=False,
|
||||
)
|
||||
assert result["temperature"] == 0.7
|
||||
assert result["reasoning_effort"] == "high"
|
||||
assert result["thinking"] == {"type": "enabled", "budget_tokens": 2000}
|
||||
|
||||
def test_unsupported_params_are_excluded(self, config):
|
||||
result = config.map_openai_params(
|
||||
non_default_params={"store": True, "service_tier": "auto", "modalities": ["text"]},
|
||||
optional_params={},
|
||||
model="gpt-4o",
|
||||
drop_params=False,
|
||||
)
|
||||
assert result == {}
|
||||
|
||||
def test_reasoning_effort_passes_for_all_sap_model_names(self, config):
|
||||
# SAP model names don't match OpenAI's o-series/gpt-5 patterns;
|
||||
# the override ensures they are not silently dropped by the inherited dispatcher.
|
||||
for model in ("anthropic--claude-4-sonnet", "gpt-4o", "gemini-2.5-flash", "gpt-5", "amazon--titan"):
|
||||
result = config.map_openai_params(
|
||||
non_default_params={"reasoning_effort": "low"},
|
||||
optional_params={},
|
||||
model=model,
|
||||
drop_params=False,
|
||||
)
|
||||
assert "reasoning_effort" in result, f"reasoning_effort dropped for {model}"
|
||||
|
||||
def test_thinking_passes_for_all_sap_model_names(self, config):
|
||||
thinking = {"type": "enabled", "budget_tokens": 2000}
|
||||
for model in ("anthropic--claude-4-sonnet", "gpt-4o", "gemini-2.5-flash"):
|
||||
result = config.map_openai_params(
|
||||
non_default_params={"thinking": thinking},
|
||||
optional_params={},
|
||||
model=model,
|
||||
drop_params=False,
|
||||
)
|
||||
assert "thinking" in result, f"thinking dropped for {model}"
|
||||
|
||||
def test_existing_optional_params_are_preserved(self, config):
|
||||
result = config.map_openai_params(
|
||||
non_default_params={"temperature": 0.5},
|
||||
optional_params={"seed": 42},
|
||||
model="gpt-4o",
|
||||
drop_params=False,
|
||||
)
|
||||
assert result["seed"] == 42
|
||||
assert result["temperature"] == 0.5
|
||||
|
||||
|
||||
class TestGetSupportedOpenaiParams:
|
||||
"""Unit tests for GenAIHubOrchestrationConfig.get_supported_openai_params."""
|
||||
|
||||
@pytest.fixture
|
||||
def config(self):
|
||||
from litellm.llms.sap.chat.transformation import GenAIHubOrchestrationConfig
|
||||
|
||||
return GenAIHubOrchestrationConfig.__new__(GenAIHubOrchestrationConfig)
|
||||
|
||||
def test_new_params_present_for_standard_models(self, config):
|
||||
for model in ("gpt-4o", "anthropic--claude-4-sonnet", "gemini-2.5-flash"):
|
||||
params = config.get_supported_openai_params(model)
|
||||
assert "reasoning_effort" in params, f"reasoning_effort missing for {model}"
|
||||
assert "thinking" in params, f"thinking missing for {model}"
|
||||
|
||||
def test_response_format_excluded_for_unsupported_models(self, config):
|
||||
for model in ("amazon--titan", "cohere--command", "alephalpha--luminous", "gpt-4"):
|
||||
assert "response_format" not in config.get_supported_openai_params(model)
|
||||
|
||||
def test_tool_choice_excluded_for_gemini_and_amazon(self, config):
|
||||
for model in ("gemini-2.5-flash", "amazon--titan"):
|
||||
assert "tool_choice" not in config.get_supported_openai_params(model)
|
||||
|
||||
def test_tool_choice_present_for_gpt_and_anthropic(self, config):
|
||||
for model in ("gpt-4o", "anthropic--claude-4-sonnet"):
|
||||
assert "tool_choice" in config.get_supported_openai_params(model)
|
||||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
def test_null_content_in_block_uses_empty_string(self):
|
||||
"""Explicit null content value must not leak None into thinking field."""
|
||||
raw = {
|
||||
"choices": [{
|
||||
"message": {
|
||||
"reasoning_content": [{"content": None, "signature": "s"}],
|
||||
}
|
||||
}]
|
||||
}
|
||||
out = self._normalize(raw)
|
||||
block = out["choices"][0]["message"]["thinking_blocks"][0]
|
||||
assert block["thinking"] == ""
|
||||
assert out["choices"][0]["message"]["reasoning_content"] is None
|
||||
|
||||
def test_transform_response_normalizes_list_reasoning_content(self):
|
||||
"""Production path: transform_response must produce a ModelResponse
|
||||
with thinking_blocks populated when the raw payload carries a
|
||||
list-shaped reasoning_content.
|
||||
"""
|
||||
import json
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from litellm.llms.sap.chat.transformation import GenAIHubOrchestrationConfig
|
||||
|
||||
config = GenAIHubOrchestrationConfig()
|
||||
|
||||
final_result = {
|
||||
"id": "chatcmpl-test",
|
||||
"object": "chat.completion",
|
||||
"created": 1700000000,
|
||||
"model": "gemini-test",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "The answer is 42.",
|
||||
"reasoning_content": [
|
||||
{"content": "Let me think.", "signature": "sig1"},
|
||||
{"content": "Yes, 42.", "signature": "sig2"},
|
||||
],
|
||||
},
|
||||
"finish_reason": "stop",
|
||||
}],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
|
||||
}
|
||||
|
||||
raw_response = MagicMock()
|
||||
raw_response.text = json.dumps({"final_result": final_result})
|
||||
raw_response.json.return_value = {"final_result": final_result}
|
||||
|
||||
response = config.transform_response(
|
||||
model="gemini-test",
|
||||
raw_response=raw_response,
|
||||
model_response=MagicMock(),
|
||||
logging_obj=MagicMock(),
|
||||
api_key="test",
|
||||
request_data={},
|
||||
messages=[],
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
encoding=None,
|
||||
)
|
||||
|
||||
choice = response.choices[0]
|
||||
assert hasattr(choice.message, "thinking_blocks"), "thinking_blocks missing from message"
|
||||
assert choice.message.thinking_blocks == [
|
||||
{"type": "thinking", "thinking": "Let me think.", "signature": "sig1"},
|
||||
{"type": "thinking", "thinking": "Yes, 42.", "signature": "sig2"},
|
||||
]
|
||||
assert choice.message.reasoning_content == "Let me think.\nYes, 42."
|
||||
|
||||
|
||||
class TestNormalizeChoice:
|
||||
"""Unit tests for GenAIHubOrchestrationConfig._normalize_choice (message and delta shapes)."""
|
||||
|
||||
from litellm.llms.sap.chat.transformation import GenAIHubOrchestrationConfig
|
||||
|
||||
_normalize_choice = staticmethod(GenAIHubOrchestrationConfig._normalize_choice)
|
||||
|
||||
def test_message_list_reasoning_content_normalized(self):
|
||||
choice = {
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": "hi", "reasoning_content": [{"content": "thought", "signature": "s1"}]},
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
result = self._normalize_choice(choice)
|
||||
msg = result["message"]
|
||||
assert msg["reasoning_content"] == "thought"
|
||||
assert msg["thinking_blocks"] == [{"type": "thinking", "thinking": "thought", "signature": "s1"}]
|
||||
|
||||
def test_delta_list_reasoning_content_normalized(self):
|
||||
choice = {
|
||||
"index": 0,
|
||||
"delta": {"role": "assistant", "reasoning_content": [{"content": "delta thought", "signature": None}]},
|
||||
"finish_reason": None,
|
||||
}
|
||||
result = self._normalize_choice(choice)
|
||||
delta = result["delta"]
|
||||
assert delta["reasoning_content"] == "delta thought"
|
||||
assert delta["thinking_blocks"][0]["thinking"] == "delta thought"
|
||||
|
||||
def test_string_reasoning_content_unchanged(self):
|
||||
choice = {"index": 0, "message": {"reasoning_content": "already a string"}}
|
||||
assert self._normalize_choice(choice) == choice
|
||||
|
||||
def test_no_reasoning_content_unchanged(self):
|
||||
choice = {"index": 0, "delta": {"content": "hello"}}
|
||||
assert self._normalize_choice(choice) == choice
|
||||
|
||||
def test_normalize_reasoning_content_covers_delta_path(self):
|
||||
from litellm.llms.sap.chat.transformation import GenAIHubOrchestrationConfig
|
||||
|
||||
raw = {
|
||||
"id": "c1",
|
||||
"object": "chat.completion.chunk",
|
||||
"choices": [
|
||||
{"index": 0, "delta": {"reasoning_content": [{"content": "stream thought", "signature": "sig"}]}, "finish_reason": None}
|
||||
],
|
||||
}
|
||||
result = GenAIHubOrchestrationConfig._normalize_reasoning_content(raw)
|
||||
delta = result["choices"][0]["delta"]
|
||||
assert delta["reasoning_content"] == "stream thought"
|
||||
assert delta["thinking_blocks"][0]["type"] == "thinking"
|
||||
|
||||
|
||||
class TestMessagesToSapTemplateReasoningContent:
|
||||
"""_messages_to_sap_template must forward reasoning_content on assistant turns."""
|
||||
|
||||
def test_assistant_message_with_reasoning_content_is_preserved(self):
|
||||
from litellm.llms.sap.chat.transformation import _messages_to_sap_template
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": "What is 2+2?"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "4",
|
||||
"reasoning_content": [
|
||||
{"content": "Simple arithmetic.", "signature": "sig1"}
|
||||
],
|
||||
},
|
||||
{"role": "user", "content": "Are you sure?"},
|
||||
]
|
||||
result = _messages_to_sap_template(messages)
|
||||
assistant_msg = result[1]
|
||||
assert assistant_msg["reasoning_content"] == [
|
||||
{"content": "Simple arithmetic.", "signature": "sig1"}
|
||||
]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue