From 15256c8fa88859ac496dee1b5251441a6ee8ac94 Mon Sep 17 00:00:00 2001 From: philipph-askui Date: Fri, 28 Aug 2026 10:48:28 -0400 Subject: [PATCH 1/8] fix(anthropic): only forward `temperature` to the client when set AnthropicMessagesApi.create_message always passed `temperature=` to `client.beta.messages.create`, even as the `omit` sentinel. `anthropic` client versions that dropped `temperature` from `beta.messages.create` (and accept no `**kwargs`) then raise `TypeError: ... unexpected keyword argument 'temperature'` at argument binding, making AnthropicVlmProvider unusable out of the box. Forward `temperature` only when a value was actually requested (build it into the create() kwargs conditionally). The other options remain passed as `omit` since they are still part of the client signature. Also fix a related pre-existing bug: `temperature or omit` dropped an explicit `temperature=0.0` (a valid deterministic value) because 0.0 is falsy; use `is None` instead. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/askui/models/anthropic/messages_api.py | 19 +++++- .../models/anthropic/test_messages_api.py | 62 +++++++++++++++++++ 2 files changed, 78 insertions(+), 3 deletions(-) diff --git a/src/askui/models/anthropic/messages_api.py b/src/askui/models/anthropic/messages_api.py index 7442f29b..7157dbb2 100644 --- a/src/askui/models/anthropic/messages_api.py +++ b/src/askui/models/anthropic/messages_api.py @@ -193,7 +193,9 @@ def _parse_to_anthropic_types( _tool_choice = ( cast("BetaToolChoiceParam", tool_choice) if tool_choice is not None else omit ) - _temperature = temperature or omit + # Use `is None` (not truthiness) so an explicit `temperature=0.0` + # (fully deterministic) is preserved rather than dropped. + _temperature: float | Omit = omit if temperature is None else temperature return ( _tools, @@ -267,7 +269,18 @@ def create_message( temperature, ) - response = self._client.beta.messages.create( # type: ignore[misc] + # Only forward `temperature` when a value was actually requested. It is + # an optional sampling parameter that some `anthropic` client versions do + # not expose on `beta.messages.create` (and the client does not accept + # `**kwargs`), so passing it unconditionally - even as the `omit` + # sentinel - raises `TypeError` at argument binding on those clients. + # The other options remain passed as `omit`; they are still part of the + # client signature. + temperature_kwarg: dict[str, float] = {} + if not isinstance(_temperature, Omit): + temperature_kwarg["temperature"] = _temperature + + response = self._client.beta.messages.create( # type: ignore[misc, call-overload] messages=_messages, max_tokens=max_tokens or 8192, cache_control=_cache_control, @@ -278,7 +291,7 @@ def create_message( thinking=_thinking, output_config=_output_config, tool_choice=_tool_choice, - temperature=_temperature, timeout=300.0, + **temperature_kwarg, ) return MessageParam.model_validate(response.model_dump()) diff --git a/tests/unit/models/anthropic/test_messages_api.py b/tests/unit/models/anthropic/test_messages_api.py index 443da006..0248d1b8 100644 --- a/tests/unit/models/anthropic/test_messages_api.py +++ b/tests/unit/models/anthropic/test_messages_api.py @@ -26,6 +26,19 @@ def test_adaptive_thinking_passed_through(self) -> None: result = _parse_to_anthropic_types(tools=None, thinking={"type": "adaptive"}) assert result[4] == {"type": "adaptive"} + def test_no_temperature_is_omitted(self) -> None: + result = _parse_to_anthropic_types(tools=None, temperature=None) + assert result[7] is omit + + def test_temperature_passed_through(self) -> None: + result = _parse_to_anthropic_types(tools=None, temperature=0.7) + assert result[7] == 0.7 + + def test_temperature_zero_is_preserved(self) -> None: + # 0.0 is a valid deterministic value and must not be treated as unset. + result = _parse_to_anthropic_types(tools=None, temperature=0.0) + assert result[7] == 0.0 + class TestCreateMessage: """`create_message` reads output_config from provider_options.""" @@ -68,3 +81,52 @@ def test_no_output_config_omits_it(self) -> None: kwargs = client.beta.messages.create.call_args.kwargs assert kwargs["output_config"] is omit assert kwargs["thinking"] == {"type": "enabled", "budget_tokens": 2048} + + def test_temperature_not_forwarded_when_unset(self) -> None: + api, client = self._make_api() + + api.create_message( + messages=[MessageParam(role="user", content="hi")], + model_id="claude-sonnet-5", + ) + + kwargs = client.beta.messages.create.call_args.kwargs + # Not passed at all (not even as `omit`) so clients that dropped the + # parameter do not raise TypeError. + assert "temperature" not in kwargs + + def test_temperature_forwarded_when_set(self) -> None: + api, client = self._make_api() + + api.create_message( + messages=[MessageParam(role="user", content="hi")], + model_id="claude-sonnet-5", + temperature=0.3, + ) + + kwargs = client.beta.messages.create.call_args.kwargs + assert kwargs["temperature"] == 0.3 + + def test_succeeds_on_client_that_rejects_temperature(self) -> None: + """Regression: mirrors an anthropic client whose create() has no + `temperature` parameter (and no **kwargs). Passing `temperature` at all - + even as the `omit` sentinel - would raise TypeError, so the SDK must not + forward it when it is unset.""" + + def create(**kwargs: object) -> MagicMock: + if "temperature" in kwargs: + error_msg = "create() got an unexpected keyword argument 'temperature'" + raise TypeError(error_msg) + response = MagicMock() + response.model_dump.return_value = {"role": "assistant", "content": "hi"} + return response + + client = MagicMock() + client.beta.messages.create = create + api = AnthropicMessagesApi(client=client) + + result = api.create_message( + messages=[MessageParam(role="user", content="hi")], + model_id="claude-sonnet-5", + ) + assert isinstance(result, MessageParam) From a0529ae343369e1b09e22efa09e732e83394bc06 Mon Sep 17 00:00:00 2001 From: philipph-askui Date: Fri, 28 Aug 2026 14:03:04 -0400 Subject: [PATCH 2/8] build(deps): bump locked anthropic to 1.2.0 + real-signature guard test The `temperature` crash only reproduces on anthropic versions that removed the parameter (1.2.0), which a fresh `pip install` already resolves (the constraint is `anthropic>=0.86.0` with no upper bound); CI missed it only because the lock pinned 0.116.0, which still has `temperature`. - Bump the lockfile to anthropic 1.2.0 so CI runs against the version users actually get. Full unit suite + typecheck pass on 1.2.0. - Add an integration guard that binds the SDK's actual create() kwargs against the real installed client signature, so forwarding any unsupported parameter fails in CI (on 1.2.0 this fails if the temperature fix is reverted). Co-Authored-By: Claude Opus 4.8 (1M context) --- pdm.lock | 104 ++++++++++++++---- .../models/anthropic/test_messages_api.py | 29 +++++ 2 files changed, 110 insertions(+), 23 deletions(-) diff --git a/pdm.lock b/pdm.lock index c9c0845b..aa53edb1 100644 --- a/pdm.lock +++ b/pdm.lock @@ -51,56 +51,55 @@ files = [ [[package]] name = "anthropic" -version = "0.116.0" -requires_python = ">=3.9" +version = "1.2.0" +requires_python = ">=3.10" summary = "The official Python library for the anthropic API" groups = ["default", "all", "bedrock", "vertex"] dependencies = [ "anyio<5,>=3.5.0", - "distro<2,>=1.7.0", "docstring-parser<1,>=0.15", - "httpx<1,>=0.25.0", + "httpx2<3,>=2.0.0", "jiter<1,>=0.4.0", "pydantic<3,>=1.9.0", - "sniffio", + "sniffio<2,>=1", "typing-extensions<5,>=4.14", ] files = [ - {file = "anthropic-0.116.0-py3-none-any.whl", hash = "sha256:6c0a7698e8d652455da3499978279bb2588c7264d0a35be3666009a4258c8256"}, - {file = "anthropic-0.116.0.tar.gz", hash = "sha256:5fc248fbb9fe03ef686f8a774f81586bca31a043260aab88b387ea3660f4a396"}, + {file = "anthropic-1.2.0-py3-none-any.whl", hash = "sha256:b60642b3e3cd6b8e3e328a2d3f2863ad2b6e743f1037e42cc0143f7df99f63c6"}, + {file = "anthropic-1.2.0.tar.gz", hash = "sha256:12f8eedee7b7fb5685837b1371b7bfae1b281703f62355f4632598ec2fc53b34"}, ] [[package]] name = "anthropic" -version = "0.116.0" +version = "1.2.0" extras = ["bedrock"] -requires_python = ">=3.9" +requires_python = ">=3.10" summary = "The official Python library for the anthropic API" groups = ["all", "bedrock"] dependencies = [ - "anthropic==0.116.0", - "boto3>=1.28.57", - "botocore>=1.31.57", + "anthropic==1.2.0", + "boto3<2,>=1.28.57", + "botocore<2,>=1.31.57", ] files = [ - {file = "anthropic-0.116.0-py3-none-any.whl", hash = "sha256:6c0a7698e8d652455da3499978279bb2588c7264d0a35be3666009a4258c8256"}, - {file = "anthropic-0.116.0.tar.gz", hash = "sha256:5fc248fbb9fe03ef686f8a774f81586bca31a043260aab88b387ea3660f4a396"}, + {file = "anthropic-1.2.0-py3-none-any.whl", hash = "sha256:b60642b3e3cd6b8e3e328a2d3f2863ad2b6e743f1037e42cc0143f7df99f63c6"}, + {file = "anthropic-1.2.0.tar.gz", hash = "sha256:12f8eedee7b7fb5685837b1371b7bfae1b281703f62355f4632598ec2fc53b34"}, ] [[package]] name = "anthropic" -version = "0.116.0" +version = "1.2.0" extras = ["vertex"] -requires_python = ">=3.9" +requires_python = ">=3.10" summary = "The official Python library for the anthropic API" groups = ["all", "vertex"] dependencies = [ - "anthropic==0.116.0", + "anthropic==1.2.0", "google-auth[requests]<3,>=2", ] files = [ - {file = "anthropic-0.116.0-py3-none-any.whl", hash = "sha256:6c0a7698e8d652455da3499978279bb2588c7264d0a35be3666009a4258c8256"}, - {file = "anthropic-0.116.0.tar.gz", hash = "sha256:5fc248fbb9fe03ef686f8a774f81586bca31a043260aab88b387ea3660f4a396"}, + {file = "anthropic-1.2.0-py3-none-any.whl", hash = "sha256:b60642b3e3cd6b8e3e328a2d3f2863ad2b6e743f1037e42cc0143f7df99f63c6"}, + {file = "anthropic-1.2.0.tar.gz", hash = "sha256:12f8eedee7b7fb5685837b1371b7bfae1b281703f62355f4632598ec2fc53b34"}, ] [[package]] @@ -437,7 +436,7 @@ name = "certifi" version = "2026.6.17" requires_python = ">=3.7" summary = "Python package for providing Mozilla's CA Bundle." -groups = ["default", "all", "bedrock", "office-document", "otel", "vertex"] +groups = ["default", "all", "office-document", "otel", "vertex"] files = [ {file = "certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db"}, {file = "certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432"}, @@ -948,7 +947,7 @@ name = "distro" version = "1.9.0" requires_python = ">=3.6" summary = "Distro - an OS platform information API" -groups = ["default", "all", "bedrock", "vertex"] +groups = ["default", "all", "vertex"] files = [ {file = "distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2"}, {file = "distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed"}, @@ -1751,7 +1750,7 @@ name = "httpcore" version = "1.0.9" requires_python = ">=3.8" summary = "A minimal low-level HTTP client." -groups = ["default", "all", "bedrock", "vertex"] +groups = ["default", "all", "vertex"] dependencies = [ "certifi", "h11>=0.16", @@ -1761,12 +1760,28 @@ files = [ {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, ] +[[package]] +name = "httpcore2" +version = "2.12.0" +requires_python = ">=3.10" +summary = "A minimal low-level HTTP client." +groups = ["default", "all", "bedrock", "vertex"] +marker = "sys_platform != \"emscripten\"" +dependencies = [ + "h11>=0.16", + "truststore>=0.10", +] +files = [ + {file = "httpcore2-2.12.0-py3-none-any.whl", hash = "sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb"}, + {file = "httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648"}, +] + [[package]] name = "httpx" version = "0.28.1" requires_python = ">=3.8" summary = "The next generation HTTP client." -groups = ["default", "all", "bedrock", "vertex"] +groups = ["default", "all", "vertex"] dependencies = [ "anyio", "certifi", @@ -1789,6 +1804,37 @@ files = [ {file = "httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d"}, ] +[[package]] +name = "httpx2" +version = "2.12.0" +requires_python = ">=3.10" +summary = "The next generation HTTP client." +groups = ["default", "all", "bedrock", "vertex"] +dependencies = [ + "anyio>=4.10; sys_platform != \"emscripten\"", + "httpcore2==2.12.0; sys_platform != \"emscripten\"", + "httpx2-jsfetch; sys_platform == \"emscripten\" and python_version >= \"3.12\"", + "idna>=3.18", + "truststore>=0.10; sys_platform != \"emscripten\"", + "typing-extensions>=4.5.0; python_version < \"3.13\"", +] +files = [ + {file = "httpx2-2.12.0-py3-none-any.whl", hash = "sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36"}, + {file = "httpx2-2.12.0.tar.gz", hash = "sha256:7631fe9887a8a2275f4a2540e053aa670fcc50742864a9ae7c66e609fdcf12cf"}, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +requires_python = ">=3.12" +summary = "httpx2 transports for Emscripten/Pyodide, backed by the JavaScript fetch API." +groups = ["default", "all", "bedrock", "vertex"] +marker = "sys_platform == \"emscripten\" and python_version >= \"3.12\"" +files = [ + {file = "httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32"}, + {file = "httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60"}, +] + [[package]] name = "huggingface-hub" version = "1.22.0" @@ -4324,6 +4370,18 @@ files = [ {file = "tqdm-4.68.3.tar.gz", hash = "sha256:00dfa48452b6b6cfae3dd9885636c23d3422d1ec97c66d96818cbd5e0821d482"}, ] +[[package]] +name = "truststore" +version = "0.10.4" +requires_python = ">=3.10" +summary = "Verify certificates using native system trust stores" +groups = ["default", "all", "bedrock", "vertex"] +marker = "sys_platform != \"emscripten\"" +files = [ + {file = "truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981"}, + {file = "truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301"}, +] + [[package]] name = "typeguard" version = "4.5.2" diff --git a/tests/unit/models/anthropic/test_messages_api.py b/tests/unit/models/anthropic/test_messages_api.py index 0248d1b8..105567dc 100644 --- a/tests/unit/models/anthropic/test_messages_api.py +++ b/tests/unit/models/anthropic/test_messages_api.py @@ -1,7 +1,10 @@ """Unit tests for Anthropic messages API output_config / thinking handling.""" +import inspect +from typing import Any from unittest.mock import MagicMock +import anthropic from anthropic import omit from askui.models.anthropic.messages_api import ( @@ -130,3 +133,29 @@ def create(**kwargs: object) -> MagicMock: model_id="claude-sonnet-5", ) assert isinstance(result, MessageParam) + + def test_kwargs_accepted_by_real_client_signature(self) -> None: + """Integration guard: every kwarg the SDK sends must be accepted by the + REAL installed `anthropic` client's `beta.messages.create` signature. + + This binds against the real signature (no network), so it fails if the + SDK forwards a parameter the installed client version does not support - + catching this class of breakage on whatever anthropic CI resolves.""" + real_client = anthropic.Anthropic(api_key="dummy") + real_signature = inspect.signature(real_client.beta.messages.create) + + def spy(**kwargs: Any) -> MagicMock: + # Raises TypeError if the SDK sends an unsupported keyword. + real_signature.bind(**kwargs) + response = MagicMock() + response.model_dump.return_value = {"role": "assistant", "content": "hi"} + return response + + real_client.beta.messages.create = spy # type: ignore[method-assign] + api = AnthropicMessagesApi(client=real_client) + + result = api.create_message( + messages=[MessageParam(role="user", content="hi")], + model_id="claude-sonnet-5", + ) + assert isinstance(result, MessageParam) From 243f1444bc72470fdf3224b005dcc0d02804091b Mon Sep 17 00:00:00 2001 From: philipph-askui Date: Fri, 28 Aug 2026 14:26:25 -0400 Subject: [PATCH 3/8] fix(anthropic): send `temperature` via extra_body so it works when set The previous fix stopped forwarding `temperature` when unset, but forwarding it when a value *was* set still crashed on anthropic clients that removed the typed parameter (e.g. 1.2.0) - which affects BOTH AnthropicVlmProvider and AskUIVlmProvider, since both route Claude through this AnthropicMessagesApi. The Messages API still accepts `temperature` in the request body, so send it via `extra_body` (only when set) instead of as a typed keyword. Verified against the real anthropic 1.2.0 client: `extra_body={"temperature": x}` binds cleanly while `temperature=x` raises TypeError. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/askui/models/anthropic/messages_api.py | 21 ++++--- .../models/anthropic/test_messages_api.py | 58 ++++++++++++------- 2 files changed, 47 insertions(+), 32 deletions(-) diff --git a/src/askui/models/anthropic/messages_api.py b/src/askui/models/anthropic/messages_api.py index 7157dbb2..2a5cee49 100644 --- a/src/askui/models/anthropic/messages_api.py +++ b/src/askui/models/anthropic/messages_api.py @@ -269,18 +269,17 @@ def create_message( temperature, ) - # Only forward `temperature` when a value was actually requested. It is - # an optional sampling parameter that some `anthropic` client versions do - # not expose on `beta.messages.create` (and the client does not accept - # `**kwargs`), so passing it unconditionally - even as the `omit` - # sentinel - raises `TypeError` at argument binding on those clients. - # The other options remain passed as `omit`; they are still part of the - # client signature. - temperature_kwarg: dict[str, float] = {} + # `temperature` was removed from the typed `beta.messages.create` + # signature in newer `anthropic` clients (e.g. 1.2.0), which accept no + # `**kwargs` - so forwarding it as a normal keyword raises `TypeError` at + # argument binding. The Messages API itself still accepts `temperature` + # in the request body, so send it via `extra_body` (only when a value was + # actually requested). This works regardless of client version. + extra_body: dict[str, Any] = {} if not isinstance(_temperature, Omit): - temperature_kwarg["temperature"] = _temperature + extra_body["temperature"] = _temperature - response = self._client.beta.messages.create( # type: ignore[misc, call-overload] + response = self._client.beta.messages.create( # type: ignore[misc] messages=_messages, max_tokens=max_tokens or 8192, cache_control=_cache_control, @@ -292,6 +291,6 @@ def create_message( output_config=_output_config, tool_choice=_tool_choice, timeout=300.0, - **temperature_kwarg, + extra_body=extra_body or omit, ) return MessageParam.model_validate(response.model_dump()) diff --git a/tests/unit/models/anthropic/test_messages_api.py b/tests/unit/models/anthropic/test_messages_api.py index 105567dc..fc9c151d 100644 --- a/tests/unit/models/anthropic/test_messages_api.py +++ b/tests/unit/models/anthropic/test_messages_api.py @@ -85,7 +85,7 @@ def test_no_output_config_omits_it(self) -> None: assert kwargs["output_config"] is omit assert kwargs["thinking"] == {"type": "enabled", "budget_tokens": 2048} - def test_temperature_not_forwarded_when_unset(self) -> None: + def test_temperature_not_in_body_when_unset(self) -> None: api, client = self._make_api() api.create_message( @@ -94,11 +94,11 @@ def test_temperature_not_forwarded_when_unset(self) -> None: ) kwargs = client.beta.messages.create.call_args.kwargs - # Not passed at all (not even as `omit`) so clients that dropped the - # parameter do not raise TypeError. + # Never sent as a typed keyword, and no temperature in the body. assert "temperature" not in kwargs + assert kwargs["extra_body"] is omit - def test_temperature_forwarded_when_set(self) -> None: + def test_temperature_sent_via_extra_body_when_set(self) -> None: api, client = self._make_api() api.create_message( @@ -108,13 +108,27 @@ def test_temperature_forwarded_when_set(self) -> None: ) kwargs = client.beta.messages.create.call_args.kwargs - assert kwargs["temperature"] == 0.3 + # Routed through the request body (not the typed `temperature=` kwarg, + # which newer clients removed). + assert "temperature" not in kwargs + assert kwargs["extra_body"] == {"temperature": 0.3} + + def test_temperature_zero_sent_via_extra_body(self) -> None: + api, client = self._make_api() + + api.create_message( + messages=[MessageParam(role="user", content="hi")], + model_id="claude-sonnet-5", + temperature=0.0, + ) - def test_succeeds_on_client_that_rejects_temperature(self) -> None: - """Regression: mirrors an anthropic client whose create() has no - `temperature` parameter (and no **kwargs). Passing `temperature` at all - - even as the `omit` sentinel - would raise TypeError, so the SDK must not - forward it when it is unset.""" + kwargs = client.beta.messages.create.call_args.kwargs + assert kwargs["extra_body"] == {"temperature": 0.0} + + def test_succeeds_on_client_that_rejects_temperature_kwarg(self) -> None: + """Regression: a client whose create() has no `temperature` parameter + (and no **kwargs) must not receive it as a keyword - even when a + temperature is requested. It goes into the request body instead.""" def create(**kwargs: object) -> MagicMock: if "temperature" in kwargs: @@ -131,16 +145,16 @@ def create(**kwargs: object) -> MagicMock: result = api.create_message( messages=[MessageParam(role="user", content="hi")], model_id="claude-sonnet-5", + temperature=0.5, # even when set, must not become a kwarg ) assert isinstance(result, MessageParam) def test_kwargs_accepted_by_real_client_signature(self) -> None: - """Integration guard: every kwarg the SDK sends must be accepted by the - REAL installed `anthropic` client's `beta.messages.create` signature. - - This binds against the real signature (no network), so it fails if the - SDK forwards a parameter the installed client version does not support - - catching this class of breakage on whatever anthropic CI resolves.""" + """Integration guard: every kwarg the SDK sends - with and without a + temperature - must be accepted by the REAL installed `anthropic` client's + `beta.messages.create` signature (bound with no network). This catches + the SDK forwarding a parameter the installed client version does not + support, on whatever anthropic CI resolves.""" real_client = anthropic.Anthropic(api_key="dummy") real_signature = inspect.signature(real_client.beta.messages.create) @@ -154,8 +168,10 @@ def spy(**kwargs: Any) -> MagicMock: real_client.beta.messages.create = spy # type: ignore[method-assign] api = AnthropicMessagesApi(client=real_client) - result = api.create_message( - messages=[MessageParam(role="user", content="hi")], - model_id="claude-sonnet-5", - ) - assert isinstance(result, MessageParam) + for temperature in (None, 0.0, 0.7): + result = api.create_message( + messages=[MessageParam(role="user", content="hi")], + model_id="claude-sonnet-5", + temperature=temperature, + ) + assert isinstance(result, MessageParam) From a1381456ee5c6642ac2e40c71a7ef245004b76dc Mon Sep 17 00:00:00 2001 From: philipph-askui Date: Fri, 28 Aug 2026 14:42:15 -0400 Subject: [PATCH 4/8] fix(anthropic): do not forward deprecated `temperature`; warn if requested Live testing against the real Anthropic API showed two things: - The previous `extra_body=... or omit` passed the `omit` sentinel, crashing the default (unset) path with `TypeError: 'Omit' object is not a mapping`. - Anthropic has DEPRECATED `temperature` for its models: newer clients removed it from `beta.messages.create`, and the API rejects a non-default value with `400 "temperature is deprecated for this model."` (only the default is accepted). So there is no way to make a non-default temperature work. Stop forwarding `temperature` to the Anthropic Messages API entirely (this also fixes the AskUI provider, which routes Claude through the same API), and log a warning when a caller explicitly requests one so it is not silently ignored. `act()` now works for any temperature value. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/askui/models/anthropic/messages_api.py | 33 +++--- .../models/anthropic/test_messages_api.py | 100 ++++++------------ 2 files changed, 48 insertions(+), 85 deletions(-) diff --git a/src/askui/models/anthropic/messages_api.py b/src/askui/models/anthropic/messages_api.py index 2a5cee49..b7c87ea9 100644 --- a/src/askui/models/anthropic/messages_api.py +++ b/src/askui/models/anthropic/messages_api.py @@ -1,3 +1,4 @@ +import logging from typing import Any, Tuple, cast from anthropic import ( @@ -46,6 +47,8 @@ from askui.utils.image_utils import image_to_base64 from askui.utils.pdf_utils import PdfSource +logger = logging.getLogger(__name__) + def _is_retryable_error(exception: BaseException) -> bool: """Check if the exception is a retryable error.""" @@ -147,7 +150,6 @@ def _parse_to_anthropic_types( thinking: ThinkingConfigParam | None = None, output_config: dict[str, Any] | None = None, tool_choice: ToolChoiceParam | None = None, - temperature: float | None = None, ) -> Tuple[ list[BetaToolUnionParam] | Omit, list[AnthropicBetaParam] | Omit, @@ -156,7 +158,6 @@ def _parse_to_anthropic_types( BetaThinkingConfigParam | Omit, BetaOutputConfigParam | Omit, BetaToolChoiceParam | Omit, - float | Omit, ]: """Convert provider-agnostic types to Anthropic-specific types. @@ -193,9 +194,6 @@ def _parse_to_anthropic_types( _tool_choice = ( cast("BetaToolChoiceParam", tool_choice) if tool_choice is not None else omit ) - # Use `is None` (not truthiness) so an explicit `temperature=0.0` - # (fully deterministic) is preserved rather than dropped. - _temperature: float | Omit = omit if temperature is None else temperature return ( _tools, @@ -205,7 +203,6 @@ def _parse_to_anthropic_types( _thinking, _output_config, _tool_choice, - _temperature, ) @@ -257,7 +254,6 @@ def create_message( _thinking, _output_config, _tool_choice, - _temperature, ) = _parse_to_anthropic_types( tools, betas, @@ -266,18 +262,20 @@ def create_message( thinking, output_config, tool_choice, - temperature, ) - # `temperature` was removed from the typed `beta.messages.create` - # signature in newer `anthropic` clients (e.g. 1.2.0), which accept no - # `**kwargs` - so forwarding it as a normal keyword raises `TypeError` at - # argument binding. The Messages API itself still accepts `temperature` - # in the request body, so send it via `extra_body` (only when a value was - # actually requested). This works regardless of client version. - extra_body: dict[str, Any] = {} - if not isinstance(_temperature, Omit): - extra_body["temperature"] = _temperature + # `temperature` is intentionally NOT forwarded to the Anthropic Messages + # API. Anthropic has deprecated it for its models: newer `anthropic` + # clients removed it from `beta.messages.create`, and the API rejects a + # non-default value with `400 "temperature is deprecated for this + # model."`. Forwarding it therefore only breaks the call, so we drop it + # and warn if the caller explicitly requested one. + if temperature is not None: + logger.warning( + "Ignoring temperature=%s: Anthropic has deprecated the " + "`temperature` parameter for its models, so it is not sent.", + temperature, + ) response = self._client.beta.messages.create( # type: ignore[misc] messages=_messages, @@ -291,6 +289,5 @@ def create_message( output_config=_output_config, tool_choice=_tool_choice, timeout=300.0, - extra_body=extra_body or omit, ) return MessageParam.model_validate(response.model_dump()) diff --git a/tests/unit/models/anthropic/test_messages_api.py b/tests/unit/models/anthropic/test_messages_api.py index fc9c151d..f91debe3 100644 --- a/tests/unit/models/anthropic/test_messages_api.py +++ b/tests/unit/models/anthropic/test_messages_api.py @@ -29,19 +29,6 @@ def test_adaptive_thinking_passed_through(self) -> None: result = _parse_to_anthropic_types(tools=None, thinking={"type": "adaptive"}) assert result[4] == {"type": "adaptive"} - def test_no_temperature_is_omitted(self) -> None: - result = _parse_to_anthropic_types(tools=None, temperature=None) - assert result[7] is omit - - def test_temperature_passed_through(self) -> None: - result = _parse_to_anthropic_types(tools=None, temperature=0.7) - assert result[7] == 0.7 - - def test_temperature_zero_is_preserved(self) -> None: - # 0.0 is a valid deterministic value and must not be treated as unset. - result = _parse_to_anthropic_types(tools=None, temperature=0.0) - assert result[7] == 0.0 - class TestCreateMessage: """`create_message` reads output_config from provider_options.""" @@ -85,69 +72,48 @@ def test_no_output_config_omits_it(self) -> None: assert kwargs["output_config"] is omit assert kwargs["thinking"] == {"type": "enabled", "budget_tokens": 2048} - def test_temperature_not_in_body_when_unset(self) -> None: - api, client = self._make_api() - - api.create_message( - messages=[MessageParam(role="user", content="hi")], - model_id="claude-sonnet-5", - ) - - kwargs = client.beta.messages.create.call_args.kwargs - # Never sent as a typed keyword, and no temperature in the body. - assert "temperature" not in kwargs - assert kwargs["extra_body"] is omit - - def test_temperature_sent_via_extra_body_when_set(self) -> None: + def test_temperature_never_forwarded(self) -> None: api, client = self._make_api() - api.create_message( - messages=[MessageParam(role="user", content="hi")], - model_id="claude-sonnet-5", - temperature=0.3, - ) + for temperature in (None, 0.0, 0.5, 1.0): + api.create_message( + messages=[MessageParam(role="user", content="hi")], + model_id="claude-sonnet-5", + temperature=temperature, + ) + kwargs = client.beta.messages.create.call_args.kwargs + # Anthropic deprecated `temperature`; the SDK never sends it, neither + # as a typed keyword nor in the request body. + assert "temperature" not in kwargs + assert "extra_body" not in kwargs - kwargs = client.beta.messages.create.call_args.kwargs - # Routed through the request body (not the typed `temperature=` kwarg, - # which newer clients removed). - assert "temperature" not in kwargs - assert kwargs["extra_body"] == {"temperature": 0.3} + def test_warns_when_temperature_requested(self, caplog: Any) -> None: + api, _ = self._make_api() - def test_temperature_zero_sent_via_extra_body(self) -> None: - api, client = self._make_api() + import logging - api.create_message( - messages=[MessageParam(role="user", content="hi")], - model_id="claude-sonnet-5", - temperature=0.0, + with caplog.at_level(logging.WARNING): + api.create_message( + messages=[MessageParam(role="user", content="hi")], + model_id="claude-sonnet-5", + temperature=0.2, + ) + assert any( + "deprecated" in rec.message and "0.2" in rec.message + for rec in caplog.records ) - kwargs = client.beta.messages.create.call_args.kwargs - assert kwargs["extra_body"] == {"temperature": 0.0} - - def test_succeeds_on_client_that_rejects_temperature_kwarg(self) -> None: - """Regression: a client whose create() has no `temperature` parameter - (and no **kwargs) must not receive it as a keyword - even when a - temperature is requested. It goes into the request body instead.""" + def test_no_warning_when_temperature_unset(self, caplog: Any) -> None: + api, _ = self._make_api() - def create(**kwargs: object) -> MagicMock: - if "temperature" in kwargs: - error_msg = "create() got an unexpected keyword argument 'temperature'" - raise TypeError(error_msg) - response = MagicMock() - response.model_dump.return_value = {"role": "assistant", "content": "hi"} - return response + import logging - client = MagicMock() - client.beta.messages.create = create - api = AnthropicMessagesApi(client=client) - - result = api.create_message( - messages=[MessageParam(role="user", content="hi")], - model_id="claude-sonnet-5", - temperature=0.5, # even when set, must not become a kwarg - ) - assert isinstance(result, MessageParam) + with caplog.at_level(logging.WARNING): + api.create_message( + messages=[MessageParam(role="user", content="hi")], + model_id="claude-sonnet-5", + ) + assert not any("temperature" in rec.message for rec in caplog.records) def test_kwargs_accepted_by_real_client_signature(self) -> None: """Integration guard: every kwarg the SDK sends - with and without a From 17e22ff7d62f12328f482111c624d96bd6ff2ead Mon Sep 17 00:00:00 2001 From: philipph-askui Date: Fri, 28 Aug 2026 15:15:00 -0400 Subject: [PATCH 5/8] fix(anthropic): omit null fields when serializing message content blocks `from_content_block` used `model_dump()`, which serialized unset optional fields as explicit `null` (e.g. `cache_control: null`, `citations: null`) on every text/ image/tool_result block. The real Anthropic API and the AskUI proxy tolerate these nulls, but stricter Anthropic-compatible endpoints (e.g. OpenRouter's) reject them with `cache_control: expected object, received null`, failing every request. Serialize with `exclude_none=True` so optional fields are omitted rather than sent as null. This is more schema-correct and unblocks strict endpoints while remaining valid for Anthropic/AskUI. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/askui/models/anthropic/messages_api.py | 18 +++-- .../models/anthropic/test_messages_api.py | 65 ++++++++++++++++++- 2 files changed, 77 insertions(+), 6 deletions(-) diff --git a/src/askui/models/anthropic/messages_api.py b/src/askui/models/anthropic/messages_api.py index b7c87ea9..70393db8 100644 --- a/src/askui/models/anthropic/messages_api.py +++ b/src/askui/models/anthropic/messages_api.py @@ -60,9 +60,14 @@ def _is_retryable_error(exception: BaseException) -> bool: def from_content_block(block: ContentBlockParam) -> BetaContentBlockParam: """Convert an internal content block to an Anthropic API-compatible dict. - Uses `model_dump()` to produce plain dicts compatible with Anthropic's - TypedDicts. Strips ``visual_representation`` and ``extra_content`` from - `ToolUseBlockParam` as they are not accepted by the API. + Uses `model_dump(exclude_none=True)` to produce plain dicts compatible with + Anthropic's TypedDicts. ``exclude_none`` omits unset optional fields (e.g. + ``cache_control``, ``citations``) instead of serialising them as explicit + ``null``. The Anthropic API tolerates those nulls, but stricter + Anthropic-compatible endpoints (e.g. OpenRouter's) reject them with + ``cache_control: expected object, received null``. Also strips + ``visual_representation`` and ``extra_content`` from `ToolUseBlockParam` as + they are not accepted by the API. """ if isinstance(block, ToolUseBlockParam): # visual_representation (perceptual hash for cache validation) and @@ -72,9 +77,12 @@ def from_content_block(block: ContentBlockParam) -> BetaContentBlockParam: # unknown-field error. return cast( "BetaContentBlockParam", - block.model_dump(exclude={"visual_representation", "extra_content"}), + block.model_dump( + exclude={"visual_representation", "extra_content"}, + exclude_none=True, + ), ) - return cast("BetaContentBlockParam", block.model_dump()) + return cast("BetaContentBlockParam", block.model_dump(exclude_none=True)) def from_message_param(message: MessageParam) -> BetaMessageParam: diff --git a/tests/unit/models/anthropic/test_messages_api.py b/tests/unit/models/anthropic/test_messages_api.py index f91debe3..affc72f6 100644 --- a/tests/unit/models/anthropic/test_messages_api.py +++ b/tests/unit/models/anthropic/test_messages_api.py @@ -10,8 +10,71 @@ from askui.models.anthropic.messages_api import ( AnthropicMessagesApi, _parse_to_anthropic_types, + from_content_block, + from_message_param, ) -from askui.models.shared.agent_message_param import MessageParam +from askui.models.shared.agent_message_param import ( + Base64ImageSourceParam, + ImageBlockParam, + MessageParam, + TextBlockParam, + ToolResultBlockParam, + ToolUseBlockParam, +) + + +def _assert_no_nulls(value: Any, path: str = "") -> None: + """Recursively assert that a serialized block contains no `None` values.""" + if isinstance(value, dict): + for key, sub in value.items(): + assert sub is not None, f"unexpected null at {path}.{key}" + _assert_no_nulls(sub, f"{path}.{key}") + elif isinstance(value, list): + for i, item in enumerate(value): + _assert_no_nulls(item, f"{path}[{i}]") + + +class TestSerializationOmitsNulls: + """Content blocks must not serialize optional fields as explicit `null`. + + Real Anthropic tolerates `cache_control: null`, but stricter + Anthropic-compatible endpoints (e.g. OpenRouter) reject it. + """ + + def test_text_block_has_no_nulls(self) -> None: + dumped = from_content_block(TextBlockParam(text="hi")) + assert "cache_control" not in dumped + assert "citations" not in dumped + _assert_no_nulls(dumped) + + def test_image_block_has_no_nulls(self) -> None: + block = ImageBlockParam( + source=Base64ImageSourceParam(data="AAAA", media_type="image/png") + ) + _assert_no_nulls(from_content_block(block)) + + def test_tool_result_with_image_has_no_nested_nulls(self) -> None: + block = ToolResultBlockParam( + tool_use_id="t1", + content=[ + TextBlockParam(text="hi"), + ImageBlockParam( + source=Base64ImageSourceParam(data="AAAA", media_type="image/png") + ), + ], + ) + _assert_no_nulls(from_content_block(block)) + + def test_tool_use_block_has_no_nulls_and_drops_internal_fields(self) -> None: + block = ToolUseBlockParam(id="1", name="click", input={"x": 1}) + dumped = from_content_block(block) + assert "visual_representation" not in dumped + assert "extra_content" not in dumped + _assert_no_nulls(dumped) + + def test_message_with_block_content_has_no_nulls(self) -> None: + message = MessageParam(role="user", content=[TextBlockParam(text="hi")]) + _assert_no_nulls(from_message_param(message)) class TestParseToAnthropicTypes: From 8f7b6f50c8fb0016e3b5c02a67db6c51bf088f6b Mon Sep 17 00:00:00 2001 From: philipph-askui Date: Fri, 28 Aug 2026 15:40:41 -0400 Subject: [PATCH 6/8] fix(anthropic): gate temperature by model instead of dropping it Replaces the blanket temperature drop with the SDK's existing per-model capability check (`accepts_sampling_params`). Forward `temperature` only to models that accept sampling params, routed via `extra_body` (the typed param was removed from newer clients); drop it for the adaptive-thinking generation and warn once per model. This preserves backward compatibility for legacy budget-thinking models (Sonnet 4/4.5, Opus 4.1/4.5, Haiku 4.5, ...) - including the Android agent's deterministic `temperature=0.0` via `make_non_thinking_settings` - and avoids per-call log spam. Also reconcile the taxonomy: the 4.6 adaptive generation (Sonnet 4.6, Opus 4.6) was marked sampling-capable, but the API rejects a non-default temperature with `400 "temperature is deprecated for this model."` (confirmed against both direct Anthropic and Vertex). `accepts_sampling_params` now returns False for the whole adaptive generation. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/askui/models/anthropic/messages_api.py | 35 +++-- src/askui/models/shared/thinking.py | 23 ++-- .../models/anthropic/test_messages_api.py | 121 +++++++++++++----- tests/unit/models/test_thinking.py | 29 +++-- 4 files changed, 140 insertions(+), 68 deletions(-) diff --git a/src/askui/models/anthropic/messages_api.py b/src/askui/models/anthropic/messages_api.py index 70393db8..9adc924f 100644 --- a/src/askui/models/anthropic/messages_api.py +++ b/src/askui/models/anthropic/messages_api.py @@ -43,6 +43,7 @@ ) from askui.models.shared.messages_api import MessagesApi from askui.models.shared.prompts import SystemPrompt +from askui.models.shared.thinking import accepts_sampling_params from askui.models.shared.tools import ToolCollection from askui.utils.image_utils import image_to_base64 from askui.utils.pdf_utils import PdfSource @@ -220,6 +221,9 @@ def __init__( client: AnthropicApiClient, ) -> None: self._client = client + # Models for which we already warned about an ignored temperature, so + # the warning fires at most once per model (not on every step). + self._temperature_warned: set[str] = set() @retry( stop=stop_after_attempt(4), # 3 retries @@ -272,18 +276,28 @@ def create_message( tool_choice, ) - # `temperature` is intentionally NOT forwarded to the Anthropic Messages - # API. Anthropic has deprecated it for its models: newer `anthropic` - # clients removed it from `beta.messages.create`, and the API rejects a + # Forward `temperature` only to models that accept sampling parameters. + # The adaptive-thinking Claude generation (Sonnet 4.6 onward) rejects a # non-default value with `400 "temperature is deprecated for this - # model."`. Forwarding it therefore only breaks the call, so we drop it - # and warn if the caller explicitly requested one. + # model."`, and newer `anthropic` clients removed `temperature` from the + # typed `beta.messages.create` (passing it as a keyword would crash). + # So, when accepted, send it in the request body via `extra_body`; when + # not, drop it and warn once per model. + extra_body: dict[str, Any] = {} if temperature is not None: - logger.warning( - "Ignoring temperature=%s: Anthropic has deprecated the " - "`temperature` parameter for its models, so it is not sent.", - temperature, - ) + if accepts_sampling_params(model_id): + extra_body["temperature"] = temperature + elif model_id not in self._temperature_warned: + self._temperature_warned.add(model_id) + logger.warning( + "Ignoring temperature=%s: model %s does not accept sampling " + "parameters (Anthropic deprecated them for this model " + "generation).", + temperature, + model_id, + ) + + create_kwargs: dict[str, Any] = {"extra_body": extra_body} if extra_body else {} response = self._client.beta.messages.create( # type: ignore[misc] messages=_messages, @@ -297,5 +311,6 @@ def create_message( output_config=_output_config, tool_choice=_tool_choice, timeout=300.0, + **create_kwargs, ) return MessageParam.model_validate(response.model_dump()) diff --git a/src/askui/models/shared/thinking.py b/src/askui/models/shared/thinking.py index 5fe1f94b..20ecdd1f 100644 --- a/src/askui/models/shared/thinking.py +++ b/src/askui/models/shared/thinking.py @@ -54,13 +54,13 @@ "claude-opus-4-5", ) -# The one adaptive-thinking generation that still accepts sampling parameters -# (temperature/top_p/top_k). From Opus 4.7 / Sonnet 5 / Fable 5 onward the API -# rejects them with a 400. -_SAMPLING_CAPABLE_ADAPTIVE_MODEL_PREFIXES = ( - "claude-sonnet-4-6", - "claude-opus-4-6", -) +# Adaptive-thinking Claude models reject sampling parameters +# (temperature/top_p/top_k) with a 400 - the API rejects a non-default value +# with "temperature is deprecated for this model." (confirmed against both +# direct Anthropic and Vertex for Sonnet 4.6). Only the legacy budget-thinking +# generation still accepts them. This set is therefore empty and kept for +# documentation / potential future exceptions. +_SAMPLING_CAPABLE_ADAPTIVE_MODEL_PREFIXES: tuple[str, ...] = () # Models where thinking is always on: an explicit {"type": "disabled"} is # rejected with a 400, so the thinking field must be omitted entirely. @@ -110,10 +110,11 @@ def uses_adaptive_thinking(model_id: str) -> bool: def accepts_sampling_params(model_id: str) -> bool: """Whether the model accepts sampling parameters such as ``temperature``. - False for adaptive-thinking Claude models newer than the 4.6 generation - (Opus 4.7/4.8, Sonnet 5, Fable 5, and future models), which reject them - with a 400. True for older Claude models and non-Claude model IDs (other - providers manage their own sampling parameters). + False for adaptive-thinking Claude models (the 4.6 generation onward - + Sonnet 4.6, Opus 4.6/4.7/4.8, Sonnet 5, Fable 5, and future models), which + reject a non-default value with a 400. True for the legacy budget-thinking + Claude models (Sonnet 4/4.5, Opus 4.1/4.5, Haiku 4.5, ...) and for + non-Claude model IDs (other providers manage their own sampling params). Args: model_id (str): The model identifier (bare or gateway-prefixed). diff --git a/tests/unit/models/anthropic/test_messages_api.py b/tests/unit/models/anthropic/test_messages_api.py index affc72f6..96c7e97d 100644 --- a/tests/unit/models/anthropic/test_messages_api.py +++ b/tests/unit/models/anthropic/test_messages_api.py @@ -135,61 +135,111 @@ def test_no_output_config_omits_it(self) -> None: assert kwargs["output_config"] is omit assert kwargs["thinking"] == {"type": "enabled", "budget_tokens": 2048} - def test_temperature_never_forwarded(self) -> None: + # A legacy budget-thinking model (accepts sampling) vs. an adaptive model + # (rejects sampling / deprecated temperature). + _SAMPLING_MODEL = "claude-sonnet-4-5" + _NO_SAMPLING_MODEL = "claude-sonnet-5" + + def test_temperature_sent_via_extra_body_for_sampling_model(self) -> None: + api, client = self._make_api() + + api.create_message( + messages=[MessageParam(role="user", content="hi")], + model_id=self._SAMPLING_MODEL, + temperature=0.3, + ) + + kwargs = client.beta.messages.create.call_args.kwargs + # Routed through the request body (never as the typed `temperature=` + # kwarg, which newer clients removed). + assert "temperature" not in kwargs + assert kwargs["extra_body"] == {"temperature": 0.3} + + def test_temperature_zero_sent_for_sampling_model(self) -> None: + api, client = self._make_api() + + api.create_message( + messages=[MessageParam(role="user", content="hi")], + model_id=self._SAMPLING_MODEL, + temperature=0.0, + ) + + kwargs = client.beta.messages.create.call_args.kwargs + assert kwargs["extra_body"] == {"temperature": 0.0} + + def test_temperature_dropped_for_non_sampling_model(self) -> None: api, client = self._make_api() - for temperature in (None, 0.0, 0.5, 1.0): + for temperature in (0.0, 0.5, 1.0): api.create_message( messages=[MessageParam(role="user", content="hi")], - model_id="claude-sonnet-5", + model_id=self._NO_SAMPLING_MODEL, temperature=temperature, ) kwargs = client.beta.messages.create.call_args.kwargs - # Anthropic deprecated `temperature`; the SDK never sends it, neither - # as a typed keyword nor in the request body. assert "temperature" not in kwargs assert "extra_body" not in kwargs - def test_warns_when_temperature_requested(self, caplog: Any) -> None: - api, _ = self._make_api() - - import logging + def test_temperature_never_in_body_when_unset(self) -> None: + api, client = self._make_api() - with caplog.at_level(logging.WARNING): - api.create_message( - messages=[MessageParam(role="user", content="hi")], - model_id="claude-sonnet-5", - temperature=0.2, - ) - assert any( - "deprecated" in rec.message and "0.2" in rec.message - for rec in caplog.records + api.create_message( + messages=[MessageParam(role="user", content="hi")], + model_id=self._SAMPLING_MODEL, ) - def test_no_warning_when_temperature_unset(self, caplog: Any) -> None: + kwargs = client.beta.messages.create.call_args.kwargs + assert "temperature" not in kwargs + assert "extra_body" not in kwargs + + def test_warns_once_per_model_for_non_sampling_temperature( + self, caplog: Any + ) -> None: + import logging + api, _ = self._make_api() + with caplog.at_level(logging.WARNING): + for _ in range(3): + api.create_message( + messages=[MessageParam(role="user", content="hi")], + model_id=self._NO_SAMPLING_MODEL, + temperature=0.2, + ) + warnings = [ + rec for rec in caplog.records if "sampling parameters" in rec.message + ] + assert len(warnings) == 1 # once per model, not per call + assert self._NO_SAMPLING_MODEL in warnings[0].message + + def test_no_warning_for_sampling_model_or_unset(self, caplog: Any) -> None: import logging + api, _ = self._make_api() + with caplog.at_level(logging.WARNING): api.create_message( messages=[MessageParam(role="user", content="hi")], - model_id="claude-sonnet-5", + model_id=self._SAMPLING_MODEL, + temperature=0.5, + ) + api.create_message( + messages=[MessageParam(role="user", content="hi")], + model_id=self._NO_SAMPLING_MODEL, ) - assert not any("temperature" in rec.message for rec in caplog.records) + assert not any("sampling parameters" in rec.message for rec in caplog.records) def test_kwargs_accepted_by_real_client_signature(self) -> None: - """Integration guard: every kwarg the SDK sends - with and without a - temperature - must be accepted by the REAL installed `anthropic` client's - `beta.messages.create` signature (bound with no network). This catches - the SDK forwarding a parameter the installed client version does not - support, on whatever anthropic CI resolves.""" + """Integration guard: every kwarg the SDK sends - for a sampling model + (temperature in extra_body) and a non-sampling model (no temperature) - + must be accepted by the REAL installed `anthropic` client signature + (bound with no network). Catches the SDK forwarding an unsupported + parameter on whatever anthropic CI resolves.""" real_client = anthropic.Anthropic(api_key="dummy") real_signature = inspect.signature(real_client.beta.messages.create) def spy(**kwargs: Any) -> MagicMock: - # Raises TypeError if the SDK sends an unsupported keyword. - real_signature.bind(**kwargs) + real_signature.bind(**kwargs) # raises on an unsupported keyword response = MagicMock() response.model_dump.return_value = {"role": "assistant", "content": "hi"} return response @@ -197,10 +247,11 @@ def spy(**kwargs: Any) -> MagicMock: real_client.beta.messages.create = spy # type: ignore[method-assign] api = AnthropicMessagesApi(client=real_client) - for temperature in (None, 0.0, 0.7): - result = api.create_message( - messages=[MessageParam(role="user", content="hi")], - model_id="claude-sonnet-5", - temperature=temperature, - ) - assert isinstance(result, MessageParam) + for model_id in (self._SAMPLING_MODEL, self._NO_SAMPLING_MODEL): + for temperature in (None, 0.0, 0.7): + result = api.create_message( + messages=[MessageParam(role="user", content="hi")], + model_id=model_id, + temperature=temperature, + ) + assert isinstance(result, MessageParam) diff --git a/tests/unit/models/test_thinking.py b/tests/unit/models/test_thinking.py index 7249d82d..b2a9a533 100644 --- a/tests/unit/models/test_thinking.py +++ b/tests/unit/models/test_thinking.py @@ -88,9 +88,11 @@ def test_sonnet_5_is_not_confused_with_sonnet_4_5() -> None: [ ("claude-sonnet-4-5-20250929", True), # legacy: sampling params fine ("claude-haiku-4-5", True), - ("claude-sonnet-4-6", True), # 4.6 generation still accepts them - ("claude-opus-4-6", True), - ("claude-opus-4-7", False), # removed from Opus 4.7 onward + # 4.6 adaptive generation onward rejects sampling params with a 400 + # ("temperature is deprecated for this model.") - confirmed vs the API. + ("claude-sonnet-4-6", False), + ("claude-opus-4-6", False), + ("claude-opus-4-7", False), ("claude-opus-4-8", False), ("claude-sonnet-5", False), ("claude-fable-5", False), @@ -119,20 +121,23 @@ def test_supports_disabled_thinking(model_id: str, expected: bool) -> None: assert supports_disabled_thinking(model_id) is expected -def test_non_thinking_settings_keep_parity_on_older_models() -> None: - assert make_non_thinking_settings("claude-sonnet-4-6") == { +def test_non_thinking_settings_keep_parity_on_legacy_models() -> None: + assert make_non_thinking_settings("claude-sonnet-4-5") == { "thinking": {"type": "disabled"}, "temperature": 0.0, } -def test_non_thinking_settings_drop_temperature_from_opus_4_7_on() -> None: - assert make_non_thinking_settings("claude-opus-4-8") == { - "thinking": {"type": "disabled"}, - } - assert make_non_thinking_settings("anthropic/claude-sonnet-5") == { - "thinking": {"type": "disabled"}, - } +def test_non_thinking_settings_drop_temperature_from_adaptive_generation() -> None: + # 4.6 generation onward: adaptive thinking + no sampling params. + for model_id in ( + "claude-sonnet-4-6", + "claude-opus-4-8", + "anthropic/claude-sonnet-5", + ): + assert make_non_thinking_settings(model_id) == { + "thinking": {"type": "disabled"}, + } def test_non_thinking_settings_omit_thinking_on_always_on_models() -> None: From 81f29d6e2358333a486bb41306b3eac3c1ace1f7 Mon Sep 17 00:00:00 2001 From: philipph-askui Date: Fri, 28 Aug 2026 15:58:01 -0400 Subject: [PATCH 7/8] fix(anthropic): also drop temperature under adaptive thinking (not by model) A direct API probe corrected the earlier assumption: Sonnet 4.6 / Opus 4.6 DO accept `temperature` (only Sonnet 5 / Opus 4.7+ deprecated it). The Sonnet 4.6 "temperature is deprecated" 400 seen earlier was the *adaptive-thinking + temperature* incompatibility (ComputerAgent enables adaptive thinking; the probe did not), not model-level deprecation - which is why a budget-thinking model like Sonnet 4.5 replayed fine with temperature. - Revert the taxonomy change: `accepts_sampling_params` returns True again for the 4.6 generation (Sonnet 4.6 / Opus 4.6). - Forward `temperature` only when the model accepts sampling params AND adaptive thinking is not enabled (fixed budget_tokens thinking is compatible); otherwise drop it and warn once per model with the specific reason. Route it via `extra_body` when sent. This keeps temperature working for the 4.5 and 4.6 generations without thinking (and with budget thinking), drops it under adaptive thinking and for Sonnet 5+, and never crashes. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/askui/models/anthropic/messages_api.py | 39 +++++++++++++------ src/askui/models/shared/thinking.py | 31 +++++++++------ .../models/anthropic/test_messages_api.py | 37 ++++++++++++++++-- tests/unit/models/test_thinking.py | 29 ++++++-------- 4 files changed, 93 insertions(+), 43 deletions(-) diff --git a/src/askui/models/anthropic/messages_api.py b/src/askui/models/anthropic/messages_api.py index 9adc924f..b472b116 100644 --- a/src/askui/models/anthropic/messages_api.py +++ b/src/askui/models/anthropic/messages_api.py @@ -58,6 +58,16 @@ def _is_retryable_error(exception: BaseException) -> bool: return isinstance(exception, (APIConnectionError, APITimeoutError, APIError)) +def _is_adaptive_thinking(thinking: BetaThinkingConfigParam | Omit) -> bool: + """Whether *adaptive* thinking is enabled for this request. + + The API rejects a non-default ``temperature`` while adaptive thinking is on + (fixed ``budget_tokens`` thinking is unaffected), so temperature is dropped + in that case. + """ + return isinstance(thinking, dict) and thinking.get("type") == "adaptive" + + def from_content_block(block: ContentBlockParam) -> BetaContentBlockParam: """Convert an internal content block to an Anthropic API-compatible dict. @@ -276,25 +286,32 @@ def create_message( tool_choice, ) - # Forward `temperature` only to models that accept sampling parameters. - # The adaptive-thinking Claude generation (Sonnet 4.6 onward) rejects a - # non-default value with `400 "temperature is deprecated for this - # model."`, and newer `anthropic` clients removed `temperature` from the - # typed `beta.messages.create` (passing it as a keyword would crash). - # So, when accepted, send it in the request body via `extra_body`; when - # not, drop it and warn once per model. + # Decide whether to forward `temperature`. The API rejects it (400) in + # two independent cases: + # 1. Models that deprecated sampling params entirely (Sonnet 5 / Opus + # 4.7 onward) - see `accepts_sampling_params`. + # 2. Any request with *adaptive* thinking enabled, where a non-default + # temperature is rejected (budget thinking is fine). + # Newer `anthropic` clients also removed the typed `temperature` param, + # so when we do send it we route it through the request body via + # `extra_body`; otherwise we drop it and warn once per model. extra_body: dict[str, Any] = {} if temperature is not None: - if accepts_sampling_params(model_id): + adaptive = _is_adaptive_thinking(_thinking) + if accepts_sampling_params(model_id) and not adaptive: extra_body["temperature"] = temperature elif model_id not in self._temperature_warned: self._temperature_warned.add(model_id) + reason = ( + "adaptive thinking is enabled (temperature must be left unset)" + if adaptive + else "the model deprecated sampling parameters" + ) logger.warning( - "Ignoring temperature=%s: model %s does not accept sampling " - "parameters (Anthropic deprecated them for this model " - "generation).", + "Ignoring temperature=%s for model %s: %s.", temperature, model_id, + reason, ) create_kwargs: dict[str, Any] = {"extra_body": extra_body} if extra_body else {} diff --git a/src/askui/models/shared/thinking.py b/src/askui/models/shared/thinking.py index 20ecdd1f..cb809338 100644 --- a/src/askui/models/shared/thinking.py +++ b/src/askui/models/shared/thinking.py @@ -54,13 +54,16 @@ "claude-opus-4-5", ) -# Adaptive-thinking Claude models reject sampling parameters -# (temperature/top_p/top_k) with a 400 - the API rejects a non-default value -# with "temperature is deprecated for this model." (confirmed against both -# direct Anthropic and Vertex for Sonnet 4.6). Only the legacy budget-thinking -# generation still accepts them. This set is therefore empty and kept for -# documentation / potential future exceptions. -_SAMPLING_CAPABLE_ADAPTIVE_MODEL_PREFIXES: tuple[str, ...] = () +# The adaptive-thinking generation that still accepts sampling parameters +# (temperature/top_p/top_k) when thinking is not adaptive. From Opus 4.7 / +# Sonnet 5 / Fable 5 onward the API rejects sampling params outright with a 400. +# (Note: even for these models, an explicit temperature is rejected while +# *adaptive* thinking is enabled - that constraint is enforced separately, at +# request time, not by this model classification.) +_SAMPLING_CAPABLE_ADAPTIVE_MODEL_PREFIXES = ( + "claude-sonnet-4-6", + "claude-opus-4-6", +) # Models where thinking is always on: an explicit {"type": "disabled"} is # rejected with a 400, so the thinking field must be omitted entirely. @@ -110,11 +113,15 @@ def uses_adaptive_thinking(model_id: str) -> bool: def accepts_sampling_params(model_id: str) -> bool: """Whether the model accepts sampling parameters such as ``temperature``. - False for adaptive-thinking Claude models (the 4.6 generation onward - - Sonnet 4.6, Opus 4.6/4.7/4.8, Sonnet 5, Fable 5, and future models), which - reject a non-default value with a 400. True for the legacy budget-thinking - Claude models (Sonnet 4/4.5, Opus 4.1/4.5, Haiku 4.5, ...) and for - non-Claude model IDs (other providers manage their own sampling params). + False for adaptive-thinking Claude models newer than the 4.6 generation + (Opus 4.7/4.8, Sonnet 5, Fable 5, and future models), which reject them + with a 400. True for older Claude models and non-Claude model IDs (other + providers manage their own sampling parameters). + + Note: this is a *model-level* capability. Sonnet 4.6 / Opus 4.6 accept + ``temperature`` in general, but the API still rejects it while *adaptive* + thinking is enabled - that request-level rule is enforced in the messages + API, not here. Args: model_id (str): The model identifier (bare or gateway-prefixed). diff --git a/tests/unit/models/anthropic/test_messages_api.py b/tests/unit/models/anthropic/test_messages_api.py index 96c7e97d..67ace169 100644 --- a/tests/unit/models/anthropic/test_messages_api.py +++ b/tests/unit/models/anthropic/test_messages_api.py @@ -135,10 +135,12 @@ def test_no_output_config_omits_it(self) -> None: assert kwargs["output_config"] is omit assert kwargs["thinking"] == {"type": "enabled", "budget_tokens": 2048} - # A legacy budget-thinking model (accepts sampling) vs. an adaptive model - # (rejects sampling / deprecated temperature). - _SAMPLING_MODEL = "claude-sonnet-4-5" + # A model that accepts sampling params (Sonnet 4.6 - accepts temperature when + # thinking is not adaptive) vs. one that deprecated them (Sonnet 5). + _SAMPLING_MODEL = "claude-sonnet-4-6" _NO_SAMPLING_MODEL = "claude-sonnet-5" + _ADAPTIVE_THINKING = {"type": "adaptive"} + _BUDGET_THINKING = {"type": "enabled", "budget_tokens": 2048} def test_temperature_sent_via_extra_body_for_sampling_model(self) -> None: api, client = self._make_api() @@ -180,6 +182,35 @@ def test_temperature_dropped_for_non_sampling_model(self) -> None: assert "temperature" not in kwargs assert "extra_body" not in kwargs + def test_temperature_sent_with_budget_thinking(self) -> None: + # Fixed budget_tokens thinking is compatible with temperature. + api, client = self._make_api() + + api.create_message( + messages=[MessageParam(role="user", content="hi")], + model_id=self._SAMPLING_MODEL, + thinking=self._BUDGET_THINKING, + temperature=0.3, + ) + + kwargs = client.beta.messages.create.call_args.kwargs + assert kwargs["extra_body"] == {"temperature": 0.3} + + def test_temperature_dropped_with_adaptive_thinking(self) -> None: + # Adaptive thinking rejects a non-default temperature -> drop it. + api, client = self._make_api() + + api.create_message( + messages=[MessageParam(role="user", content="hi")], + model_id=self._SAMPLING_MODEL, + thinking=self._ADAPTIVE_THINKING, + temperature=0.3, + ) + + kwargs = client.beta.messages.create.call_args.kwargs + assert "temperature" not in kwargs + assert "extra_body" not in kwargs + def test_temperature_never_in_body_when_unset(self) -> None: api, client = self._make_api() diff --git a/tests/unit/models/test_thinking.py b/tests/unit/models/test_thinking.py index b2a9a533..7249d82d 100644 --- a/tests/unit/models/test_thinking.py +++ b/tests/unit/models/test_thinking.py @@ -88,11 +88,9 @@ def test_sonnet_5_is_not_confused_with_sonnet_4_5() -> None: [ ("claude-sonnet-4-5-20250929", True), # legacy: sampling params fine ("claude-haiku-4-5", True), - # 4.6 adaptive generation onward rejects sampling params with a 400 - # ("temperature is deprecated for this model.") - confirmed vs the API. - ("claude-sonnet-4-6", False), - ("claude-opus-4-6", False), - ("claude-opus-4-7", False), + ("claude-sonnet-4-6", True), # 4.6 generation still accepts them + ("claude-opus-4-6", True), + ("claude-opus-4-7", False), # removed from Opus 4.7 onward ("claude-opus-4-8", False), ("claude-sonnet-5", False), ("claude-fable-5", False), @@ -121,23 +119,20 @@ def test_supports_disabled_thinking(model_id: str, expected: bool) -> None: assert supports_disabled_thinking(model_id) is expected -def test_non_thinking_settings_keep_parity_on_legacy_models() -> None: - assert make_non_thinking_settings("claude-sonnet-4-5") == { +def test_non_thinking_settings_keep_parity_on_older_models() -> None: + assert make_non_thinking_settings("claude-sonnet-4-6") == { "thinking": {"type": "disabled"}, "temperature": 0.0, } -def test_non_thinking_settings_drop_temperature_from_adaptive_generation() -> None: - # 4.6 generation onward: adaptive thinking + no sampling params. - for model_id in ( - "claude-sonnet-4-6", - "claude-opus-4-8", - "anthropic/claude-sonnet-5", - ): - assert make_non_thinking_settings(model_id) == { - "thinking": {"type": "disabled"}, - } +def test_non_thinking_settings_drop_temperature_from_opus_4_7_on() -> None: + assert make_non_thinking_settings("claude-opus-4-8") == { + "thinking": {"type": "disabled"}, + } + assert make_non_thinking_settings("anthropic/claude-sonnet-5") == { + "thinking": {"type": "disabled"}, + } def test_non_thinking_settings_omit_thinking_on_always_on_models() -> None: From a6d392eeea2e7cd1bb2e5caca2c100e8e17a6f18 Mon Sep 17 00:00:00 2001 From: philipph-askui Date: Fri, 28 Aug 2026 16:33:18 -0400 Subject: [PATCH 8/8] build(deps): require anthropic>=1,<2 This PR makes the messages API work with the 1.x anthropic client (routes `temperature` through `extra_body` instead of the typed kwarg that 1.x removed), and the lock already resolves 1.2.0. Bump the dependency floor to `>=1` so the declared constraint matches what the code targets, and cap at `<2` so the next major can't silently break us the way 1.x did. Co-Authored-By: Claude Opus 4.8 (1M context) --- pdm.lock | 2 +- pyproject.toml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pdm.lock b/pdm.lock index aa53edb1..2beb981a 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "all", "bedrock", "dev", "office-document", "otel", "vertex", "web"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:156da503bb3b6d055b873aca6b997b6e62cb1b42bf9ccb17ade899bb0d8c6052" +content_hash = "sha256:238adac982e7ce1a5e94921efaea67fb9c397102bd0da26158dea57110ef3887" [[metadata.targets]] requires_python = ">=3.10,<3.14" diff --git a/pyproject.toml b/pyproject.toml index aa9f17d2..4890ef4b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ authors = [ ] dependencies = [ "askui-agent-os>=26.6.1", - "anthropic>=0.86.0", + "anthropic>=1,<2", # messages API targets the 1.x client (routes temperature via extra_body; 1.x removed the typed kwarg). <2 so the next major can't silently break us again "fastapi>=0.115.12", "fastmcp>=2.3.0", "gradio-client>=1.4.3", @@ -213,7 +213,7 @@ office-document = [ "markitdown[xls,xlsx,docx]>=0.1.2" ] bedrock = [ - "anthropic[bedrock]>=0.72.0" + "anthropic[bedrock]>=1,<2" ] otel = [ "opentelemetry-api>=1.38.0", @@ -222,7 +222,7 @@ otel = [ ] vertex = [ "google-cloud-aiplatform>=1.122.0", - "anthropic[vertex]>=0.72.0", + "anthropic[vertex]>=1,<2", ] web = [ "playwright>=1.41.0",