diff --git a/litellm/llms/oci/chat/transformation.py b/litellm/llms/oci/chat/transformation.py index e62e5e63642..0d20aa0dac6 100644 --- a/litellm/llms/oci/chat/transformation.py +++ b/litellm/llms/oci/chat/transformation.py @@ -54,6 +54,7 @@ from litellm.llms.oci.common_utils import ( from litellm.router_utils.reasoning_effort_capability import declared_reasoning_efforts_for_model from litellm.types.llms.oci import ( CohereChatRequest, + CohereToolMessage, OCIChatRequestPayload, OCICompletionPayload, OCIServingMode, @@ -661,11 +662,32 @@ class OCIChatConfig(BaseConfig): if preamble: preamble_override = preamble + chat_history = adapt_messages_to_cohere_standard([m for m in messages if m.get("role") != "system"]) + + # In multi-turn tool calling, when the conversation ends with tool results, + # OCI Cohere requires trailing tool results to be passed in top-level `toolResults` + # and `message` must be empty (""). Leaving tool messages at the end of chatHistory + # while providing `message` causes OCI to reject with: + # "invalid request: cannot specify message if the last entry in chat history contains tool results". + tool_results = None + non_system_messages: Final = [m for m in messages if m.get("role") != "system"] + if non_system_messages and non_system_messages[-1].get("role") == "tool": + popped_results = [] + while chat_history and isinstance(chat_history[-1], CohereToolMessage): + tool_msg = chat_history.pop() + popped_results = tool_msg.toolResults + popped_results + if popped_results: + tool_results = popped_results + request_message = "" + else: + request_message = extract_text_content(user_messages[-1]["content"]) + chat_request: Final = CohereChatRequest( apiFormat="COHERE", - message=extract_text_content(user_messages[-1]["content"]), - chatHistory=adapt_messages_to_cohere_standard([m for m in messages if m.get("role") != "system"]), + message=request_message, + chatHistory=chat_history, preambleOverride=preamble_override, + toolResults=tool_results, **self._get_optional_params(OCIVendors.COHERE, optional_params, model), ) data = OCICompletionPayload( diff --git a/litellm/types/llms/oci.py b/litellm/types/llms/oci.py index bd3a0af1dca..7d6e637ed6a 100644 --- a/litellm/types/llms/oci.py +++ b/litellm/types/llms/oci.py @@ -3,7 +3,7 @@ from __future__ import annotations from enum import Enum from typing import Literal -from pydantic import SerializeAsAny +from pydantic import ConfigDict, Field, SerializeAsAny from litellm.types.llms.base import LiteLLMBaseModel @@ -295,7 +295,7 @@ class CohereChatRequest(LiteLLMBaseModel): """Cohere chat request model.""" # Required fields - message: str + message: str = "" apiFormat: Literal["COHERE"] = "COHERE" # Optional fields @@ -313,6 +313,7 @@ class CohereChatRequest(LiteLLMBaseModel): stopSequences: list[str] | None = None seed: int | None = None tools: list[CohereTool] | None = None + toolResults: list[CohereToolResult] | None = None # NOTE: OCI's Cohere chat endpoint does not accept ``toolChoice`` — see # ``OCIChatConfig.openai_to_oci_cohere_param_map`` which marks # ``tool_choice`` as unsupported. The field is intentionally absent here @@ -351,10 +352,12 @@ class CohereUsage(LiteLLMBaseModel): class CohereCitation(LiteLLMBaseModel): """Citation in Cohere response.""" + model_config = ConfigDict(populate_by_name=True) + start: int end: int text: str - document_ids: list[str] + document_ids: list[str] = Field(default_factory=list, alias="documentIds") class CohereSearchQuery(LiteLLMBaseModel): diff --git a/tests/unit/llms/oci/chat/test_oci_cohere_tool_calls.py b/tests/unit/llms/oci/chat/test_oci_cohere_tool_calls.py index 30b574e040e..14911227f13 100644 --- a/tests/unit/llms/oci/chat/test_oci_cohere_tool_calls.py +++ b/tests/unit/llms/oci/chat/test_oci_cohere_tool_calls.py @@ -952,6 +952,223 @@ class TestCohereMessageAdaptationEdgeCases: result = extract_text_content(12345) assert result == "12345" + def test_cohere_multi_turn_tool_results_transform_request(self): + """In multi-turn tool calling, when the conversation ends with a tool message, + transform_request pops trailing tool results into top-level toolResults and sets message=''. + This prevents OCI 400 'cannot specify message if the last entry in chat history contains tool results'. + See issue #46157. + """ + config = OCIChatConfig() + messages = [ + {"role": "user", "content": "How is support ticket priority decided?"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "lookup_policy", + "arguments": json.dumps({"topic": "ticket priority"}), + }, + } + ], + }, + { + "role": "tool", + "tool_call_id": "call_1", + "content": "P1 for an outage, P2 for degraded service, P3 for questions.", + }, + ] + optional_params = { + "oci_compartment_id": TEST_COMPARTMENT_ID, + } + + transformed_request = config.transform_request( + model="cohere.command-a-03-2025", + messages=messages, # type: ignore + optional_params=optional_params, + litellm_params={}, + headers={}, + ) + + chat_request = transformed_request["chatRequest"] + assert chat_request["apiFormat"] == "COHERE" + assert chat_request["message"] == "" + assert "toolResults" in chat_request + assert len(chat_request["toolResults"]) == 1 + result = chat_request["toolResults"][0] + assert result["call"]["name"] == "lookup_policy" + assert result["call"]["parameters"] == {"topic": "ticket priority"} + assert result["outputs"] == [{"output": "P1 for an outage, P2 for degraded service, P3 for questions."}] + + # chatHistory must only contain the assistant turn, NOT the trailing tool result + assert len(chat_request["chatHistory"]) == 1 + assert chat_request["chatHistory"][0]["role"] == "CHATBOT" + assert chat_request["chatHistory"][0]["toolCalls"][0]["name"] == "lookup_policy" + + def test_cohere_multi_turn_parallel_tool_results_transform_request(self): + """Parallel tool results trailing the conversation must all be collected into top-level toolResults.""" + config = OCIChatConfig() + messages = [ + {"role": "user", "content": "Compare weather in Paris and London"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "get_weather", + "arguments": json.dumps({"location": "Paris"}), + }, + }, + { + "id": "call_2", + "type": "function", + "function": { + "name": "get_weather", + "arguments": json.dumps({"location": "London"}), + }, + }, + ], + }, + { + "role": "tool", + "tool_call_id": "call_1", + "content": "Paris: 18C", + }, + { + "role": "tool", + "tool_call_id": "call_2", + "content": "London: 15C", + }, + ] + optional_params = { + "oci_compartment_id": TEST_COMPARTMENT_ID, + } + + transformed_request = config.transform_request( + model="cohere.command-rplus", + messages=messages, # type: ignore + optional_params=optional_params, + litellm_params={}, + headers={}, + ) + + chat_request = transformed_request["chatRequest"] + assert chat_request["message"] == "" + assert "toolResults" in chat_request + assert len(chat_request["toolResults"]) == 2 + assert chat_request["toolResults"][0]["call"]["name"] == "get_weather" + assert chat_request["toolResults"][0]["call"]["parameters"] == {"location": "Paris"} + assert chat_request["toolResults"][1]["call"]["parameters"] == {"location": "London"} + assert len(chat_request["chatHistory"]) == 1 + + def test_cohere_citation_document_ids_alias_and_field_name(self): + """CohereCitation parses OCI's camelCase documentIds alias as well as snake_case document_ids. + See issue #46157. + """ + from litellm.types.llms.oci import CohereCitation, CohereChatResult + + # 1. Direct validation using camelCase documentIds (OCI wire format) + citation_from_alias = CohereCitation.model_validate({ + "start": 0, + "end": 10, + "text": "sample text", + "documentIds": ["doc_1", "doc_2"], + }) + assert citation_from_alias.document_ids == ["doc_1", "doc_2"] + + # 2. Direct validation using snake_case document_ids + citation_from_name = CohereCitation.model_validate({ + "start": 0, + "end": 10, + "text": "sample text", + "document_ids": ["doc_3"], + }) + assert citation_from_name.document_ids == ["doc_3"] + + # 3. Validation inside full CohereChatResult + full_response_payload = { + "modelId": "cohere.command-a-03-2025", + "modelVersion": "1.0", + "chatResponse": { + "apiFormat": "COHERE", + "text": "Priority is decided based on outage level.", + "finishReason": "COMPLETE", + "citations": [ + { + "start": 0, + "end": 20, + "text": "Priority is decided", + "documentIds": ["lookup_policy:0:1:0"], + } + ], + "usage": { + "promptTokens": 15, + "completionTokens": 10, + "totalTokens": 25, + }, + }, + } + chat_result = CohereChatResult.model_validate(full_response_payload) + assert chat_result.chatResponse.citations is not None + assert len(chat_result.chatResponse.citations) == 1 + assert chat_result.chatResponse.citations[0].document_ids == ["lookup_policy:0:1:0"] + + def test_cohere_transform_response_with_citations(self): + """transform_response parses responses containing citations with documentIds without raising. + See issue #46157. + """ + config = OCIChatConfig() + mock_response = { + "modelId": "cohere.command-a-03-2025", + "modelVersion": "1.0", + "chatResponse": { + "apiFormat": "COHERE", + "text": "P1 is for an outage.", + "finishReason": "COMPLETE", + "citations": [ + { + "start": 0, + "end": 20, + "text": "P1 is for an outage.", + "documentIds": ["lookup_policy:0:1:0"], + } + ], + "usage": { + "promptTokens": 20, + "completionTokens": 8, + "totalTokens": 28, + }, + }, + } + + response = httpx.Response( + status_code=200, + json=mock_response, + headers={"Content-Type": "application/json"}, + ) + + result = config.transform_response( + model="cohere.command-a-03-2025", + raw_response=response, + model_response=ModelResponse(), + logging_obj={}, # type: ignore + request_data={}, + messages=[], + optional_params={}, + litellm_params={}, + encoding={}, + ) + + assert isinstance(result, ModelResponse) + assert result.choices[0].message.content == "P1 is for an outage." + assert result.choices[0].finish_reason == "stop" + class TestOCICohereStreaming: """Test Cohere streaming functionality"""