diff --git a/frontier_agent/infra/config.py b/frontier_agent/infra/config.py index 4c94249b..7c6b1d2f 100644 --- a/frontier_agent/infra/config.py +++ b/frontier_agent/infra/config.py @@ -134,7 +134,7 @@ class FrontierAgentConfig(BaseSettings): openai_model: str = "gpt-4o" openai_base_url: str = "https://api.openai.com/v1" anthropic_api_key: str = "" - anthropic_model: str = "claude-sonnet-4-20250514" + anthropic_model: str = "claude-sonnet-5-5" anthropic_base_url: str = "" anthropic_thinking: bool = False anthropic_thinking_type: str = "adaptive" diff --git a/frontier_agent/infra/llm_adapter.py b/frontier_agent/infra/llm_adapter.py index 70e7ca50..87cdf719 100644 --- a/frontier_agent/infra/llm_adapter.py +++ b/frontier_agent/infra/llm_adapter.py @@ -5,6 +5,7 @@ import asyncio import logging import random +import re import time from collections.abc import AsyncIterator from typing import Any, TypeGuard @@ -365,7 +366,26 @@ async def stream( raise last_error from fb_err -def _anthropic_thinking_kwarg(config: Any, max_tokens: int) -> dict[str, Any] | None: +# Claude 5 models and Opus 4.7+ reject non-default sampling parameters (temperature) with a 400. +# Not anchored, so OpenRouter (``anthropic/claude-sonnet-5.5``) and Bedrock +# (``global.anthropic.claude-sonnet-5-5``) ids match too. +_ANTHROPIC_NO_SAMPLING_MODEL = re.compile( + r"claude-(?:(?:opus|sonnet|haiku|fable|mythos)-5|opus-4[-.][78])" +) + + +def _anthropic_temperature( + model: str, thinking: dict[str, Any] | None, temperature: float = 0.3, +) -> float | None: + """Sampling temperature for the Anthropic client, ``None`` to omit it.""" + if thinking or _ANTHROPIC_NO_SAMPLING_MODEL.search(model): + return None + return temperature + + +def _anthropic_thinking_kwarg( + config: Any, max_tokens: int, model: str, +) -> dict[str, Any] | None: """Anthropic extended-thinking request kwarg, or ``None`` when off. ``ANTHROPIC_THINKING`` on → the shape selected by ``anthropic_thinking_type`` @@ -382,7 +402,9 @@ def _anthropic_thinking_kwarg(config: Any, max_tokens: int) -> dict[str, Any] | if not getattr(config, "anthropic_thinking", False): return None ttype = str(getattr(config, "anthropic_thinking_type", "adaptive") or "adaptive").strip().lower() - if ttype == "enabled": + # Claude 5 and Opus 4.7+ require adaptive thinking; migrate legacy budgets + # using the resolved model so role and fallback overrides stay compatible. + if ttype == "enabled" and not _ANTHROPIC_NO_SAMPLING_MODEL.search(model): budget = int(getattr(config, "anthropic_thinking_budget", 8192) or 8192) budget = max(1024, min(budget, int(max_tokens) - 1)) return {"type": "enabled", "budget_tokens": budget} @@ -432,13 +454,14 @@ def _create_single_provider(config: FrontierAgentConfig, model_override: str | N from frontier_agent.infra.anthropic_client import AnthropicClient # Opt-in extended thinking (ANTHROPIC_THINKING): standard enabled+budget # thinking + drop temperature (Anthropic 400s on the combo). Flag off → - # prior kwargs preserved exactly (temperature=0.3, no thinking). - thinking = _anthropic_thinking_kwarg(config, max_tokens) + # prior kwargs preserved exactly (temperature=0.3 except on Claude 5 models, no thinking). + model = model_override or config.anthropic_model + thinking = _anthropic_thinking_kwarg(config, max_tokens, model) return AnthropicClient( - model=model_override or config.anthropic_model, + model=model, api_key=config.anthropic_api_key, base_url=config.anthropic_base_url or None, - temperature=None if thinking else 0.3, + temperature=_anthropic_temperature(model, thinking), max_tokens=max_tokens, thinking=thinking, effort=_anthropic_effort_kwarg(config, thinking), @@ -619,16 +642,17 @@ def _create_overridden_provider( # Opt-in extended thinking (see sibling branch above): standard # enabled+budget thinking + drop temperature. Flag off → prior kwargs # preserved exactly. - thinking = _anthropic_thinking_kwarg(config, max_tokens) + anthropic_model = model if (provider and model) else _resolve_model_name( + config, resolved_provider, model, + ) + thinking = _anthropic_thinking_kwarg(config, max_tokens, anthropic_model) return AnthropicClient( - model=model if (provider and model) else _resolve_model_name( - config, resolved_provider, model, - ), + model=anthropic_model, api_key=api_key if api_key is not None else config.anthropic_api_key, # Honour a per-entry base_url (fallback-chain / override) — fall back # to the configured default only when the caller didn't pass one. base_url=(base_url if base_url is not None else config.anthropic_base_url) or None, - temperature=None if thinking else temperature, + temperature=_anthropic_temperature(anthropic_model, thinking, temperature), max_tokens=max_tokens, thinking=thinking, effort=_anthropic_effort_kwarg(config, thinking), diff --git a/tests/test_anthropic_default_model.py b/tests/test_anthropic_default_model.py new file mode 100644 index 00000000..0736476d --- /dev/null +++ b/tests/test_anthropic_default_model.py @@ -0,0 +1,84 @@ +import pytest + +from frontier_agent.infra.config import FrontierAgentConfig +from frontier_agent.infra.llm_adapter import _anthropic_temperature + +# claude-sonnet-4-20250514 was retired by Anthropic on 2026-06-15. +RETIRED_MODEL = "claude-sonnet-4-20250514" + + +def test_default_anthropic_model_is_not_retired(): + assert FrontierAgentConfig.model_fields["anthropic_model"].default != RETIRED_MODEL + + +def test_default_anthropic_model_gets_no_temperature(): + default = FrontierAgentConfig.model_fields["anthropic_model"].default + + assert _anthropic_temperature(default, None) is None + + +def test_older_models_keep_the_temperature(): + assert _anthropic_temperature("claude-sonnet-4-6", None) == 0.3 + + +def test_thinking_drops_the_temperature(): + assert _anthropic_temperature("claude-sonnet-4-6", {"type": "adaptive"}) is None + + +def test_prefixed_claude_5_ids_get_no_temperature(): + assert _anthropic_temperature("anthropic/claude-sonnet-5.5", None) is None + assert _anthropic_temperature("global.anthropic.claude-sonnet-5-5", None) is None + + +def test_opus_4_7_and_4_8_get_no_temperature(): + assert _anthropic_temperature("claude-opus-4-7", None) is None + assert _anthropic_temperature("claude-opus-4-8", None) is None + assert _anthropic_temperature("claude-opus-4-6", None) == 0.3 + + +def test_role_temperature_is_kept_for_older_models(): + assert _anthropic_temperature("claude-sonnet-4-6", None, 0.7) == 0.7 + assert _anthropic_temperature("claude-sonnet-5-5", None, 0.7) is None + + +def test_overridden_provider_omits_temperature_for_claude_5(monkeypatch): + import frontier_agent.infra.anthropic_client as anthropic_client + from frontier_agent.infra.llm_adapter import _create_overridden_provider + + captured = {} + + class FakeClient: + def __init__(self, **kwargs): + captured.update(kwargs) + + monkeypatch.setattr(anthropic_client, "AnthropicClient", FakeClient) + config = FrontierAgentConfig(llm_provider="anthropic", anthropic_api_key="test-key") + + _create_overridden_provider(config, temperature=0.7) + assert captured["model"] == config.anthropic_model + assert captured["temperature"] is None + + _create_overridden_provider(config, "claude-sonnet-4-6", temperature=0.7) + assert captured["temperature"] == 0.7 + + +@pytest.mark.parametrize("factory_name", ["_create_single_provider", "_create_overridden_provider"]) +@pytest.mark.parametrize("model", [None, "claude-sonnet-4-20250514", "claude-sonnet-5-5"]) +def test_manual_thinking_migration_on_wire(factory_name, model): + from frontier_agent.infra import llm_adapter + + config = FrontierAgentConfig( + _env_file=None, llm_provider="anthropic", anthropic_api_key="test-key", + anthropic_model="claude-sonnet-5-5", anthropic_thinking=True, + anthropic_thinking_type="enabled", anthropic_thinking_budget=2048, + ) + client = getattr(llm_adapter, factory_name)(config, model) + payload = client._build_kwargs( + [{"role": "user", "content": "hello"}], tools=None, temperature=None, + max_tokens=None, extra_headers=None, timeout=None, + ) + if model == "claude-sonnet-4-20250514": + assert payload["thinking"] == {"type": "enabled", "budget_tokens": 2048} + else: + assert payload["thinking"] == {"type": "adaptive", "display": "summarized"} + assert payload["model"] == (model or config.anthropic_model)