From fd0723c0da927f991692dd1264730ad1d3072a81 Mon Sep 17 00:00:00 2001 From: Yusuf Efe <120668197+yuefdev@users.noreply.github.com> Date: Wed, 16 Sep 2026 03:24:46 +0300 Subject: [PATCH 1/5] fix(cohere): drop synthetic tool call index in v2 chat transformation --- litellm/litellm_core_utils/core_helpers.py | 5 + litellm/llms/cohere/chat/v2_transformation.py | 30 +++-- .../cohere/chat/test_cohere_transformation.py | 104 +++++++++++++++++- 3 files changed, 127 insertions(+), 12 deletions(-) diff --git a/litellm/litellm_core_utils/core_helpers.py b/litellm/litellm_core_utils/core_helpers.py index aa7d6ca1699..1ab390f3e38 100644 --- a/litellm/litellm_core_utils/core_helpers.py +++ b/litellm/litellm_core_utils/core_helpers.py @@ -261,6 +261,11 @@ def remove_index_from_tool_calls( for tool_call in _tool_calls: if isinstance(tool_call, dict) and "index" in tool_call: # Type guard to ensure it's a dict tool_call.pop("index", None) + elif hasattr(tool_call, "index"): + try: + delattr(tool_call, "index") + except Exception: + pass def remove_items_at_indices(items: list[Any] | None, indices: Iterable[int]) -> None: diff --git a/litellm/llms/cohere/chat/v2_transformation.py b/litellm/llms/cohere/chat/v2_transformation.py index 4252e7d02e9..007b05a5af6 100644 --- a/litellm/llms/cohere/chat/v2_transformation.py +++ b/litellm/llms/cohere/chat/v2_transformation.py @@ -12,7 +12,7 @@ from litellm.types.llms.openai import ( AllMessageValues, ChatCompletionAnnotation, ChatCompletionAnnotationURLCitation, - ChatCompletionToolCallChunk, + ChatCompletionAssistantToolCall, ) from litellm.types.utils import ModelResponse, Usage @@ -174,10 +174,25 @@ class CohereV2ChatConfig(OpenAIGPTConfig): litellm_params: dict, headers: dict, ) -> dict: - """ - Cohere v2 chat api is in openai format, so we can use the openai transform request function to transform the request. - """ data: Final = super().transform_request(model, messages, optional_params, litellm_params, headers) + messages_to_send: Final = data.get("messages") + if messages_to_send is not None: + litellm.remove_index_from_tool_calls(messages=messages_to_send) + + return data + + async def async_transform_request( + self, + model: str, + messages: list[AllMessageValues], + optional_params: dict, + litellm_params: dict, + headers: dict, + ) -> dict: + data: Final = await super().async_transform_request(model, messages, optional_params, litellm_params, headers) + messages_to_send: Final = data.get("messages") + if messages_to_send is not None: + litellm.remove_index_from_tool_calls(messages=messages_to_send) return data @@ -225,11 +240,10 @@ class CohereV2ChatConfig(OpenAIGPTConfig): cohere_tools_response: Final = cohere_v2_chat_response["message"].get("tool_calls", []) if cohere_tools_response is not None and cohere_tools_response != []: # convert cohere_tools_response to OpenAI response format - tool_calls: Final[list[ChatCompletionToolCallChunk]] = [] - for index, tool in enumerate(cohere_tools_response): - tool_call: ChatCompletionToolCallChunk = { + tool_calls: Final[list[ChatCompletionAssistantToolCall]] = [] + for tool in cohere_tools_response: + tool_call: ChatCompletionAssistantToolCall = { **tool, - "index": index, } tool_calls.append(tool_call) _message: Final = litellm.Message( diff --git a/tests/test_litellm/llms/cohere/chat/test_cohere_transformation.py b/tests/test_litellm/llms/cohere/chat/test_cohere_transformation.py index 61334b6ff63..3b5d337db43 100644 --- a/tests/test_litellm/llms/cohere/chat/test_cohere_transformation.py +++ b/tests/test_litellm/llms/cohere/chat/test_cohere_transformation.py @@ -1,6 +1,5 @@ from unittest.mock import MagicMock - import litellm from litellm.llms.cohere.chat.transformation import CohereChatConfig from litellm.llms.cohere.chat.v2_transformation import CohereV2ChatConfig @@ -55,9 +54,7 @@ class TestCohereV2Transform: def test_v2_supports_max_completion_tokens(self): """max_completion_tokens must be advertised so get_optional_params does not reject it""" - assert "max_completion_tokens" in self.config.get_supported_openai_params( - self.model - ) + assert "max_completion_tokens" in self.config.get_supported_openai_params(self.model) def test_v2_max_tokens_only_still_maps(self): """max_tokens alone maps to cohere max_tokens when max_completion_tokens is absent""" @@ -112,3 +109,102 @@ class TestCohereV2Transform: ) assert optional_params["max_tokens"] == 256 + + def test_v2_transform_response_tool_calls_no_synthetic_index(self): + import httpx + + from litellm.types.utils import ModelResponse + + raw_body = { + "id": "res-1", + "message": { + "role": "assistant", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": {"name": "get_weather", "arguments": '{"location": "Paris"}'}, + } + ], + }, + "usage": {"tokens": {"input_tokens": 10, "output_tokens": 20}}, + } + resp = httpx.Response(status_code=200, json=raw_body) + model_response = ModelResponse() + result = self.config.transform_response( + model=self.model, + raw_response=resp, + model_response=model_response, + logging_obj=None, + request_data={}, + messages=[], + optional_params={}, + litellm_params={}, + encoding=None, + ) + + tool_calls = result.choices[0].message.tool_calls + assert tool_calls is not None + assert len(tool_calls) == 1 + assert "index" not in tool_calls[0] + assert not hasattr(tool_calls[0], "index") + assert tool_calls[0].id == "call_1" + assert tool_calls[0].function.name == "get_weather" + + def test_v2_transform_request_removes_index_from_tool_calls(self): + messages = [ + {"role": "user", "content": "What is the weather in Paris?"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "index": 0, + "id": "call_1", + "type": "function", + "function": {"name": "get_weather", "arguments": '{"location": "Paris"}'}, + } + ], + }, + {"role": "tool", "tool_call_id": "call_1", "content": "18C, sunny"}, + ] + + result = self.config.transform_request( + model=self.model, + messages=messages, + optional_params={}, + litellm_params={}, + headers={}, + ) + + request_tool_calls = result["messages"][1]["tool_calls"] + assert "index" not in request_tool_calls[0] + + async def test_v2_async_transform_request_removes_index_from_tool_calls(self): + messages = [ + {"role": "user", "content": "What is the weather in Paris?"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "index": 0, + "id": "call_1", + "type": "function", + "function": {"name": "get_weather", "arguments": '{"location": "Paris"}'}, + } + ], + }, + {"role": "tool", "tool_call_id": "call_1", "content": "18C, sunny"}, + ] + + result = await self.config.async_transform_request( + model=self.model, + messages=messages, + optional_params={}, + litellm_params={}, + headers={}, + ) + + request_tool_calls = result["messages"][1]["tool_calls"] + assert "index" not in request_tool_calls[0] From a0a4300f0ee3e7bf2872aedb8139bc321c26518a Mon Sep 17 00:00:00 2001 From: Yusuf Efe <120668197+yuefdev@users.noreply.github.com> Date: Wed, 16 Sep 2026 03:45:55 +0300 Subject: [PATCH 2/5] fix(cohere): strongly type mapping parameters and drop unused helper branch --- litellm/litellm_core_utils/core_helpers.py | 5 ----- litellm/llms/cohere/chat/v2_transformation.py | 16 ++++++++-------- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/litellm/litellm_core_utils/core_helpers.py b/litellm/litellm_core_utils/core_helpers.py index 1ab390f3e38..aa7d6ca1699 100644 --- a/litellm/litellm_core_utils/core_helpers.py +++ b/litellm/litellm_core_utils/core_helpers.py @@ -261,11 +261,6 @@ def remove_index_from_tool_calls( for tool_call in _tool_calls: if isinstance(tool_call, dict) and "index" in tool_call: # Type guard to ensure it's a dict tool_call.pop("index", None) - elif hasattr(tool_call, "index"): - try: - delattr(tool_call, "index") - except Exception: - pass def remove_items_at_indices(items: list[Any] | None, indices: Iterable[int]) -> None: diff --git a/litellm/llms/cohere/chat/v2_transformation.py b/litellm/llms/cohere/chat/v2_transformation.py index 007b05a5af6..c43635f2f5f 100644 --- a/litellm/llms/cohere/chat/v2_transformation.py +++ b/litellm/llms/cohere/chat/v2_transformation.py @@ -170,10 +170,10 @@ class CohereV2ChatConfig(OpenAIGPTConfig): self, model: str, messages: list[AllMessageValues], - optional_params: dict, - litellm_params: dict, - headers: dict, - ) -> dict: + optional_params: dict[str, object], + litellm_params: dict[str, object], + headers: dict[str, object], + ) -> dict[str, object]: data: Final = super().transform_request(model, messages, optional_params, litellm_params, headers) messages_to_send: Final = data.get("messages") if messages_to_send is not None: @@ -185,10 +185,10 @@ class CohereV2ChatConfig(OpenAIGPTConfig): self, model: str, messages: list[AllMessageValues], - optional_params: dict, - litellm_params: dict, - headers: dict, - ) -> dict: + optional_params: dict[str, object], + litellm_params: dict[str, object], + headers: dict[str, object], + ) -> dict[str, object]: data: Final = await super().async_transform_request(model, messages, optional_params, litellm_params, headers) messages_to_send: Final = data.get("messages") if messages_to_send is not None: From b644900a64a60483160f1a816cfa54c1d564c55c Mon Sep 17 00:00:00 2001 From: Yusuf Efe <120668197+yuefdev@users.noreply.github.com> Date: Sun, 18 Oct 2026 05:29:53 +0300 Subject: [PATCH 3/5] fix(cohere): keep request inputs read-only Signed-off-by: Yusuf Efe <120668197+yuefdev@users.noreply.github.com> --- litellm/llms/cohere/chat/v2_transformation.py | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/litellm/llms/cohere/chat/v2_transformation.py b/litellm/llms/cohere/chat/v2_transformation.py index fc11dfbd020..f5789af60e7 100644 --- a/litellm/llms/cohere/chat/v2_transformation.py +++ b/litellm/llms/cohere/chat/v2_transformation.py @@ -1,5 +1,5 @@ import time -from collections.abc import AsyncIterator, Iterator, Mapping +from collections.abc import AsyncIterator, Iterator, Mapping, Sequence from types import MappingProxyType from typing import TYPE_CHECKING, Any, Final @@ -169,12 +169,14 @@ class CohereV2ChatConfig(OpenAIGPTConfig): def transform_request( self, model: str, - messages: list[AllMessageValues], - optional_params: dict[str, object], - litellm_params: dict[str, object], - headers: dict[str, object], + messages: Sequence[AllMessageValues], + optional_params: Mapping[str, object], + litellm_params: Mapping[str, object], + headers: Mapping[str, object], ) -> dict[str, object]: - data: Final = super().transform_request(model, messages, optional_params, litellm_params, headers) + data: Final = super().transform_request( + model, list(messages), dict(optional_params), dict(litellm_params), dict(headers) + ) messages_to_send: Final = data.get("messages") if messages_to_send is not None: litellm.remove_index_from_tool_calls(messages=messages_to_send) @@ -184,12 +186,14 @@ class CohereV2ChatConfig(OpenAIGPTConfig): async def async_transform_request( self, model: str, - messages: list[AllMessageValues], - optional_params: dict[str, object], - litellm_params: dict[str, object], - headers: dict[str, object], + messages: Sequence[AllMessageValues], + optional_params: Mapping[str, object], + litellm_params: Mapping[str, object], + headers: Mapping[str, object], ) -> dict[str, object]: - data: Final = await super().async_transform_request(model, messages, optional_params, litellm_params, headers) + data: Final = await super().async_transform_request( + model, list(messages), dict(optional_params), dict(litellm_params), dict(headers) + ) messages_to_send: Final = data.get("messages") if messages_to_send is not None: litellm.remove_index_from_tool_calls(messages=messages_to_send) From 78eda98b6c8a15637712048f10724affcd6210c1 Mon Sep 17 00:00:00 2001 From: Yusuf Efe <120668197+yuefdev@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:35:09 +0300 Subject: [PATCH 4/5] fix(cohere): type the legacy request transformer boundary Signed-off-by: Yusuf Efe <120668197+yuefdev@users.noreply.github.com> --- litellm/llms/cohere/chat/v2_transformation.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/litellm/llms/cohere/chat/v2_transformation.py b/litellm/llms/cohere/chat/v2_transformation.py index f5789af60e7..c6b3e6d6d76 100644 --- a/litellm/llms/cohere/chat/v2_transformation.py +++ b/litellm/llms/cohere/chat/v2_transformation.py @@ -174,12 +174,12 @@ class CohereV2ChatConfig(OpenAIGPTConfig): litellm_params: Mapping[str, object], headers: Mapping[str, object], ) -> dict[str, object]: - data: Final = super().transform_request( + data: Final[dict[str, object]] = super().transform_request( # pyright: ignore[reportUnknownMemberType] # untyped parent dictionary contract model, list(messages), dict(optional_params), dict(litellm_params), dict(headers) ) - messages_to_send: Final = data.get("messages") + messages_to_send: Final[Sequence[AllMessageValues] | None] = data.get("messages") # pyright: ignore[reportAssignmentType] # legacy dict erases the message-list type if messages_to_send is not None: - litellm.remove_index_from_tool_calls(messages=messages_to_send) + litellm.remove_index_from_tool_calls(messages=list(messages_to_send)) return data @@ -191,12 +191,12 @@ class CohereV2ChatConfig(OpenAIGPTConfig): litellm_params: Mapping[str, object], headers: Mapping[str, object], ) -> dict[str, object]: - data: Final = await super().async_transform_request( + data: Final[dict[str, object]] = await super().async_transform_request( # pyright: ignore[reportUnknownMemberType] # untyped parent dictionaries model, list(messages), dict(optional_params), dict(litellm_params), dict(headers) ) - messages_to_send: Final = data.get("messages") + messages_to_send: Final[Sequence[AllMessageValues] | None] = data.get("messages") # pyright: ignore[reportAssignmentType] # legacy dict erases the message-list type if messages_to_send is not None: - litellm.remove_index_from_tool_calls(messages=messages_to_send) + litellm.remove_index_from_tool_calls(messages=list(messages_to_send)) return data From 5c2f5ec78a1ff42c199873a4e541a960dd106f77 Mon Sep 17 00:00:00 2001 From: Yusuf Efe <120668197+yuefdev@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:00:03 +0300 Subject: [PATCH 5/5] style(cohere): keep boundary annotations within line length Signed-off-by: Yusuf Efe <120668197+yuefdev@users.noreply.github.com> --- litellm/llms/cohere/chat/v2_transformation.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/litellm/llms/cohere/chat/v2_transformation.py b/litellm/llms/cohere/chat/v2_transformation.py index c6b3e6d6d76..cddcccc4ef5 100644 --- a/litellm/llms/cohere/chat/v2_transformation.py +++ b/litellm/llms/cohere/chat/v2_transformation.py @@ -174,10 +174,12 @@ class CohereV2ChatConfig(OpenAIGPTConfig): litellm_params: Mapping[str, object], headers: Mapping[str, object], ) -> dict[str, object]: - data: Final[dict[str, object]] = super().transform_request( # pyright: ignore[reportUnknownMemberType] # untyped parent dictionary contract + data: Final[dict[str, object]] + data = super().transform_request( # pyright: ignore[reportUnknownMemberType] # untyped parent dictionaries model, list(messages), dict(optional_params), dict(litellm_params), dict(headers) ) - messages_to_send: Final[Sequence[AllMessageValues] | None] = data.get("messages") # pyright: ignore[reportAssignmentType] # legacy dict erases the message-list type + messages_to_send: Final[Sequence[AllMessageValues] | None] + messages_to_send = data.get("messages") # pyright: ignore[reportAssignmentType] # parent erases message type if messages_to_send is not None: litellm.remove_index_from_tool_calls(messages=list(messages_to_send)) @@ -191,10 +193,12 @@ class CohereV2ChatConfig(OpenAIGPTConfig): litellm_params: Mapping[str, object], headers: Mapping[str, object], ) -> dict[str, object]: - data: Final[dict[str, object]] = await super().async_transform_request( # pyright: ignore[reportUnknownMemberType] # untyped parent dictionaries + data: Final[dict[str, object]] + data = await super().async_transform_request( # pyright: ignore[reportUnknownMemberType] # untyped parent model, list(messages), dict(optional_params), dict(litellm_params), dict(headers) ) - messages_to_send: Final[Sequence[AllMessageValues] | None] = data.get("messages") # pyright: ignore[reportAssignmentType] # legacy dict erases the message-list type + messages_to_send: Final[Sequence[AllMessageValues] | None] + messages_to_send = data.get("messages") # pyright: ignore[reportAssignmentType] # parent erases message type if messages_to_send is not None: litellm.remove_index_from_tool_calls(messages=list(messages_to_send))