Skip to content
Draft
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
12 changes: 8 additions & 4 deletions artemis/agents/operator/prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -694,12 +694,16 @@ def _clip(text: str, limit: int = 80) -> str:
#: One-line tail notice for the turn after a turn whose replies carried no visible
#: reasoning text. Thinking-mode models satisfy "reason first" inside their thought
#: channel and emit bare tool calls; the reminder costs no extra model call.
# Phrased as a request for a progress note, not for the model's reasoning:
# Claude Sonnet 5.5's safety classifiers decline a request that asks the model
# to surface its internal thinking (stop_reason "refusal", category
# "reasoning_extraction"), which the old wording ("internal thinking is not
# shown") triggered on every silent turn.
REASONING_REMINDER = (
"--- Reminder ---\n"
"Your previous turn carried no visible reasoning text (internal thinking is not"
" shown). This turn, write one short paragraph of plain prose before calling a"
" Turn-Ending Action: what the screen shows, whether the last action worked, and"
" what you do next and why."
"Your previous turn went straight to a tool call. This turn, start with one short"
" progress note in plain prose before calling a Turn-Ending Action: what the screen"
" shows, whether the last action worked, and what you do next."
)


Expand Down
114 changes: 114 additions & 0 deletions artemis/llm/claude.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Claude model capability checks for the Anthropic request builder.

Newer Claude models reject request shapes older ones accept:

* Opus 4.7+, Sonnet 5+, Haiku 5+ and every Fable / Mythos model are
*adaptive-only*: ``thinking.budget_tokens`` and non-default sampling
parameters (``temperature``) are a 400, and thinking depth is set with
``output_config.effort``. The Fable 5.1 / Opus 5.5 / Sonnet 5.5
generation also rejects forced ``tool_choice``.
* Fable 5.1, Opus 5.5, Sonnet 5.5 and Haiku 5.5 bind each thinking block to
the exact history that produced it ("preserved thinking"). Replaying a
block after an earlier message changed is a 400 unless the request opts
into ``prefix_mismatch_behavior: "drop_block"``.

Model ids with no parsable family/version (``claude-3-7-sonnet-...``,
custom deployments) are treated as legacy and keep the old request shape.
"""

import re
from typing import Any

#: Beta header that unlocks ``thinking.block_binding``.
THINKING_BINDING_BETA = "thinking-binding-controls-2026-08-01"

#: Accepted values of ``output_config.effort``.
EFFORT_LEVELS = ("low", "medium", "high", "xhigh", "max")

_MODEL_RE = re.compile(r"claude-(opus|sonnet|haiku|fable|mythos)-(\d+)(?:-(\d{1,2}))?(?!\d)")

# Lowest version in each family that is adaptive-only.
_ADAPTIVE_ONLY_MIN = {
"opus": (4, 7),
"sonnet": (5, 0),
"haiku": (5, 0),
"fable": (0, 0),
"mythos": (0, 0),
}

# Lowest version in each family that binds thinking blocks to the history.
_BLOCK_BINDING_MIN = {
"opus": (5, 5),
"sonnet": (5, 5),
"haiku": (5, 5),
"fable": (5, 1),
"mythos": (5, 1),
}


def claude_version(model_name: Any) -> tuple[str, tuple[int, int]] | None:
"""Parses ``claude-sonnet-5-5`` into ``("sonnet", (5, 5))``.

Provider prefixes (``anthropic.``, ``anthropic/``) and date or context
suffixes are ignored. Returns ``None`` for ids in the legacy
``claude-<major>-<minor>-<family>`` order or anything unrecognised.
"""
if not isinstance(model_name, str):
return None
match = _MODEL_RE.search(model_name.lower())
if not match:
return None
# The minor group stops at two digits, so a date snapshot
# (``claude-opus-4-20250514``) leaves the minor version at 0.
family, major, minor = match.groups()
return family, (int(major), int(minor) if minor else 0)


def _at_least(model_name: Any, minimums: dict[str, tuple[int, int]]) -> bool:
parsed = claude_version(model_name)
if parsed is None:
return False
family, version = parsed
return version >= minimums[family]


def is_adaptive_only(model_name: Any) -> bool:
"""Whether the model rejects ``budget_tokens`` and non-default sampling."""
return _at_least(model_name, _ADAPTIVE_ONLY_MIN)


def binds_thinking_blocks(model_name: Any) -> bool:
"""Whether replayed thinking blocks must match their original history."""
return _at_least(model_name, _BLOCK_BINDING_MIN)


def effort_for(reasoning_effort: str | None, thinking_budget: int | None) -> str | None:
"""Maps the endpoint's effort / budget settings onto ``output_config.effort``.

An explicit ``reasoning_effort`` wins; a legacy token budget is bucketed
with the same thresholds the budget path uses (2048 / 8192 / 32768).
"""
if reasoning_effort:
effort = reasoning_effort.lower()
return effort if effort in EFFORT_LEVELS else None
if thinking_budget:
if thinking_budget <= 2048:
return "low"
if thinking_budget <= 8192:
return "medium"
return "high"
return None
4 changes: 4 additions & 0 deletions artemis/llm/reliability.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ class FailureCategory(StrEnum):
CONNECTION = "connection"
AUTHENTICATION = "authentication"
BAD_REQUEST = "bad_request"
#: The model's safety classifier declined the request (Claude
#: ``stop_reason: "refusal"``). Retrying the same endpoint returns the same
#: refusal; a fallback model may answer.
REFUSAL = "refusal"
CANCELLED = "cancelled"
UNKNOWN = "unknown"

Expand Down
49 changes: 49 additions & 0 deletions artemis/llm/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@
from pydantic import BaseModel, Field

from artemis.config.settings import settings
from artemis.llm.claude import (
THINKING_BINDING_BETA,
binds_thinking_blocks,
effort_for,
is_adaptive_only,
)
from artemis.llm.google.provider import supports_thinking_level
from third_party.mobile_use.utils.logger import get_logger

Expand Down Expand Up @@ -195,6 +201,47 @@ def patched_process_tool_config(self, tool_choice, tool_config, formatted_tools)
logger.warning(f"Could not patch ChatGoogleGenerativeAI._process_tool_config: {e}")


_ADAPTIVE_CLAUDE_MAX_TOKENS = 16000


def _adaptive_claude_kwargs(endpoint: ModelEndpoint, kwargs: dict[str, Any]) -> dict[str, Any]:
"""Builds ChatAnthropic kwargs for adaptive-only Claude models (Opus 4.7+, Sonnet 5+).

These models 400 on ``budget_tokens`` and non-default ``temperature``, so
the temperature is dropped and the endpoint's effort / budget becomes
``output_config.effort`` with adaptive thinking.

Models that bind thinking blocks to their history (Sonnet 5.5, Opus 5.5,
Haiku 5.5, Fable 5.1) also get ``prefix_mismatch_behavior: "drop_block"``:
the transcript ledger scrubs old screenshots and UI lists from earlier
turns, which would otherwise make every later replayed thinking block a
400 ("bound to a different conversation"). With ``drop_block`` the API
drops the stale blocks and the request goes through.

``max_tokens`` is set explicitly: thinking counts toward it, and
langchain-anthropic falls back to 4096 for models it has no profile for.
16000 leaves room for thinking and stays under the SDK's non-streaming
limit (structured-output calls do not stream).
"""
kwargs = {k: v for k, v in kwargs.items() if k != "temperature"}
kwargs["max_tokens"] = endpoint.max_tokens or _ADAPTIVE_CLAUDE_MAX_TOKENS
effort = effort_for(endpoint.reasoning_effort, endpoint.thinking_budget)
thinking: dict[str, Any] | None = None
if binds_thinking_blocks(endpoint.model_name):
thinking = {
"type": "adaptive",
"block_binding": {"prefix_mismatch_behavior": "drop_block"},
}
kwargs["betas"] = [THINKING_BINDING_BETA]
elif effort:
thinking = {"type": "adaptive"}
if thinking:
kwargs["thinking"] = thinking
if effort:
kwargs["effort"] = effort
return {k: v for k, v in kwargs.items() if v is not None}


class ModelFactory:
"""Unified factory for instantiating and caching LangChain chat models across providers."""

Expand Down Expand Up @@ -321,6 +368,8 @@ def create_model(cls, endpoint: ModelEndpoint) -> BaseChatModel:
"api_key": api_key,
"timeout": endpoint.timeout_seconds,
}
if is_adaptive_only(endpoint.model_name):
return ChatAnthropic(**_adaptive_claude_kwargs(endpoint, kwargs))
budget = endpoint.thinking_budget
if not budget and endpoint.reasoning_effort:
effort_map = {"low": 2048, "medium": 8192, "high": 32768}
Expand Down
33 changes: 33 additions & 0 deletions artemis/services/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,11 @@
)
from artemis.context import ArtemisContext
from artemis.data_engine.trace import CURRENT_TRACE_ID, DataEngineCallbackHandler
from artemis.llm.claude import is_adaptive_only
from artemis.llm.google import is_google_chat_model, is_google_provider
from artemis.llm.reliability import (
CircuitBreaker,
Failure,
FailureCategory,
LLMCallError,
LLMExhaustedError,
Expand Down Expand Up @@ -594,6 +596,16 @@ def with_config(self, *args, **kwargs):
)

def with_structured_output(self, *args, **kwargs):
# ChatAnthropic defaults to forced tool calling, which adaptive-only
# Claude models (Sonnet 5.5, Opus 5.5, ...) reject with a 400; native
# structured outputs (output_config.format) work on all of them.
if (
"method" not in kwargs
and self.endpoint is not None
and ModelProvider.from_string(self.endpoint.provider) == ModelProvider.ANTHROPIC
and is_adaptive_only(self.endpoint.model_name)
):
kwargs["method"] = "json_schema"
if hasattr(self.base_model, "with_structured_output"):
return RobustChatModelWrapper(
self.base_model.with_structured_output(*args, **kwargs),
Expand Down Expand Up @@ -631,6 +643,25 @@ def _traced_call(self, args: tuple, kwargs: dict) -> tuple[tuple, dict, Any]:
args, kwargs = _inject_parent_trace_id(trace_id, *args, **kwargs)
return args, kwargs, trace_id

@staticmethod
def _raise_on_refusal(response) -> None:
"""Turns a model refusal into a typed error instead of an empty reply.

Claude's safety classifiers decline with HTTP 200, ``stop_reason:
"refusal"`` and no content. Returned as-is, the agent loop sees a turn
without a tool call, nudges and re-sends the same prompt, which is
refused again on every turn. Raising lets the fallback chain try the
next endpoint and otherwise fails the task with the reason.
"""
meta = getattr(response, "response_metadata", None)
if isinstance(meta, dict) and meta.get("stop_reason") == "refusal":
model = meta.get("model_name") or meta.get("model") or "the model"
raise LLMPermanentError(
f"{model} declined the request (stop_reason 'refusal'): its safety"
" classifier flagged the prompt.",
failure=Failure(FailureCategory.REFUSAL, retryable=False, should_fallback=True),
)

def _meter_usage(self, response) -> None:
"""Record measured token usage for this call (M0 metering, best-effort)."""
engine = self.ctx.data_engine if self.ctx else None
Expand All @@ -646,6 +677,7 @@ async def ainvoke(self, *args, **kwargs):
endpoint_key=self._endpoint_key(),
)
self._meter_usage(response)
self._raise_on_refusal(response)
return response

async def complete(self, *args, **kwargs):
Expand All @@ -666,6 +698,7 @@ async def complete(self, *args, **kwargs):
endpoint_key=self._endpoint_key(),
)
self._meter_usage(response)
self._raise_on_refusal(response)
return response

def _emit_stream_delta(self, stream_exec_id, chunk) -> None:
Expand Down
Loading
Loading