From 8b8b5fd3774b8d34f02c7477ba87b9822d79f023 Mon Sep 17 00:00:00 2001 From: lum1t4 <12648607+lum1t4@users.noreply.github.com> Date: Wed, 7 Oct 2026 08:29:39 +0200 Subject: [PATCH 1/2] fix(oci): support LangChain streaming and multipart MCP tool results --- litellm/llms/oci/chat/generic.py | 37 +++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/litellm/llms/oci/chat/generic.py b/litellm/llms/oci/chat/generic.py index 8db5803deb5..80152c62bbc 100644 --- a/litellm/llms/oci/chat/generic.py +++ b/litellm/llms/oci/chat/generic.py @@ -166,6 +166,31 @@ def adapt_messages_to_generic_oci_standard_tool_response(role: str, tool_call_id ) +def normalize_tool_result_content(content: object) -> str: + """Convert OpenAI text content parts to the string required by OCI.""" + if isinstance(content, str): + return content + if not isinstance(content, list): + raise OCIError( + status_code=400, + message="Tool result message `content` must be a string or list of text parts", + ) + + for content_item in content: + if not isinstance(content_item, dict) or content_item.get("type") not in ("text", "input_text"): + raise OCIError( + status_code=400, + message="Tool result content must contain only text parts", + ) + if not isinstance(content_item.get("text"), str): + raise OCIError( + status_code=400, + message="Tool result text content must have a string `text` field", + ) + + return "\n".join(content_item["text"] for content_item in content) + + def adapt_messages_to_generic_oci_standard( messages: list[AllMessageValues], ) -> list[OCIMessage]: @@ -196,12 +221,13 @@ def adapt_messages_to_generic_oci_standard( status_code=400, message="Tool result message must have a string `tool_call_id`", ) - if not isinstance(content, str): - raise OCIError( - status_code=400, - message="Tool result message `content` must be a string", + new_messages.append( + adapt_messages_to_generic_oci_standard_tool_response( + role, + tool_call_id, + normalize_tool_result_content(content), ) - new_messages.append(adapt_messages_to_generic_oci_standard_tool_response(role, tool_call_id, content)) + ) return new_messages @@ -425,6 +451,7 @@ def handle_generic_stream_chunk(dict_chunk: dict) -> ModelResponseStream: StreamingChoices( index=typed_chunk.index, delta=Delta( + role="assistant", content=text, tool_calls=tool_calls, provider_specific_fields=None, From aecc81d228206ac365a52ac65256b7d86bb5fe23 Mon Sep 17 00:00:00 2001 From: lum1t4 <12648607+lum1t4@users.noreply.github.com> Date: Wed, 7 Oct 2026 08:53:06 +0200 Subject: [PATCH 2/2] test(oci): cover multipart tool results and assistant stream role --- .../llms/oci/chat/test_oci_generic_chat.py | 48 ++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/tests/unit/llms/oci/chat/test_oci_generic_chat.py b/tests/unit/llms/oci/chat/test_oci_generic_chat.py index 9ec5ab9aed4..8b205524a82 100644 --- a/tests/unit/llms/oci/chat/test_oci_generic_chat.py +++ b/tests/unit/llms/oci/chat/test_oci_generic_chat.py @@ -2,10 +2,11 @@ Unit tests for litellm/llms/oci/chat/generic.py — error paths and stream handling. """ -import pytest +from typing import Final from unittest.mock import MagicMock import httpx +import pytest from litellm import ModelResponse from litellm.llms.oci.chat.generic import ( @@ -22,6 +23,7 @@ from litellm.llms.oci.chat.transformation import ( _model_uses_max_completion_tokens, ) from litellm.llms.oci.common_utils import OCIError +from litellm.types.llms.oci import OCIMessage, OCITextContentPart # --------------------------------------------------------------------------- # adapt_messages_to_generic_oci_standard_content_message — error paths @@ -158,6 +160,49 @@ class TestGenericToolCallErrors: class TestGenericMessageAdaptation: + @pytest.mark.parametrize( + "content, expected", + [ + ("plain result", "plain result"), + ([], ""), + ([{"type": "text", "text": "first"}], "first"), + ([{"type": "input_text", "text": "second"}], "second"), + ( + [{"type": "text", "text": "first"}, {"type": "input_text", "text": "second"}], + "first\nsecond", + ), + ([{"type": "text", "text": ""}, {"type": "text", "text": "last"}], "\nlast"), + ], + ) + def test_tool_result_preserves_call_id_and_normalizes_text(self, content: object, expected: str) -> None: + result: Final = adapt_messages_to_generic_oci_standard( + [{"role": "tool", "tool_call_id": "call_mcp", "content": content}] + ) + + assert result == [OCIMessage(role="TOOL", content=[OCITextContentPart(text=expected)], toolCallId="call_mcp")] + + @pytest.mark.parametrize( + "content, error", + [ + (None, "must be a string or list of text parts"), + (42, "must be a string or list of text parts"), + (["text"], "must contain only text parts"), + ([{"text": "missing type"}], "must contain only text parts"), + ( + [{"type": "image_url", "image_url": {"url": "https://example.com/image.png"}}], + "must contain only text parts", + ), + ([{"type": "text"}], "must have a string `text` field"), + ([{"type": "input_text", "text": 42}], "must have a string `text` field"), + ([{"type": "text", "text": "valid"}, {"type": "text", "text": None}], "must have a string `text` field"), + ], + ) + def test_tool_result_rejects_invalid_content(self, content: object, error: str) -> None: + with pytest.raises(OCIError, match=error) as exc_info: + adapt_messages_to_generic_oci_standard([{"role": "tool", "tool_call_id": "call_mcp", "content": content}]) + + assert exc_info.value.status_code == 400 + def test_tool_calls_not_list_raises(self): messages = [ { @@ -310,6 +355,7 @@ class TestHandleGenericStreamChunk: }, } result = handle_generic_stream_chunk(chunk) + assert result.choices[0].delta.role == "assistant" assert result.choices[0].delta.tool_calls is not None