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/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 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