From c6d1f6f23b2907e378c74084f9e32a0f1a6b9afb Mon Sep 17 00:00:00 2001 From: jryberg Date: Thu, 20 Aug 2026 07:31:33 +0200 Subject: [PATCH] [Bugfix][Frontend] Anthropic API: honor the `thinking` request parameter `AnthropicMessagesRequest` never declared a `thinking` field, so the documented Anthropic parameter was silently discarded -- Pydantic's default `extra="ignore"` drops it without an error. Clients that ask for disabled thinking, a token budget, or omitted reasoning display were answered as though they had asked for nothing, at whatever `output_config.effort` implied. Add `AnthropicThinkingConfig` and map it onto the reasoning controls that `ChatCompletionRequest` already exposes: {"type": "disabled"} -> reasoning_effort = "none" {"type": "enabled", budget_tokens: N} -> thinking_token_budget = N {"display": "omitted"} -> include_reasoning = False `{"type": "adaptive"}` deliberately pins nothing: the model chooses depth and `output_config.effort` stays the ceiling. `_handle_thinking` runs after `_handle_output_config` so an explicit `thinking` overrides the effort-derived default. `display` controls visibility only -- reasoning still runs and is still billed -- so it maps to `include_reasoning` rather than to any depth control. Verified against a DeepSeek-V4-Flash-0731 deployment: `reasoning_effort="none" takes reasoning from 1356 chars to 0, and `include_reasoning=False` suppresses it from the response. `thinking_token_budget` is rejected by the V2 model runner on current builds, so that branch is covered at the conversion layer. Signed-off-by: jryberg --- .../test_anthropic_messages_conversion.py | 106 ++++++++++++++++++ .../anthropic/test_protocol_exports.py | 2 + vllm/entrypoints/anthropic/protocol.py | 13 +++ vllm/entrypoints/anthropic/serving.py | 26 +++++ 4 files changed, 147 insertions(+) diff --git a/tests/entrypoints/anthropic/test_anthropic_messages_conversion.py b/tests/entrypoints/anthropic/test_anthropic_messages_conversion.py index 1cd7227bdc99..cc16ba26601a 100644 --- a/tests/entrypoints/anthropic/test_anthropic_messages_conversion.py +++ b/tests/entrypoints/anthropic/test_anthropic_messages_conversion.py @@ -1553,3 +1553,109 @@ def test_count_tokens_validation_error_returns_bad_request(self): assert response.status_code == HTTPStatus.BAD_REQUEST assert response.json()["error"]["type"] == "BadRequestError" + + +# ====================================================================== +# thinking configuration pass-through +# ====================================================================== + + +class TestThinkingConfig: + def test_absent_thinking_leaves_reasoning_untouched(self): + """Requests without `thinking` must convert exactly as before.""" + request = _make_request([{"role": "user", "content": "Hello"}]) + + result = _convert(request) + assert result.reasoning_effort is None + assert result.thinking_token_budget is None + assert result.include_reasoning is True + + def test_disabled_clears_reasoning_effort(self): + """`disabled` maps to reasoning_effort="none", which is what clears + enable_thinking for templates that honor it.""" + request = _make_request( + [{"role": "user", "content": "Hello"}], + thinking={"type": "disabled"}, + ) + + result = _convert(request) + assert result.reasoning_effort == "none" + + def test_disabled_overrides_output_config_effort(self): + """`thinking` is applied after output_config so an explicit opt-out wins + over an inherited effort ceiling.""" + request = _make_request( + [{"role": "user", "content": "Hello"}], + output_config={"effort": "high"}, + thinking={"type": "disabled"}, + ) + + result = _convert(request) + assert result.reasoning_effort == "none" + + def test_enabled_with_budget_sets_thinking_token_budget(self): + request = _make_request( + [{"role": "user", "content": "Hello"}], + thinking={"type": "enabled", "budget_tokens": 2048}, + ) + + result = _convert(request) + assert result.thinking_token_budget == 2048 + + def test_enabled_without_budget_pins_nothing(self): + request = _make_request( + [{"role": "user", "content": "Hello"}], + thinking={"type": "enabled"}, + ) + + result = _convert(request) + assert result.thinking_token_budget is None + assert result.reasoning_effort is None + + def test_adaptive_pins_nothing_and_keeps_effort_ceiling(self): + """`adaptive` lets the model choose depth, so only the ceiling from + output_config.effort should survive.""" + request = _make_request( + [{"role": "user", "content": "Hello"}], + output_config={"effort": "low"}, + thinking={"type": "adaptive"}, + ) + + result = _convert(request) + assert result.reasoning_effort == "low" + assert result.thinking_token_budget is None + + def test_display_omitted_suppresses_reasoning_in_response(self): + """`display` controls visibility only -- it must not touch depth.""" + request = _make_request( + [{"role": "user", "content": "Hello"}], + thinking={"type": "adaptive", "display": "omitted"}, + ) + + result = _convert(request) + assert result.include_reasoning is False + assert result.reasoning_effort is None + assert result.thinking_token_budget is None + + def test_display_summarized_keeps_reasoning_included(self): + request = _make_request( + [{"role": "user", "content": "Hello"}], + thinking={"type": "adaptive", "display": "summarized"}, + ) + + result = _convert(request) + assert result.include_reasoning is True + + def test_claude_code_payload(self): + """The combination Claude Code sends on every request: an effort ceiling + plus adaptive thinking with reasoning display omitted.""" + request = _make_request( + [{"role": "user", "content": "Hello"}], + output_config={"effort": "high"}, + thinking={"type": "adaptive", "display": "omitted"}, + ) + + result = _convert(request) + assert result.reasoning_effort == "high" + assert result.include_reasoning is False + assert result.thinking_token_budget is None diff --git a/tests/entrypoints/anthropic/test_protocol_exports.py b/tests/entrypoints/anthropic/test_protocol_exports.py index 466f40e3ccf5..1123175793b2 100644 --- a/tests/entrypoints/anthropic/test_protocol_exports.py +++ b/tests/entrypoints/anthropic/test_protocol_exports.py @@ -19,6 +19,7 @@ AnthropicMessagesResponse, AnthropicOutputConfig, AnthropicStreamEvent, + AnthropicThinkingConfig, AnthropicUsage, ) @@ -35,6 +36,7 @@ AnthropicMessagesResponse, AnthropicOutputConfig, AnthropicStreamEvent, + AnthropicThinkingConfig, AnthropicUsage, ) diff --git a/vllm/entrypoints/anthropic/protocol.py b/vllm/entrypoints/anthropic/protocol.py index 02b9356113ca..b7826936e298 100644 --- a/vllm/entrypoints/anthropic/protocol.py +++ b/vllm/entrypoints/anthropic/protocol.py @@ -118,6 +118,18 @@ class AnthropicOutputConfig(BaseModel): format: AnthropicJsonOutputFormat | None = None +class AnthropicThinkingConfig(BaseModel): + """Extended-thinking configuration. + + ``display`` controls visibility only: reasoning still runs and is still + billed under every setting. + """ + + type: Literal["enabled", "disabled", "adaptive"] = "enabled" + budget_tokens: int | None = None + display: Literal["summarized", "omitted"] | None = None + + class AnthropicMessagesRequest(BaseModel): """Anthropic Messages API request""" @@ -126,6 +138,7 @@ class AnthropicMessagesRequest(BaseModel): max_tokens: int metadata: dict[str, Any] | None = None output_config: AnthropicOutputConfig | None = None + thinking: AnthropicThinkingConfig | None = None stop_sequences: ( Annotated[list[str], Field(max_length=envs.VLLM_MAX_STOP_STRINGS)] | None ) = None diff --git a/vllm/entrypoints/anthropic/serving.py b/vllm/entrypoints/anthropic/serving.py index 4ec5c14e019b..6c8f5754a13a 100644 --- a/vllm/entrypoints/anthropic/serving.py +++ b/vllm/entrypoints/anthropic/serving.py @@ -27,6 +27,7 @@ AnthropicMessagesResponse, AnthropicOutputConfig, AnthropicStreamEvent, + AnthropicThinkingConfig, AnthropicUsage, ) from vllm.entrypoints.chat_utils import ChatTemplateContentFormatOption @@ -213,6 +214,7 @@ def _convert_anthropic_to_openai_request( req = cls._build_base_request(anthropic_request, openai_messages) cls._handle_streaming_options(req, anthropic_request) cls._handle_output_config(req, anthropic_request) + cls._handle_thinking(req, anthropic_request) cls._convert_tool_choice(anthropic_request, req) cls._convert_tools(anthropic_request, req) return req @@ -492,6 +494,30 @@ def _build_base_request( chat_template_kwargs=anthropic_request.chat_template_kwargs, ) + @classmethod + def _handle_thinking( + cls, + req: ChatCompletionRequest, + anthropic_request: AnthropicMessagesRequest | AnthropicCountTokensRequest, + ) -> None: + """Handle extended-thinking configuration""" + if isinstance(anthropic_request, AnthropicCountTokensRequest): + return + thinking: AnthropicThinkingConfig | None = anthropic_request.thinking + if thinking is None: + return + + if thinking.type == "disabled": + # "none" is what clears enable_thinking for templates that honor it. + req.reasoning_effort = "none" + elif thinking.type == "enabled" and thinking.budget_tokens is not None: + req.thinking_token_budget = thinking.budget_tokens + # "adaptive" pins nothing: the model chooses depth beneath the ceiling + # already set from output_config.effort. + + if thinking.display == "omitted": + req.include_reasoning = False + @classmethod def _handle_output_config( cls,