This commit is contained in:
pheonix 14 2026-10-19 19:10:32 +00:00 • committed by GitHub
commit 31e17f1bf8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 247 additions and 5 deletions

View file

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

View file

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

View file

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