feat(anthropic): onboard claude-opus-5

The capabilities row is a copy of claude-opus-4-8 — 1M context, 128K output,
adaptive thinking, the full low..max effort ladder, mid-conversation system
messages. Two of the model's documented breaking changes are unreachable from
this lane and stay that way only while thinking_mode is "adaptive": thinking is
on by default when the param is omitted, and disabling it is a 400 at effort
xhigh or max. We never omit it and never emit "disabled", so both are recorded
at the row rather than defended against.

The third needed work. A safety classifier can decline with
stop_reason="refusal" on a successful HTTP 200, with content either empty or
partial, and an unmapped value fell through _normalize_finish_reason as a
literal string. The drain gate only raises on an ABSENT finish reason, so a
declined turn landed as a complete result with nothing to notice it: the
interactive lane matched neither of its two warn arms and stayed silent, and a
sub-agent handed the declined partial up to its parent as though it were
finished synthesis. Normalizing onto content_filter routes the decline into the
arms both lanes already have for that state — the operator gets the warning,
and the sub-agent stops rather than passing the fragment on.

The raw stop reason is logged where it is still in hand: normalization is lossy
and a classifier decline is otherwise indistinguishable from an ordinary
content filter. The gate is on the RAW value rather than
(normalized != raw), which is true for end_turn and tool_use as well and would
fire on every turn in every lane.

The anthropic floor moves to 0.117 to track the release current at onboarding.
The model needs no new SDK surface — ids are opaque strings and "refusal" has
been in the StopReason literal since ~0.95 — so this is hygiene; raise it again
when adopting fast mode, server-side fallbacks, advisor, or mid-conversation
tool changes, which do need newer typed params.
This commit is contained in:
Patrick Buckley
2026-07-24 21:26:13 -07:00
parent 04c29c8ef5
commit 96834496c4
7 changed files with 196 additions and 7 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ classifiers = [
]
dependencies = [
"openai>=2.45", # GPT-5.6: typed reasoning.mode, prompt_cache_options, and cache_write_tokens
"anthropic>=0.108", # claude-fable-5 support; hard runtime floor is 0.105 (mid-conversation system blocks)
"anthropic>=0.117", # tracks the release current at claude-opus-5 onboarding; hard runtime floor is still 0.105 (mid-conversation system blocks) — Opus 5 itself needs no new SDK surface (model ids are opaque strings; "refusal" has been in the StopReason literal since ~0.95). Raise this when adopting fast mode / server-side fallbacks / advisor / mid-conversation tool changes, which DO need newer typed params.
"httpx>=0.28",
"mcp>=1.27,<2", # v2 is a breaking rewrite (2.0.0a1 live 2026-06-11; stable ~2026-07-27) — streamablehttp_client removed, 2-tuple transport, snake_case types; migrate deliberately
"starlette>=1.3.1", # CVE-2026-54282 (path->authority host spoof) + CVE-2026-54283 (url-encoded form DoS); supersedes the PYSEC-2026-161 host-header path-injection floor
+125
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import json
import logging
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock, PropertyMock, patch
@@ -1353,6 +1354,71 @@ class TestAnthropicProvider:
assert _normalize_finish_reason("max_tokens") == "length"
assert _normalize_finish_reason("other_reason") == "other_reason"
def test_refusal_normalized_to_content_filter(self) -> None:
"""Safety-classifier declines (Opus 5 / Fable 5) arrive as a 200 with
stop_reason="refusal". It must NOT fall through as the raw string: the
drain gate only errors on an ABSENT finish reason, so an unmapped
"refusal" lands a declined turn as a complete result. content_filter
is the OpenAI-vocabulary equivalent both consumers already handle."""
from turnstone.core.providers._anthropic import _normalize_finish_reason
assert _normalize_finish_reason("refusal") == "content_filter"
@patch("turnstone.core.providers._anthropic._ensure_anthropic")
def test_refusal_logs_the_providers_own_word(self, mock_ensure: MagicMock, caplog) -> None:
"""Normalization is lossy, so this log is the only site that still holds
the raw stop reason a classifier decline is indistinguishable from an
ordinary content filter once collapsed onto ``content_filter``."""
events = [
_anthropic_event("content_block_delta", delta_type="text_delta", text="partial"),
_anthropic_event("message_delta", stop_reason="refusal"),
]
stream_ctx = MagicMock()
stream_ctx.__enter__ = MagicMock(return_value=iter(events))
stream_ctx.__exit__ = MagicMock(return_value=False)
client = MagicMock()
client.messages.stream.return_value = stream_ctx
with caplog.at_level(logging.INFO, logger="turnstone.core.providers._anthropic"):
list(
self.provider.create_streaming(
client=client,
model="claude-opus-5",
messages=[{"role": "user", "content": "go"}],
)
)
assert any("anthropic.refusal" in r.message for r in caplog.records)
@patch("turnstone.core.providers._anthropic._ensure_anthropic")
def test_ordinary_turn_logs_no_refusal(self, mock_ensure: MagicMock, caplog) -> None:
"""Non-vacuity, and the defect the RAW-value gate exists to prevent.
Normalization rewrites ``end_turn`` and ``tool_use`` as well, so gating
the log on ``normalized != raw`` fires on every ordinary turn in every
lane rather than only on a decline.
"""
events = [
_anthropic_event("content_block_delta", delta_type="text_delta", text="hello"),
_anthropic_event("message_delta", stop_reason="end_turn"),
]
stream_ctx = MagicMock()
stream_ctx.__enter__ = MagicMock(return_value=iter(events))
stream_ctx.__exit__ = MagicMock(return_value=False)
client = MagicMock()
client.messages.stream.return_value = stream_ctx
with caplog.at_level(logging.INFO, logger="turnstone.core.providers._anthropic"):
list(
self.provider.create_streaming(
client=client,
model="claude-opus-5",
messages=[{"role": "user", "content": "go"}],
)
)
assert not any("anthropic.refusal" in r.message for r in caplog.records)
@patch("turnstone.core.providers._anthropic._ensure_anthropic")
def test_drained_stream_basic(self, mock_ensure: MagicMock) -> None:
client = MagicMock()
@@ -1919,6 +1985,39 @@ class TestAnthropicHelpers:
assert caps.thinking_display == "summarized"
assert caps.supports_mid_conversation_system is True
def test_capabilities_opus_5(self) -> None:
from turnstone.core.providers._anthropic import AnthropicProvider
provider = AnthropicProvider()
caps = provider.get_capabilities("claude-opus-5")
assert caps.context_window == 1000000
assert caps.max_output_tokens == 128000
assert caps.thinking_mode == "adaptive"
assert caps.supports_effort is True
assert "xhigh" in caps.effort_levels
assert "max" in caps.effort_levels
assert caps.supports_temperature is False
assert caps.thinking_display == "summarized"
assert caps.supports_web_search is True
assert caps.supports_tool_search is True
assert caps.supports_vision is True
assert caps.supports_pdf is True
assert caps.supports_reasoning_replay is True
assert caps.supports_mid_conversation_system is True
def test_capabilities_opus_5_dated(self) -> None:
"""A dated snapshot resolves via longest-prefix match, and must NOT
fall back to _ANTHROPIC_DEFAULT (which has no effort support)."""
from turnstone.core.providers._anthropic import AnthropicProvider
provider = AnthropicProvider()
caps = provider.get_capabilities("claude-opus-5-20260724")
assert caps.context_window == 1000000
assert caps.supports_temperature is False
assert caps.thinking_display == "summarized"
assert caps.supports_effort is True
assert caps.supports_mid_conversation_system is True
def test_capabilities_opus_4_8(self) -> None:
from turnstone.core.providers._anthropic import AnthropicProvider
@@ -4480,6 +4579,32 @@ class TestAnthropicPromptCaching:
)
assert kwargs["output_config"] == {"effort": "xhigh"}
def test_opus_5_max_effort_still_sends_explicit_adaptive_thinking(self) -> None:
"""Opus 5's two breaking changes are unreachable ONLY because this lane
always writes thinking explicitly. Observe the artefact directly: at
effort=max the payload must carry an explicit adaptive thinking dict
(never omitted -> the changed on-by-default default cannot bite) and
must never carry type="disabled" (which 400s at xhigh/max). If a
future edit adds a disabled branch, this fails instead of shipping a
400 to production."""
caps = self.provider.get_capabilities("claude-opus-5")
kwargs = self.provider._build_thinking_and_kwargs(
caps=caps,
reasoning_effort="max",
extra_params=None,
max_tokens=8192,
temperature=0.5,
converted_msgs=[{"role": "user", "content": "hi"}],
system_prompt="",
model="claude-opus-5",
tools=None,
)
assert kwargs["thinking"] == {"type": "adaptive", "display": "summarized"}
assert kwargs["output_config"] == {"effort": "max"}
# Sampling params are a 400 on this model — the row declares
# supports_temperature=False, so temperature must not reach the wire.
assert "temperature" not in kwargs
def test_xhigh_effort_snaps_to_max_on_opus_4_6(self) -> None:
"""Opus 4.6 declares (low, medium, high, max) — a knob of xhigh
rounds up to max instead of silently dropping output_config."""
+64 -1
View File
@@ -158,6 +158,36 @@ _ANTHROPIC_CAPABILITIES: dict[str, ModelCapabilities] = {
supports_reasoning_replay=True,
supports_mid_conversation_system=True,
),
# Opus 5: same wire surface as opus-4-8. Two of this model's documented
# breaking changes are unreachable from this lane, and stay that way only
# while thinking_mode is "adaptive":
# * thinking is ON by default when the param is omitted (opus-4-8 omitted
# meant OFF) — we never omit it, the adaptive branch in
# _build_thinking_and_kwargs always writes an explicit {"type":
# "adaptive"}, so the changed default cannot reach us.
# * thinking={"type": "disabled"} is a 400 at effort xhigh/max — that
# branch does not exist here (same reasoning as the fable-5 row above).
# Adding a disabled-thinking branch to this lane re-opens both; gate it on
# effort <= high if that ever happens.
# This model's safety classifiers can also decline a request with
# stop_reason="refusal" on an HTTP 200 — normalized in
# _normalize_finish_reason, see the note there.
"claude-opus-5": ModelCapabilities(
context_window=1000000,
max_output_tokens=128000,
token_param="max_tokens",
thinking_mode="adaptive",
supports_effort=True,
effort_levels=("low", "medium", "high", "xhigh", "max"),
supports_web_search=True,
supports_tool_search=True,
supports_vision=True,
supports_pdf=True,
supports_temperature=False,
thinking_display="summarized",
supports_reasoning_replay=True,
supports_mid_conversation_system=True,
),
"claude-opus-4-8": ModelCapabilities(
context_window=1000000,
max_output_tokens=128000,
@@ -1121,7 +1151,28 @@ class AnthropicProvider:
cache_read_tokens=cr,
)
if hasattr(event.delta, "stop_reason") and event.delta.stop_reason:
sc.finish_reason = _normalize_finish_reason(event.delta.stop_reason)
raw_stop = event.delta.stop_reason
sc.finish_reason = _normalize_finish_reason(raw_stop)
if raw_stop == "refusal":
# Normalization is lossy and this is the only site that
# still holds the provider's own word: a safety-classifier
# decline is indistinguishable from an ordinary content
# filter once collapsed onto "content_filter". Record it
# here rather than plumb a raw field through StreamChunk
# for a consumer that does not exist yet. Gate on the RAW
# value, not on (normalized != raw) — normalization
# rewrites end_turn and tool_use too, so an inequality
# test fires on every ordinary turn in every lane.
# ``stop_details`` rides the same event and is populated
# exactly when the stop reason is a refusal; its
# ``category`` is the one distinction an operator acts
# on. ``getattr`` keeps this floor-safe on SDKs that
# predate the field.
details = getattr(event.delta, "stop_details", None)
log.info(
"anthropic.refusal: classifier declined the turn (category=%s)",
getattr(details, "category", None),
)
emitted_finish = True
# Emit all raw content blocks for multi-turn preservation
_attach_terminal_blocks(sc)
@@ -1234,6 +1285,18 @@ def _normalize_finish_reason(reason: str) -> str:
if reason == "pause_turn":
# Server-side tool (web search) paused a long turn; treat as stop
return "stop"
if reason == "refusal":
# A safety classifier declined the request. This arrives as a
# SUCCESSFUL HTTP 200 with content empty (declined before any output)
# or partial (declined mid-stream), so nothing upstream raises — the
# drain gate only errors on an ABSENT finish reason. "content_filter"
# is the
# OpenAI-vocabulary equivalent this function normalizes onto, and both
# consumers already handle it: ChatSession._stream_response warns the
# user, and the sub-agent loop stops early instead of flailing on an
# empty turn. Falling through to the raw "refusal" string instead
# would land a truncated answer as a complete result.
return "content_filter"
return reason
+1 -1
View File
@@ -364,7 +364,7 @@ class ModelCapabilities:
# invalidating the cached prefix. When False, ``system``-role messages must
# be hoisted into the top-level ``system`` param (the universal fallback).
# Available on the Claude API only (NOT Bedrock / Vertex / Foundry), on
# claude-opus-4-8 (validated header-less) and claude-fable-5 (same
# every row that sets this flag (validated header-less on claude-opus-4-8;
# documented wire surface); no beta header required.
supports_mid_conversation_system: bool = False
# Phase 3 reranker calibration — populated by calibrate-on-detect; read by
+1 -1
View File
@@ -4809,7 +4809,7 @@ class ChatSession:
:func:`turnstone.core.lowering.fold_system_turns`: non-native
models get each turn wrapped as a nonce-delimited
``[start system-reminder]`` block on the preceding turn; native
mid-conversation-system models (claude-opus-4-8, claude-fable-5)
mid-conversation-system models (rows with the capability flag)
keep them inline for the Anthropic converter to emit as real
``system`` messages.
+3 -2
View File
@@ -99,7 +99,8 @@ def render_user_interjection(message: str, priority: str) -> str:
# ``{"role": "system", "_source": <kind>, "content": ...}`` messages rather
# than spliced into a neighbouring turn's ``content``. At the wire boundary a
# system turn is either kept inline (native mid-conversation system messages —
# claude-opus-4-8, claude-fable-5) or folded into the preceding turn as a
# any model whose capability row sets supports_mid_conversation_system) or
# folded into the preceding turn as a
# ``[start system-reminder]`` block (every other model). ``_source`` classifies the turn for UI rendering
# and replay; it rides the persisted ``_source`` column and is stripped before
# the LLM wire by ``sanitize_messages``. See ``ChatSession`` for the producers
@@ -155,7 +156,7 @@ def make_system_turn(source: str, content: str, **meta: Any) -> dict[str, Any]:
structured fields never reach the model.
``content`` is stored and — on the native mid-conversation-system path
(claude-opus-4-8, claude-fable-5) — sent to the model verbatim, so
(any row with supports_mid_conversation_system) — sent verbatim, so
fence-escaping is NOT
done here. It belongs to the fallback fold step, which wraps the content
in a nonce-delimited ``[start system-reminder_{nonce}]`` fence via
Generated
+1 -1
View File
@@ -2535,7 +2535,7 @@ requires-dist = [
{ name = "aiohttp", marker = "extra == 'test'", specifier = ">=3.9" },
{ name = "alembic", specifier = ">=1.14" },
{ name = "altair", specifier = ">=6.0" },
{ name = "anthropic", specifier = ">=0.108" },
{ name = "anthropic", specifier = ">=0.117" },
{ name = "bcrypt", specifier = ">=4.0" },
{ name = "croniter", specifier = ">=3.0" },
{ name = "cryptography", specifier = ">=48.0.1" },