diff --git a/pdm.lock b/pdm.lock index c9c0845b..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" @@ -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/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", diff --git a/src/askui/models/anthropic/messages_api.py b/src/askui/models/anthropic/messages_api.py index 7442f29b..b472b116 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 ( @@ -42,10 +43,13 @@ ) 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 +logger = logging.getLogger(__name__) + def _is_retryable_error(exception: BaseException) -> bool: """Check if the exception is a retryable error.""" @@ -54,12 +58,27 @@ 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. - 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 @@ -69,9 +88,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: @@ -147,7 +169,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 +177,6 @@ def _parse_to_anthropic_types( BetaThinkingConfigParam | Omit, BetaOutputConfigParam | Omit, BetaToolChoiceParam | Omit, - float | Omit, ]: """Convert provider-agnostic types to Anthropic-specific types. @@ -193,7 +213,6 @@ def _parse_to_anthropic_types( _tool_choice = ( cast("BetaToolChoiceParam", tool_choice) if tool_choice is not None else omit ) - _temperature = temperature or omit return ( _tools, @@ -203,7 +222,6 @@ def _parse_to_anthropic_types( _thinking, _output_config, _tool_choice, - _temperature, ) @@ -213,6 +231,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 @@ -255,7 +276,6 @@ def create_message( _thinking, _output_config, _tool_choice, - _temperature, ) = _parse_to_anthropic_types( tools, betas, @@ -264,9 +284,38 @@ def create_message( thinking, output_config, tool_choice, - temperature, ) + # 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: + 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 for model %s: %s.", + temperature, + model_id, + reason, + ) + + create_kwargs: dict[str, Any] = {"extra_body": extra_body} if extra_body else {} + response = self._client.beta.messages.create( # type: ignore[misc] messages=_messages, max_tokens=max_tokens or 8192, @@ -278,7 +327,7 @@ def create_message( thinking=_thinking, output_config=_output_config, tool_choice=_tool_choice, - temperature=_temperature, 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..cb809338 100644 --- a/src/askui/models/shared/thinking.py +++ b/src/askui/models/shared/thinking.py @@ -54,9 +54,12 @@ "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. +# 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", @@ -115,6 +118,11 @@ def accepts_sampling_params(model_id: str) -> bool: 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 443da006..67ace169 100644 --- a/tests/unit/models/anthropic/test_messages_api.py +++ b/tests/unit/models/anthropic/test_messages_api.py @@ -1,14 +1,80 @@ """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 ( 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: @@ -68,3 +134,155 @@ 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} + + # 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() + + 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 (0.0, 0.5, 1.0): + api.create_message( + messages=[MessageParam(role="user", content="hi")], + model_id=self._NO_SAMPLING_MODEL, + temperature=temperature, + ) + kwargs = client.beta.messages.create.call_args.kwargs + 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() + + api.create_message( + messages=[MessageParam(role="user", content="hi")], + model_id=self._SAMPLING_MODEL, + ) + + 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=self._SAMPLING_MODEL, + temperature=0.5, + ) + api.create_message( + messages=[MessageParam(role="user", content="hi")], + model_id=self._NO_SAMPLING_MODEL, + ) + 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 - 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: + real_signature.bind(**kwargs) # raises on an unsupported keyword + 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) + + 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)