diff --git a/litellm/llms/sap/chat/handler.py b/litellm/llms/sap/chat/handler.py index 53537726047..2867b64057b 100755 --- a/litellm/llms/sap/chat/handler.py +++ b/litellm/llms/sap/chat/handler.py @@ -51,15 +51,6 @@ class _StreamParser: def _validate_chunk( payload: dict, # mutable-ok: normalized in place (pops empty logprobs) before validation ) -> OpenAIChatCompletionChunk: - """ - Validate an OpenAI-shaped dict into a chunk, normalizing fields that would - otherwise break downstream serialization: - - drop the empty `logprobs` ({}) the orchestration service sends on every choice - - replace the raw openai-SDK usage object (deferred-build pydantic model whose - serializer is still a MockValSer) with litellm's Usage, so nested - model_dump() calls in the streaming handler don't raise - "'MockValSer' object is not an instance of 'SchemaSerializer'" - """ for choice in payload.get("choices") or []: # mutable-ok: only iterated, never mutated if isinstance(choice, dict) and not choice.get("logprobs"): choice.pop("logprobs", None) diff --git a/tests/test_litellm/llms/sap/chat/test_sap_chat_calls.py b/tests/test_litellm/llms/sap/chat/test_sap_chat_calls.py index c41254f1a2b..170354dae7e 100644 --- a/tests/test_litellm/llms/sap/chat/test_sap_chat_calls.py +++ b/tests/test_litellm/llms/sap/chat/test_sap_chat_calls.py @@ -204,3 +204,113 @@ async def test_sap_chat_required_headers( f"Header '{header_name}' has incorrect value. " f"Expected: '{expected_value}', Got: '{request.headers[header_name]}'" ) + + +def _final_chunk_payload() -> dict: + return { + "id": "chatcmpl-sap-final", + "object": "chat.completion.chunk", + "created": 1761319270, + "model": "anthropic--claude-4.7-opus", + "choices": [ + { + "index": 0, + "delta": {}, + "logprobs": {}, + "finish_reason": "tool_calls", + } + ], + "usage": { + "completion_tokens": 206, + "prompt_tokens": 62322, + "total_tokens": 62528, + }, + } + + +def test_validate_chunk_drops_empty_logprobs(): + from litellm.llms.sap.chat.handler import _StreamParser + + chunk = _StreamParser._validate_chunk(_final_chunk_payload()) + assert chunk.choices[0].logprobs is None + + +def test_validate_chunk_preserves_real_logprobs(): + from litellm.llms.sap.chat.handler import _StreamParser + + payload = _final_chunk_payload() + payload["choices"][0]["logprobs"] = { + "content": [{"token": "Hello", "logprob": -0.1, "bytes": None, "top_logprobs": []}] + } + chunk = _StreamParser._validate_chunk(payload) + assert chunk.choices[0].logprobs is not None + assert chunk.choices[0].logprobs.content[0].token == "Hello" + + +def test_validate_chunk_converts_usage_to_litellm_usage(): + from litellm.llms.sap.chat.handler import _StreamParser + from litellm.types.utils import Usage + + chunk = _StreamParser._validate_chunk(_final_chunk_payload()) + assert isinstance(chunk.usage, Usage) + assert chunk.usage.completion_tokens == 206 + assert chunk.usage.prompt_tokens == 62322 + assert chunk.usage.total_tokens == 62528 + + +def test_validate_chunk_without_usage_keeps_none(): + from litellm.llms.sap.chat.handler import _StreamParser + + payload = _final_chunk_payload() + del payload["usage"] + chunk = _StreamParser._validate_chunk(payload) + assert chunk.usage is None + + +def test_validated_usage_survives_nested_model_dump(): + from litellm.llms.sap.chat.handler import _StreamParser + from litellm.types.utils import ModelResponseStream + + chunk = _StreamParser._validate_chunk(_final_chunk_payload()) + + model_response = ModelResponseStream() + setattr(model_response, "usage", chunk.usage) + + dumped = model_response.model_dump() + assert dumped["usage"]["total_tokens"] == 62528 + + +def test_to_openai_chunk_normalizes_openai_shaped_event(): + from litellm.llms.sap.chat.handler import _StreamParser + from litellm.types.utils import Usage + + chunk = _StreamParser.to_openai_chunk(_final_chunk_payload()) + assert chunk is not None + assert chunk.choices[0].logprobs is None + assert isinstance(chunk.usage, Usage) + + +def test_to_openai_chunk_from_orchestration_result(): + from litellm.llms.sap.chat.handler import _StreamParser + + event = { + "request_id": "a07127d3-cb74-9427-a4dc-ef9bf424fb43", + "orchestration_result": { + "id": "chatcmpl-sap-delta", + "object": "chat.completion.chunk", + "created": 1761319270, + "model": "anthropic--claude-4.7-opus", + "choices": [ + { + "index": 0, + "delta": {"role": "assistant", "content": "Hello "}, + "logprobs": {}, + "finish_reason": None, + } + ], + }, + } + chunk = _StreamParser.to_openai_chunk(event) + assert chunk is not None + assert chunk.choices[0].delta.content == "Hello " + assert chunk.choices[0].logprobs is None diff --git a/tests/test_litellm/llms/sap/chat/test_sap_stream_chunk_validation.py b/tests/test_litellm/llms/sap/chat/test_sap_stream_chunk_validation.py deleted file mode 100644 index 632130c23ea..00000000000 --- a/tests/test_litellm/llms/sap/chat/test_sap_stream_chunk_validation.py +++ /dev/null @@ -1,137 +0,0 @@ -""" -Tests for SAP orchestration stream chunk normalization (_StreamParser._validate_chunk) -and the descriptive error raised when no orchestration deployment exists. - -Regression tests for: -- TypeError: 'MockValSer' object is not an instance of 'SchemaSerializer' - raised from nested model_dump() when the raw openai-SDK usage object - (a deferred-build pydantic model) was attached to ModelResponseStream. -- IndexError: list index out of range raised from deployment_url when the - configured resource group contains no orchestration deployment. -""" - -from unittest.mock import MagicMock - -import pytest - -from litellm.llms.sap.chat.handler import GenAIHubOrchestrationError, _StreamParser -from litellm.types.utils import ModelResponseStream, Usage - - -def _final_chunk_payload() -> dict: - """OpenAI-shaped final chunk as sent by the SAP orchestration service: - every choice carries an empty `logprobs` ({}) and the last chunk carries usage.""" - return { - "id": "chatcmpl-sap-final", - "object": "chat.completion.chunk", - "created": 1761319270, - "model": "anthropic--claude-4.7-opus", - "choices": [ - { - "index": 0, - "delta": {}, - "logprobs": {}, - "finish_reason": "tool_calls", - } - ], - "usage": { - "completion_tokens": 206, - "prompt_tokens": 62322, - "total_tokens": 62528, - }, - } - - -def test_validate_chunk_drops_empty_logprobs(): - chunk = _StreamParser._validate_chunk(_final_chunk_payload()) - assert chunk.choices[0].logprobs is None - - -def test_validate_chunk_preserves_real_logprobs(): - payload = _final_chunk_payload() - payload["choices"][0]["logprobs"] = { - "content": [{"token": "Hello", "logprob": -0.1, "bytes": None, "top_logprobs": []}] - } - chunk = _StreamParser._validate_chunk(payload) - assert chunk.choices[0].logprobs is not None - assert chunk.choices[0].logprobs.content[0].token == "Hello" - - -def test_validate_chunk_converts_usage_to_litellm_usage(): - chunk = _StreamParser._validate_chunk(_final_chunk_payload()) - assert isinstance(chunk.usage, Usage) - assert chunk.usage.completion_tokens == 206 - assert chunk.usage.prompt_tokens == 62322 - assert chunk.usage.total_tokens == 62528 - - -def test_validate_chunk_without_usage_keeps_none(): - payload = _final_chunk_payload() - del payload["usage"] - chunk = _StreamParser._validate_chunk(payload) - assert chunk.usage is None - - -def test_validated_usage_survives_nested_model_dump(): - """The original crash: the raw openai-SDK usage object attached to a - ModelResponseStream made model_dump() raise - "TypeError: 'MockValSer' object is not an instance of 'SchemaSerializer'".""" - chunk = _StreamParser._validate_chunk(_final_chunk_payload()) - - model_response = ModelResponseStream() - setattr(model_response, "usage", chunk.usage) - - dumped = model_response.model_dump() # must not raise - assert dumped["usage"]["total_tokens"] == 62528 - - -def test_to_openai_chunk_normalizes_openai_shaped_event(): - """An already-openai-shaped event goes through the same normalization.""" - chunk = _StreamParser.to_openai_chunk(_final_chunk_payload()) - assert chunk is not None - assert chunk.choices[0].logprobs is None - assert isinstance(chunk.usage, Usage) - - -def test_to_openai_chunk_from_orchestration_result(): - """An orchestration_result delta event is mapped and normalized into a chunk.""" - event = { - "request_id": "a07127d3-cb74-9427-a4dc-ef9bf424fb43", - "orchestration_result": { - "id": "chatcmpl-sap-delta", - "object": "chat.completion.chunk", - "created": 1761319270, - "model": "anthropic--claude-4.7-opus", - "choices": [ - { - "index": 0, - "delta": {"role": "assistant", "content": "Hello "}, - "logprobs": {}, - "finish_reason": None, - } - ], - }, - } - chunk = _StreamParser.to_openai_chunk(event) - assert chunk is not None - assert chunk.choices[0].delta.content == "Hello " - assert chunk.choices[0].logprobs is None - - -def test_deployment_url_raises_404_when_no_orchestration_deployment(): - from litellm.llms.sap.chat.transformation import GenAIHubOrchestrationConfig - - config = GenAIHubOrchestrationConfig() - config.token_creator = lambda: "Bearer FAKE_TOKEN" - config._base_url = "https://api.ai.mock-sap.com/v2" - config._resource_group = "fake-group" - - mock_client = MagicMock() - mock_client.get.return_value.json.return_value = {"resources": []} - config._http_client = mock_client - - with pytest.raises(GenAIHubOrchestrationError) as exc_info: - _ = config.deployment_url - - assert exc_info.value.status_code == 404 - assert "fake-group" in exc_info.value.message diff --git a/tests/test_litellm/llms/sap/chat/test_sap_transformation.py b/tests/test_litellm/llms/sap/chat/test_sap_transformation.py index 3601bdd0d5e..565545a8d84 100644 --- a/tests/test_litellm/llms/sap/chat/test_sap_transformation.py +++ b/tests/test_litellm/llms/sap/chat/test_sap_transformation.py @@ -639,3 +639,18 @@ class TestSAPTransformationIntegration: config["config"]["modules"][1]["translation"]["input"]["type"] == "sap_document_translation" ) + + def test_deployment_url_raises_404_when_no_orchestration_deployment(self, mock_config): + from unittest.mock import MagicMock + + from litellm.llms.sap.chat.handler import GenAIHubOrchestrationError + + mock_client = MagicMock() + mock_client.get.return_value.json.return_value = {"resources": []} + mock_config._http_client = mock_client + + with pytest.raises(GenAIHubOrchestrationError) as exc_info: + _ = mock_config.deployment_url + + assert exc_info.value.status_code == 404 + assert "test-group" in exc_info.value.message