diff --git a/pyproject.toml b/pyproject.toml index 41fc0dd1..e0a2e493 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 diff --git a/tests/test_providers.py b/tests/test_providers.py index d68327c0..05978175 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -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.""" diff --git a/turnstone/core/providers/_anthropic.py b/turnstone/core/providers/_anthropic.py index 1f6b39cf..31d88497 100644 --- a/turnstone/core/providers/_anthropic.py +++ b/turnstone/core/providers/_anthropic.py @@ -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 diff --git a/turnstone/core/providers/_protocol.py b/turnstone/core/providers/_protocol.py index 0c2d89a2..32855bd9 100644 --- a/turnstone/core/providers/_protocol.py +++ b/turnstone/core/providers/_protocol.py @@ -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 diff --git a/turnstone/core/session.py b/turnstone/core/session.py index 0541e3ff..b0e21a2b 100644 --- a/turnstone/core/session.py +++ b/turnstone/core/session.py @@ -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. diff --git a/turnstone/core/tool_advisory.py b/turnstone/core/tool_advisory.py index 18006ed8..3cb6da79 100644 --- a/turnstone/core/tool_advisory.py +++ b/turnstone/core/tool_advisory.py @@ -99,7 +99,8 @@ def render_user_interjection(message: str, priority: str) -> str: # ``{"role": "system", "_source": , "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 diff --git a/uv.lock b/uv.lock index ad863c54..fbeccf04 100644 --- a/uv.lock +++ b/uv.lock @@ -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" },