From 26e71cee241d2b6f942d376363339cb1da7b14a3 Mon Sep 17 00:00:00 2001 From: Brandon Rose Date: Tue, 30 Jun 2026 17:26:38 -0500 Subject: [PATCH 1/2] fix(anthropic): tolerate models that reject an explicit temperature Agent.execute() always passes temperature=, but the newest Claude models (e.g. Sonnet 5) reject it with "temperature is deprecated for this model". OpenAIModel already strips temperature for its reasoning models by name, but Claude's naming gives no reliable signal (a "5" appears in models that both do and don't accept it). So AnthropicModel learns from the first rejection: it retries once without temperature and remembers, skipping it on subsequent calls. Unrelated BadRequestErrors are re-raised untouched. Co-Authored-By: Claude Opus 4.8 --- archytas/models/anthropic.py | 19 +++++++ tests/test_anthropic_temperature.py | 82 +++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 tests/test_anthropic_temperature.py diff --git a/archytas/models/anthropic.py b/archytas/models/anthropic.py index cd5dbfc..c315fa4 100644 --- a/archytas/models/anthropic.py +++ b/archytas/models/anthropic.py @@ -75,6 +75,25 @@ def __init__(self, config: ModelConfig, **kwargs) -> None: super().__init__(config, **kwargs) self.tool_name_map = {} self.rev_tool_name_map = {} + # Some newer Claude models reject an explicit `temperature`. The model + # name gives no reliable signal (a "5" appears in models that both do and + # don't accept it), so we learn it from the first rejection and remember. + self._temperature_unsupported = False + + async def ainvoke(self, input, *, config=None, stop=None, **kwargs): + # Newer Claude models (e.g. Sonnet 5) reject an explicit `temperature`. + # Strip it once the model complains and skip it on every subsequent call. + if self._temperature_unsupported: + kwargs.pop("temperature", None) + try: + return await super().ainvoke(input, config=config, stop=stop, **kwargs) + except BadRequestError as err: + already_stripped = "temperature" not in kwargs + if already_stripped or "temperature" not in (getattr(err, "message", "") or "").lower(): + raise + self._temperature_unsupported = True + kwargs.pop("temperature", None) + return await super().ainvoke(input, config=config, stop=stop, **kwargs) def auth(self, **kwargs) -> None: if 'api_key' in kwargs: diff --git a/tests/test_anthropic_temperature.py b/tests/test_anthropic_temperature.py new file mode 100644 index 0000000..55ad6ca --- /dev/null +++ b/tests/test_anthropic_temperature.py @@ -0,0 +1,82 @@ +"""Regression tests for Anthropic temperature handling. + +The newest Claude models (e.g. Sonnet 5) reject an explicit ``temperature``. +``Agent.execute()`` always passes one, and the model name gives no reliable +signal as to which models accept it, so ``AnthropicModel`` must learn from the +first rejection, retry without ``temperature``, and remember it thereafter -- +without swallowing unrelated ``BadRequestError``s. +""" +import asyncio + +import httpx +import pytest +from anthropic import BadRequestError +from langchain_core.messages import AIMessage, HumanMessage + +from archytas.models.anthropic import AnthropicModel + + +def _bad_request(message: str) -> BadRequestError: + request = httpx.Request("POST", "https://api.anthropic.com/v1/messages") + response = httpx.Response(400, request=request) + return BadRequestError(message, response=response, body={"error": {"message": message}}) + + +class _FakeChatAnthropic: + """Stand-in for the langchain client. Records the kwargs of each call and + raises a configurable error on the first call (optionally only when a + temperature is present).""" + + def __init__(self, error: Exception | None = None, only_with_temperature: bool = True): + self.calls: list[dict] = [] + self._error = error + self._only_with_temperature = only_with_temperature + + async def ainvoke(self, messages, config=None, stop=None, **kwargs): + self.calls.append(dict(kwargs)) + if self._error is not None and (not self._only_with_temperature or "temperature" in kwargs): + raise self._error + return AIMessage(content="ok") + + +@pytest.fixture(autouse=True) +def _disable_prompt_cache(monkeypatch): + # Keep _preprocess_messages off the cache-control path so a minimal message + # list is enough to exercise ainvoke. + monkeypatch.setenv("ARCHYTAS_DISABLE_PROMPT_CACHE", "1") + + +def _model() -> AnthropicModel: + return AnthropicModel({"api_key": "sk-ant-dummy", "model_name": "claude-sonnet-5"}) + + +def test_retries_without_temperature_then_remembers(): + model = _model() + fake = _FakeChatAnthropic(error=_bad_request("temperature is deprecated for this model.")) + model._model = fake + + result = asyncio.run(model.ainvoke([HumanMessage(content="hi")], temperature=0.0)) + + assert result.content == "ok" + assert model._temperature_unsupported is True + # First attempt carried temperature; the retry dropped it. + assert "temperature" in fake.calls[0] + assert "temperature" not in fake.calls[1] + + # A subsequent call strips temperature up front -- no failed attempt. + fake.calls.clear() + asyncio.run(model.ainvoke([HumanMessage(content="hi")], temperature=0.0)) + assert len(fake.calls) == 1 + assert "temperature" not in fake.calls[0] + + +def test_unrelated_bad_request_is_not_retried(): + model = _model() + fake = _FakeChatAnthropic(error=_bad_request("some other invalid parameter"), only_with_temperature=False) + model._model = fake + + with pytest.raises(BadRequestError): + asyncio.run(model.ainvoke([HumanMessage(content="hi")], temperature=0.0)) + + assert model._temperature_unsupported is False + assert len(fake.calls) == 1 # no retry From 93555f9a64623acbcc1ed7423fd68112a9b38c70 Mon Sep 17 00:00:00 2001 From: Brandon Rose Date: Wed, 1 Jul 2026 08:42:30 -0500 Subject: [PATCH 2/2] chore(models): log handled invocation errors at debug instead of printing Base `ainvoke` printed every caught error to stdout before delegating to handle_invoke_error, so an error a subclass expects and recovers from -- e.g. AnthropicModel retrying without a rejected `temperature` -- still dumped a scary 400 to the console and looked like a failure. Demote it to a debug log; genuinely unhandled errors still surface upstream with a traceback. Co-Authored-By: Claude Opus 4.8 --- archytas/models/base.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/archytas/models/base.py b/archytas/models/base.py index d732a9f..2e18da3 100644 --- a/archytas/models/base.py +++ b/archytas/models/base.py @@ -1,5 +1,6 @@ import copy import json +import logging import os from abc import ABC, abstractmethod from pydantic import BaseModel as PydanticModel, ConfigDict, create_model, Field @@ -15,6 +16,9 @@ from ..agent import AgentResponse +logger = logging.getLogger(__name__) + + class EnvironmentAuth: env_settings: dict[str, str] @@ -298,7 +302,12 @@ async def ainvoke(self, input, *, config=None, stop=None, agent_tools: dict=None ) return result except Exception as error: - print(error) + # Delegated to handle_invoke_error, which either transforms this into + # a specific exception or re-raises it (so it surfaces upstream with a + # traceback). Log at debug rather than printing to stdout, so expected, + # handled errors -- e.g. a model rejecting a parameter that a subclass + # then retries without -- don't look like failures. + logger.debug("Model invocation raised; delegating to handle_invoke_error: %r", error) return self.handle_invoke_error(error) finally: # Reset so a subsequent invocation that doesn't come through