From a9e90b9d1df255643fd0e600c8be182052bb6a75 Mon Sep 17 00:00:00 2001 From: Jovan <101700786+KuanJia@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:37:08 +0800 Subject: [PATCH] [Bugfix][Frontend] Reject Cohere REQUIRED tool choice without tools (#61825) Signed-off-by: Kevin --- tests/entrypoints/cohere/test_api_router.py | 15 +++++++++++++++ tests/entrypoints/cohere/test_protocol.py | 21 +++++++++++++++++++++ vllm/entrypoints/cohere/protocol.py | 12 +++++++++++- 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/tests/entrypoints/cohere/test_api_router.py b/tests/entrypoints/cohere/test_api_router.py index 209ceb4e3e..2c2fce1902 100644 --- a/tests/entrypoints/cohere/test_api_router.py +++ b/tests/entrypoints/cohere/test_api_router.py @@ -598,6 +598,21 @@ class TestCohereErrorEnvelope: assert "message" in body assert isinstance(body["message"], str) and body["message"] + @pytest.mark.parametrize("path", ["/cohere/v2/chat", "/cohere/v2/chat/render"]) + def test_required_tool_choice_without_tools_returns_400(self, path): + """Reject an impossible tool choice before reaching either handler.""" + app = _build_app_with_vllm_handlers(handler=None) + with TestClient(app) as client: + response = client.post( + path, + json={**_minimal_request_body(), "tool_choice": "REQUIRED"}, + ) + assert response.status_code == HTTPStatus.BAD_REQUEST + body = response.json() + assert "error" not in body + assert "REQUIRED" in body["message"] + assert "tools" in body["message"] + def test_http_error_body_is_cohere_shaped(self): # A raised ``HTTPException`` from anywhere in the request cycle # is routed through ``http_exception_handler`` (producing the diff --git a/tests/entrypoints/cohere/test_protocol.py b/tests/entrypoints/cohere/test_protocol.py index dc37ff7cdd..30e625cac2 100644 --- a/tests/entrypoints/cohere/test_protocol.py +++ b/tests/entrypoints/cohere/test_protocol.py @@ -95,6 +95,27 @@ class TestCohereChatV2Request: tool_choice="ANY", # not REQUIRED/NONE ) + @pytest.mark.parametrize("tool_fields", [{}, {"tools": None}, {"tools": []}]) + def test_required_tool_choice_requires_tools(self, tool_fields): + with pytest.raises(VLLMValidationError, match="REQUIRED.*tools") as exc_info: + CohereChatV2Request( + model="m", + messages=[{"role": "user", "content": "hi"}], + tool_choice="REQUIRED", + **tool_fields, + ) + assert exc_info.value.parameter == "tool_choice" + + @pytest.mark.parametrize("tool_choice", [None, "NONE"]) + def test_nonrequired_tool_choice_allows_empty_tools(self, tool_choice): + request = CohereChatV2Request( + model="m", + messages=[{"role": "user", "content": "hi"}], + tool_choice=tool_choice, + tools=[], + ) + assert request.tool_choice == tool_choice + def test_stop_sequences_bounded_by_env_limit(self): messages = [{"role": "user", "content": "hi"}] limit = envs.VLLM_MAX_STOP_STRINGS diff --git a/vllm/entrypoints/cohere/protocol.py b/vllm/entrypoints/cohere/protocol.py index 45e0d310dc..577462f592 100644 --- a/vllm/entrypoints/cohere/protocol.py +++ b/vllm/entrypoints/cohere/protocol.py @@ -44,7 +44,7 @@ from cohere.types import ( ToolV2, UserChatMessageV2, ) -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field, field_validator, model_validator import vllm.envs as envs from vllm.exceptions import VLLMValidationError @@ -211,6 +211,16 @@ class CohereChatV2Request(BaseModel): ) return v + @model_validator(mode="after") + def _validate_tool_choice(self) -> CohereChatV2Request: + if self.tool_choice == "REQUIRED" and not self.tools: + raise VLLMValidationError( + 'tool_choice="REQUIRED" requires at least one tool in tools.', + parameter="tool_choice", + value=self.tool_choice, + ) + return self + @field_validator("messages", mode="before") @classmethod def _normalize_message_roles(cls, v: Any) -> Any: