Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions archytas/models/anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
11 changes: 10 additions & 1 deletion archytas/models/base.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -15,6 +16,9 @@
from ..agent import AgentResponse


logger = logging.getLogger(__name__)


class EnvironmentAuth:
env_settings: dict[str, str]

Expand Down Expand Up @@ -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
Expand Down
82 changes: 82 additions & 0 deletions tests/test_anthropic_temperature.py
Original file line number Diff line number Diff line change
@@ -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
Loading