From 24fede8bb0fec1c7d00239454bf4bf26fe62adbe Mon Sep 17 00:00:00 2001 From: Abhyudaya Baiju Tharakan Date: Tue, 27 Oct 2026 01:01:50 +0530 Subject: [PATCH 1/3] fix(anthropic): preserve empty streamed tool arguments --- .../anthropic_continuation.py | 4 +- .../protocol_translation/anthropic_stream.py | 4 +- services/protocol_translation/common.py | 6 +- tests/outbound_inventory.json | 6 +- tests/test_anthropic_parallel_stream.py | 205 ++++++++++++++++++ 5 files changed, 218 insertions(+), 7 deletions(-) create mode 100644 tests/test_anthropic_parallel_stream.py diff --git a/services/protocol_translation/anthropic_continuation.py b/services/protocol_translation/anthropic_continuation.py index a1967a60..319787ce 100644 --- a/services/protocol_translation/anthropic_continuation.py +++ b/services/protocol_translation/anthropic_continuation.py @@ -142,7 +142,9 @@ def observe(self, event): if not isinstance(value, str): raise ValueError("Invalid Anthropic tool arguments") self.arguments[index] = self.arguments.get(index, "") + value - elif index in self.arguments: + elif self.arguments.get(index): + # Empty fragments carry no JSON; retain the input from block start. + # Nonempty malformed JSON must still fail instead of losing arguments. self.blocks[index]["input"] = json.loads(self.arguments[index]) self.bytes += len(json.dumps(event, ensure_ascii=False).encode()) if self.bytes > MAX_CONTINUATION_BYTES: diff --git a/services/protocol_translation/anthropic_stream.py b/services/protocol_translation/anthropic_stream.py index b609b2a7..c79380db 100644 --- a/services/protocol_translation/anthropic_stream.py +++ b/services/protocol_translation/anthropic_stream.py @@ -155,9 +155,9 @@ def chunk( if ( block.get("kind") == "tool" and not block["streamed"] - and block.get("input") + and isinstance(block.get("input"), Mapping) ): - # Some compatible servers send the whole input at block start. + # With no argument fragments, preserve the start input, even {}. yield from chunk( { "tool_calls": [ diff --git a/services/protocol_translation/common.py b/services/protocol_translation/common.py index 3411d04c..70b38610 100644 --- a/services/protocol_translation/common.py +++ b/services/protocol_translation/common.py @@ -9,6 +9,7 @@ from __future__ import annotations import json +import logging import re import time import uuid @@ -18,6 +19,8 @@ from streaming.sse import SSEEvent, iter_sse_events +logger = logging.getLogger(__name__) + CHAT = "chat" RESPONSES = "responses" MESSAGES = "messages" @@ -481,7 +484,8 @@ def guarded_items(items: Iterable[ChatItem]) -> Iterator[ChatItem]: """End with a failure, not an exception, when an upstream event has a bad shape.""" try: yield from items - except (TypeError, AttributeError, KeyError, IndexError, ValueError): + except (TypeError, AttributeError, KeyError, IndexError, ValueError) as error: + logger.warning("Upstream stream translation failed type=%s", type(error).__name__) yield StreamFailure("The upstream stream could not be translated.") diff --git a/tests/outbound_inventory.json b/tests/outbound_inventory.json index 6f4ccec0..f7519fa7 100644 --- a/tests/outbound_inventory.json +++ b/tests/outbound_inventory.json @@ -1097,7 +1097,7 @@ "services/protocol_translation/anthropic_continuation.py": { "kind": "non-provider", "reason": "Runtime module pinned independently of HTTP client spelling; network attempts require runtime admission.", - "sha256": "876a709fcf9661f9508d6c6e566362901c5133880a80b46c98ac67b678dff7bb" + "sha256": "4c963cca96a891cd909675d15e4c2ea4b48b9e9cf29aa281f84ebd548a6856ed" }, "services/protocol_translation/anthropic_request_policy.py": { "kind": "non-provider", @@ -1107,7 +1107,7 @@ "services/protocol_translation/anthropic_stream.py": { "kind": "non-provider", "reason": "Runtime module pinned independently of HTTP client spelling; network attempts require runtime admission.", - "sha256": "b2709c2e9b6d1abd6996ef8ec9be59ab6cb1cbc291f3f4002c419edfa5c916f0" + "sha256": "6aece70e3943ec2aa3f4eaeebc3c421b3009e13299af3d7f4406b3e35ffb219b" }, "services/protocol_translation/anthropic_usage.py": { "kind": "non-provider", @@ -1117,7 +1117,7 @@ "services/protocol_translation/common.py": { "kind": "non-provider", "reason": "Runtime module pinned independently of HTTP client spelling; network attempts require runtime admission.", - "sha256": "8e7824816c68a0ac6c5a9d0d0db7ee56561896f967e6ab8638e2f4ff546f9483" + "sha256": "65c88feb3b60523eb8ca0ede1f0ebfaa94972eda37cc42bd5aebc0088db4d75f" }, "services/protocol_translation/responses.py": { "kind": "non-provider", diff --git a/tests/test_anthropic_parallel_stream.py b/tests/test_anthropic_parallel_stream.py new file mode 100644 index 00000000..41146b5f --- /dev/null +++ b/tests/test_anthropic_parallel_stream.py @@ -0,0 +1,205 @@ +"""Signed parallel tool turns, including tools without argument fragments.""" + +import json +import logging + +import pytest + +from services.protocol_translation import ( + CHAT, + MESSAGES, + translate_request, + translate_stream, +) +from services.protocol_translation.anthropic import messages_sse_items +from services.protocol_translation.common import ( + StreamFailure, + aggregate_chat_items, + guarded_items, +) +from streaming.sse import iter_sse_events + +CONTENT = [ + {"type": "thinking", "thinking": "", "signature": "opaque-signature"}, + { + "type": "tool_use", + "id": "toolu_tokyo", + "name": "get_current_time", + "input": {"timezone": "Asia/Tokyo"}, + }, + { + "type": "tool_use", + "id": "toolu_london", + "name": "get_current_time", + "input": {"timezone": "Europe/London"}, + }, + { + "type": "tool_use", + "id": "toolu_reminders", + "name": "list_reminders", + "input": {}, + }, +] + + +def parallel_frames(empty_fragments, interleaved=False): + events = [ + { + "type": "message_start", + "message": { + "id": "msg_parallel", + "model": "claude-haiku-5-5", + "usage": {"input_tokens": 5, "output_tokens": 0}, + }, + }, + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "thinking", "thinking": ""}, + }, + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "signature_delta", "signature": "opaque-signature"}, + }, + ] + if not interleaved: + events.append({"type": "content_block_stop", "index": 0}) + for index, block in enumerate(CONTENT[1:], 1): + events.append( + { + "type": "content_block_start", + "index": index, + "content_block": {**block, "input": {}}, + } + ) + fragments = ( + [json.dumps(block["input"])[:8], json.dumps(block["input"])[8:]] + if block["input"] + else empty_fragments + ) + events.extend( + { + "type": "content_block_delta", + "index": index, + "delta": {"type": "input_json_delta", "partial_json": fragment}, + } + for fragment in fragments + ) + if not interleaved: + events.append({"type": "content_block_stop", "index": index}) + if interleaved: + events.extend( + {"type": "content_block_stop", "index": index} for index in (2, 0, 3, 1) + ) + events.extend( + [ + { + "type": "message_delta", + "delta": {"stop_reason": "tool_use"}, + "usage": {"output_tokens": 8}, + }, + {"type": "message_stop"}, + ] + ) + return [ + f"event: {event['type']}\ndata: {json.dumps(event)}\n\n".encode() + for event in events + ] + + +@pytest.mark.parametrize("interleaved", [False, True]) +@pytest.mark.parametrize("empty_fragments", [[], [""], ["", ""], ["{}"]]) +def test_signed_parallel_tools_stream_to_chat(empty_fragments, interleaved): + frames = parallel_frames(empty_fragments, interleaved) + # Exercise the unguarded translator too, so failures expose their real type. + items = list(messages_sse_items(frames)) + assert not any(isinstance(item, StreamFailure) for item in items) + output = list(translate_stream(frames, MESSAGES, CHAT)) + events = list(iter_sse_events(output)) + assert events[-1].is_done + chunks = [ + json.loads(event.data) for event in events if event.data and not event.is_done + ] + assert not any("error" in chunk for chunk in chunks) + starts = [ + call + for chunk in chunks + for call in chunk["choices"][0]["delta"].get("tool_calls", []) + if "id" in call + ] + assert [(call["index"], call["id"]) for call in starts] == [ + (index, block["id"]) for index, block in enumerate(CONTENT[1:]) + ] + completion = aggregate_chat_items(chunks) + assert completion["choices"][0]["finish_reason"] == "tool_calls" + assert completion["usage"] == { + "prompt_tokens": 5, + "completion_tokens": 8, + "total_tokens": 13, + } + assistant = completion["choices"][0]["message"] + calls = assistant["tool_calls"] + assert [json.loads(call["function"]["arguments"]) for call in calls] == [ + block["input"] for block in CONTENT[1:] + ] + assert calls[0]["extra_content"]["anthropic"]["content"] == CONTENT + assert all("extra_content" not in call for call in calls[1:]) + request = { + "model": "claude-haiku-5-5", + "messages": [ + {"role": "user", "content": "Times and reminders?"}, + assistant, + *[ + {"role": "tool", "tool_call_id": call["id"], "content": "ok"} + for call in calls + ], + ], + } + assert ( + translate_request(request, CHAT, MESSAGES)["messages"][1]["content"] == CONTENT + ) + + +@pytest.mark.parametrize("fragments", [["{"], [" "]]) +def test_nonempty_invalid_arguments_still_fail_closed(fragments, caplog): + with caplog.at_level(logging.WARNING): + output = list(translate_stream(parallel_frames(fragments), MESSAGES, CHAT)) + events = list(iter_sse_events(output)) + chunks = [ + json.loads(event.data) for event in events if event.data and not event.is_done + ] + assert events[-1].is_done + assert ( + chunks[-1]["error"]["message"] == "The upstream stream could not be translated." + ) + assert not any( + choice.get("finish_reason") + for chunk in chunks + for choice in chunk.get("choices", []) + ) + assert "type=JSONDecodeError" in caplog.text + + +@pytest.mark.parametrize( + "error_type", [TypeError, AttributeError, KeyError, IndexError, ValueError] +) +def test_stream_guard_logs_only_exception_type(error_type, caplog): + def broken_items(): + yield {"choices": []} + raise error_type("private upstream content") + + with caplog.at_level(logging.WARNING): + items = list(guarded_items(broken_items())) + assert items == [ + {"choices": []}, + StreamFailure("The upstream stream could not be translated."), + ] + record = caplog.records[-1] + assert ( + record.getMessage() + == f"Upstream stream translation failed type={error_type.__name__}" + ) + assert record.exc_info is None + assert record.stack_info is None + assert "private upstream content" not in caplog.text From 4e767c69263c98540d317b7509a66f98c061ad6f Mon Sep 17 00:00:00 2001 From: Abhyudaya Baiju Tharakan Date: Tue, 27 Oct 2026 01:10:53 +0530 Subject: [PATCH 2/3] fix(anthropic): use adaptive thinking for Claude 5.5 --- providers/anthropic.py | 3 + .../anthropic_request_policy.py | 7 +- tests/outbound_inventory.json | 4 +- tests/test_anthropic_provider.py | 83 +++++++++++++++---- 4 files changed, 78 insertions(+), 19 deletions(-) diff --git a/providers/anthropic.py b/providers/anthropic.py index 5245fea4..0a214c09 100644 --- a/providers/anthropic.py +++ b/providers/anthropic.py @@ -5,6 +5,9 @@ from typing import Any HAIKU_5_5_MODEL_ID = "claude-haiku-5-5" +ADAPTIVE_THINKING_MODEL_IDS = frozenset( + {HAIKU_5_5_MODEL_ID, "claude-sonnet-5-5", "claude-opus-5-5"} +) ANTHROPIC_MODEL_IDS = (HAIKU_5_5_MODEL_ID,) ANTHROPIC_REQUEST_HEADER_WHITELIST = { "anthropic-beta": "Anthropic-Beta", diff --git a/services/protocol_translation/anthropic_request_policy.py b/services/protocol_translation/anthropic_request_policy.py index 2f3cf6ff..d988ba7e 100644 --- a/services/protocol_translation/anthropic_request_policy.py +++ b/services/protocol_translation/anthropic_request_policy.py @@ -3,7 +3,7 @@ from collections.abc import Mapping from typing import Any -from providers.anthropic import HAIKU_5_5_MODEL_ID +from providers.anthropic import ADAPTIVE_THINKING_MODEL_IDS MIN_THINKING_BUDGET = 1024 THINKING_BUDGETS = { @@ -18,7 +18,8 @@ def apply_thinking(body: dict, payload: Mapping[str, Any]) -> None: - if body.get("model") == HAIKU_5_5_MODEL_ID: + model = body.get("model") + if isinstance(model, str) and model in ADAPTIVE_THINKING_MODEL_IDS: _apply_adaptive_thinking(body, payload) return thinking = payload.get("thinking") @@ -64,7 +65,7 @@ def apply_thinking(body: dict, payload: Mapping[str, Any]) -> None: def _apply_adaptive_thinking(body: dict, payload: Mapping[str, Any]) -> None: - # Haiku 5.5 rejects manual budgets and most client sampling defaults. + # Claude 5.5 models reject manual budgets and most client sampling defaults. for field in ("temperature", "top_p", "top_k"): body.pop(field, None) output_config = payload.get("output_config") diff --git a/tests/outbound_inventory.json b/tests/outbound_inventory.json index f7519fa7..5589f76a 100644 --- a/tests/outbound_inventory.json +++ b/tests/outbound_inventory.json @@ -47,7 +47,7 @@ "providers/anthropic.py": { "kind": "non-provider", "reason": "Runtime module pinned independently of HTTP client spelling; network attempts require runtime admission.", - "sha256": "775f4b5241684e69c80a62b23cd61a9b41de7e39e5931e2a4ea83c027e95a8fe" + "sha256": "3e389860fc6d38fa82f67dc2326261aeb4e3fa4fecca586d648189b85579a6cb" }, "providers/base.py": { "kind": "non-provider", @@ -1102,7 +1102,7 @@ "services/protocol_translation/anthropic_request_policy.py": { "kind": "non-provider", "reason": "Runtime module pinned independently of HTTP client spelling; network attempts require runtime admission.", - "sha256": "61bb336865809714a61f7af0cc620ee618ab805f441744697e8c5758d6d0e062" + "sha256": "6fbc8c9757cc29676d9db726045ca17de3eb51a2e9571985a1ef747acbd8f040" }, "services/protocol_translation/anthropic_stream.py": { "kind": "non-provider", diff --git a/tests/test_anthropic_provider.py b/tests/test_anthropic_provider.py index 4da2e062..e06d84bc 100644 --- a/tests/test_anthropic_provider.py +++ b/tests/test_anthropic_provider.py @@ -1,4 +1,4 @@ -"""Direct Claude provider and Haiku 5.5 compatibility coverage.""" +"""Direct Claude provider and Claude 5.5 compatibility coverage.""" import io import json @@ -17,6 +17,7 @@ from tests.unified_api_test_case import UnifiedApiTestCase MODEL = "claude-haiku-5-5" +CLAUDE_5_5_MODELS = (MODEL, "claude-sonnet-5-5", "claude-opus-5-5") SELECTOR = f"anthropic:{MODEL}" MESSAGE = { "id": "msg_haiku", @@ -42,14 +43,17 @@ def upstream(body, status=200): return response -@pytest.mark.parametrize("effort", ["low", "medium", "high", "xhigh", "max", "minimal"]) -def test_haiku_effort_uses_adaptive_thinking_without_sampling(effort): +@pytest.mark.parametrize("model", CLAUDE_5_5_MODELS) +@pytest.mark.parametrize("max_tokens", [400, 8192]) +@pytest.mark.parametrize("nested", [False, True]) +@pytest.mark.parametrize("effort", ["none", "minimal", "low", "medium", "high", "xhigh", "max"]) +def test_claude_5_5_effort_uses_adaptive_thinking_without_sampling(model, max_tokens, nested, effort): body = chat_request_to_messages( { - "model": MODEL, + "model": model, "messages": [{"role": "user", "content": "Hello"}], - "reasoning_effort": effort, - "max_tokens": 100, + **({"reasoning": {"effort": effort}} if nested else {"reasoning_effort": effort}), + "max_tokens": max_tokens, "temperature": 0.7, "top_p": 1, "top_k": 40, @@ -59,19 +63,22 @@ def test_haiku_effort_uses_adaptive_thinking_without_sampling(effort): }, } ) - assert body["thinking"] == {"type": "adaptive"} + assert body["model"] == model + assert body["max_tokens"] == max_tokens + assert body["thinking"] == {"type": "disabled" if effort == "none" else "adaptive"} assert body["output_config"] == { - "effort": "low" if effort == "minimal" else effort, + "effort": "low" if effort in {"none", "minimal"} else effort, "format": {"type": "json_schema", "schema": {"type": "object"}}, } assert not {"temperature", "top_p", "top_k"} & body.keys() +@pytest.mark.parametrize("model", CLAUDE_5_5_MODELS) @pytest.mark.parametrize("thinking", [None, {"type": "disabled"}]) -def test_haiku_sampling_is_removed_even_without_explicit_thinking(thinking): +def test_claude_5_5_sampling_is_removed_even_without_explicit_thinking(model, thinking): body = chat_request_to_messages( { - "model": MODEL, + "model": model, "messages": [{"role": "user", "content": "Hello"}], "temperature": 0.2, "top_p": 0.8, @@ -83,25 +90,46 @@ def test_haiku_sampling_is_removed_even_without_explicit_thinking(thinking): assert body.get("thinking") == thinking -def test_haiku_manual_budget_becomes_adaptive_and_none_disables_thinking(): +@pytest.mark.parametrize("model", CLAUDE_5_5_MODELS) +def test_claude_5_5_manual_budget_becomes_adaptive_and_none_disables_thinking(model): payload = { - "model": MODEL, + "model": model, "messages": [{"role": "user", "content": "Hello"}], "thinking": {"type": "enabled", "budget_tokens": 8000, "display": "summarized"}, "output_config": {"effort": "medium"}, } body = chat_request_to_messages(payload) assert body["thinking"] == {"type": "adaptive", "display": "summarized"} + assert body["output_config"] == {"effort": "medium"} assert payload["thinking"]["budget_tokens"] == 8000 body = chat_request_to_messages({**payload, "reasoning": {"effort": "none"}}) assert body["thinking"] == {"type": "disabled"} assert body["output_config"]["effort"] == "low" -def test_older_claude_models_keep_manual_thinking_budget(): +@pytest.mark.parametrize("model", CLAUDE_5_5_MODELS) +def test_intelligence_keeps_maximum_default_exclusive_to_haiku(model): + from services.intelligence_anthropic import prepare_anthropic_request + + payload = {"model": model, "messages": [{"role": "user", "content": "Hello"}]} + body = prepare_anthropic_request(payload) + if model == MODEL: + assert body["thinking"] == {"type": "adaptive"} + assert body["output_config"] == {"effort": "max"} + else: + assert "thinking" not in body + assert "output_config" not in body + assert "reasoning_effort" not in payload + body = prepare_anthropic_request({**payload, "reasoning_effort": "medium"}) + assert body["thinking"] == {"type": "adaptive"} + assert body["output_config"] == {"effort": "medium"} + + +@pytest.mark.parametrize("model", ["claude-haiku-4-5", "claude-sonnet-4-5", "claude-opus-4-5"]) +def test_older_claude_models_keep_manual_thinking_budget(model): body = chat_request_to_messages( { - "model": "claude-haiku-4-5", + "model": model, "messages": [{"role": "user", "content": "Hello"}], "reasoning_effort": "medium", "max_tokens": 4096, @@ -156,6 +184,33 @@ def post(self, path, body, response=None, headers=None): result.get_data() return result, send + def test_claude_5_5_chat_routes_send_adaptive_effort_at_both_token_limits(self): + for model in CLAUDE_5_5_MODELS: + for path in ("/v1/chat/completions", "/lean/v1/chat/completions"): + for max_tokens in (400, 8192): + with self.subTest(model=model, path=path, max_tokens=max_tokens): + result, send = self.post( + path, + { + "model": f"anthropic:{model}", + "messages": [{"role": "user", "content": "Hello"}], + "reasoning_effort": "medium", + "max_tokens": max_tokens, + "temperature": 0.7, + "top_p": 0.8, + "top_k": 40, + }, + response=upstream({**MESSAGE, "model": model}), + ) + self.assertEqual(result.status_code, 200) + send.assert_called_once() + body = json.loads(send.call_args.kwargs["data"]) + self.assertEqual(body["model"], model) + self.assertEqual(body["max_tokens"], max_tokens) + self.assertEqual(body["thinking"], {"type": "adaptive"}) + self.assertEqual(body["output_config"], {"effort": "medium"}) + self.assertFalse({"temperature", "top_p", "top_k"} & body.keys()) + def test_chat_uses_saved_key_and_adaptive_thinking(self): result, send = self.post( "/v1/chat/completions", From 4fe37b5b20c2592a2359dcbdbc09e55bc46f50fa Mon Sep 17 00:00:00 2001 From: Abhyudaya Baiju Tharakan Date: Tue, 27 Oct 2026 01:22:49 +0530 Subject: [PATCH 3/3] fix(anthropic): turn thinking off the way each Claude 5.5 model accepts Haiku 5.5 accepts disabled thinking, but Sonnet 5.5 answers 400 unless it is sent as between_tools, and Opus 5.5 cannot turn thinking off at all (both checked against the API on 2026-10-26). reasoning_effort none and an explicit disabled now map per model; on Opus it thinks adaptively at the lowest effort. --- .../anthropic_request_policy.py | 14 +++++++++++++- tests/outbound_inventory.json | 2 +- tests/test_anthropic_provider.py | 14 +++++++++++--- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/services/protocol_translation/anthropic_request_policy.py b/services/protocol_translation/anthropic_request_policy.py index d988ba7e..1f3b44ba 100644 --- a/services/protocol_translation/anthropic_request_policy.py +++ b/services/protocol_translation/anthropic_request_policy.py @@ -15,6 +15,16 @@ "max": 32768, } EFFORTS = frozenset({"none", "minimal", "low", "medium", "high", "xhigh", "max"}) +# How each Claude 5.5 model turns thinking off, from the API's 400s on 2026-10-26: Haiku accepts +# "disabled", Sonnet only "between_tools", and Opus cannot turn it off, so there it stays adaptive. +THINKING_OFF = { + "claude-haiku-5-5": {"type": "disabled"}, + "claude-sonnet-5-5": {"type": "between_tools"}, +} + + +def _thinking_off(model: Any) -> dict: + return dict(THINKING_OFF.get(model, {"type": "adaptive"})) def apply_thinking(body: dict, payload: Mapping[str, Any]) -> None: @@ -81,13 +91,15 @@ def _apply_adaptive_thinking(body: dict, payload: Mapping[str, Any]) -> None: body["thinking"].pop("budget_tokens", None) if thinking["type"] == "enabled": body["thinking"]["type"] = "adaptive" + elif thinking["type"] == "disabled": + body["thinking"] = _thinking_off(body.get("model")) effort = payload.get("reasoning_effort") if effort is None and isinstance(payload.get("reasoning"), Mapping): effort = payload["reasoning"].get("effort") if effort not in EFFORTS: return if effort == "none": - body["thinking"] = {"type": "disabled"} + body["thinking"] = _thinking_off(body.get("model")) effort = "low" elif "thinking" not in body: body["thinking"] = {"type": "adaptive"} diff --git a/tests/outbound_inventory.json b/tests/outbound_inventory.json index 5589f76a..9023caa0 100644 --- a/tests/outbound_inventory.json +++ b/tests/outbound_inventory.json @@ -1102,7 +1102,7 @@ "services/protocol_translation/anthropic_request_policy.py": { "kind": "non-provider", "reason": "Runtime module pinned independently of HTTP client spelling; network attempts require runtime admission.", - "sha256": "6fbc8c9757cc29676d9db726045ca17de3eb51a2e9571985a1ef747acbd8f040" + "sha256": "a31b4e907e811f53316bb4a45a4c575e1ba03efe83c4d572d0021443e36c0a36" }, "services/protocol_translation/anthropic_stream.py": { "kind": "non-provider", diff --git a/tests/test_anthropic_provider.py b/tests/test_anthropic_provider.py index e06d84bc..16c60fc9 100644 --- a/tests/test_anthropic_provider.py +++ b/tests/test_anthropic_provider.py @@ -65,7 +65,7 @@ def test_claude_5_5_effort_uses_adaptive_thinking_without_sampling(model, max_to ) assert body["model"] == model assert body["max_tokens"] == max_tokens - assert body["thinking"] == {"type": "disabled" if effort == "none" else "adaptive"} + assert body["thinking"] == (THINKING_OFF_BY_MODEL[model] if effort == "none" else {"type": "adaptive"}) assert body["output_config"] == { "effort": "low" if effort in {"none", "minimal"} else effort, "format": {"type": "json_schema", "schema": {"type": "object"}}, @@ -73,6 +73,14 @@ def test_claude_5_5_effort_uses_adaptive_thinking_without_sampling(model, max_to assert not {"temperature", "top_p", "top_k"} & body.keys() +# Haiku turns thinking off with "disabled", Sonnet only with "between_tools", and Opus cannot. +THINKING_OFF_BY_MODEL = { + "claude-haiku-5-5": {"type": "disabled"}, + "claude-sonnet-5-5": {"type": "between_tools"}, + "claude-opus-5-5": {"type": "adaptive"}, +} + + @pytest.mark.parametrize("model", CLAUDE_5_5_MODELS) @pytest.mark.parametrize("thinking", [None, {"type": "disabled"}]) def test_claude_5_5_sampling_is_removed_even_without_explicit_thinking(model, thinking): @@ -87,7 +95,7 @@ def test_claude_5_5_sampling_is_removed_even_without_explicit_thinking(model, th ) assert "temperature" not in body assert "top_p" not in body - assert body.get("thinking") == thinking + assert body.get("thinking") == (THINKING_OFF_BY_MODEL[model] if thinking else None) @pytest.mark.parametrize("model", CLAUDE_5_5_MODELS) @@ -103,7 +111,7 @@ def test_claude_5_5_manual_budget_becomes_adaptive_and_none_disables_thinking(mo assert body["output_config"] == {"effort": "medium"} assert payload["thinking"]["budget_tokens"] == 8000 body = chat_request_to_messages({**payload, "reasoning": {"effort": "none"}}) - assert body["thinking"] == {"type": "disabled"} + assert body["thinking"] == THINKING_OFF_BY_MODEL[model] assert body["output_config"]["effort"] == "low"