feat(providers): add Claude Fable 5 to the Anthropic provider

- claude-fable-5 capability entry: 1M context / 128K output, adaptive
  thinking (summarized display), effort low..max incl. xhigh, no
  sampling params, web + tool search, vision, reasoning replay, native
  mid-conversation system messages
- document the Fable 5 wire quirk at the capability table: an explicit
  thinking={"type": "disabled"} is a 400 on this model; the adaptive
  branch never emits "disabled", so adaptive-or-omitted is preserved
- widen the native mid-conversation-system comments from opus-4-8-only
  to opus-4-8 + fable-5 (protocol, provider, tool_advisory, prompts,
  session)
- raise the anthropic SDK floor 0.39 -> 0.108: 0.39 predates every
  named kwarg the provider sends (output_config 0.77, top-level
  cache_control 0.83, mid-conversation system blocks 0.105); 0.108
  adds claude-fable-5
- tests: capability assertions for claude-fable-5 + dated-variant
  prefix match
This commit is contained in:
Patrick Buckley
2026-06-09 10:45:11 -07:00
parent ab6d95da24
commit 114ada791b
7 changed files with 72 additions and 17 deletions
+1 -1
View File
@@ -23,7 +23,7 @@ classifiers = [
]
dependencies = [
"openai>=2.37",
"anthropic>=0.39",
"anthropic>=0.108", # claude-fable-5 support; hard runtime floor is 0.105 (mid-conversation system blocks)
"httpx>=0.28",
"mcp>=1.27",
"starlette>=1.0.1", # PYSEC-2026-161: host-header path-injection in URL reconstruction (auth-bypass on apps comparing reconstructed URL paths)
+29
View File
@@ -1407,6 +1407,35 @@ class TestAnthropicHelpers:
assert caps.token_param == "max_tokens"
assert caps.thinking_mode == "adaptive"
def test_capabilities_fable_5(self) -> None:
from turnstone.core.providers._anthropic import AnthropicProvider
provider = AnthropicProvider()
caps = provider.get_capabilities("claude-fable-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_reasoning_replay is True
assert caps.supports_mid_conversation_system is True
def test_capabilities_fable_5_dated(self) -> None:
from turnstone.core.providers._anthropic import AnthropicProvider
provider = AnthropicProvider()
caps = provider.get_capabilities("claude-fable-5-20260815")
assert caps.context_window == 1000000
assert caps.supports_temperature is False
assert caps.thinking_display == "summarized"
assert caps.supports_mid_conversation_system is True
def test_capabilities_opus_4_8(self) -> None:
from turnstone.core.providers._anthropic import AnthropicProvider
+23 -1
View File
@@ -84,6 +84,27 @@ _ANTHROPIC_DEFAULT = ModelCapabilities(
)
_ANTHROPIC_CAPABILITIES: dict[str, ModelCapabilities] = {
# Fable 5: same wire surface as opus-4-8 (adaptive-only thinking, no
# sampling params, prefill rejected) with one extra constraint — an
# explicit thinking={"type": "disabled"} is a 400 on this model; thinking
# must be adaptive or the param omitted entirely. The adaptive branch in
# _build_thinking_and_kwargs never emits "disabled", so this is safe as
# long as thinking_mode stays "adaptive".
"claude-fable-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_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,
@@ -361,7 +382,8 @@ class AnthropicProvider:
(``ChatSession._try_stream`` / ``_utility_completion``) always
pass the resolved flag explicitly.
``supports_mid_conversation_system`` (claude-opus-4-8) makes the
``supports_mid_conversation_system`` (claude-opus-4-8,
claude-fable-5) makes the
system-role handling position-aware: leading system/developer
messages (the base prompt) still hoist into the top-level
``system`` param, but a system message appearing AFTER a
+2 -1
View File
@@ -116,7 +116,8 @@ 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
# NextOpus (claude-opus-4-8) only; no beta header required.
# claude-opus-4-8 (validated header-less) and claude-fable-5 (same
# documented wire surface); no beta header required.
supports_mid_conversation_system: bool = False
# Phase 3 reranker calibration — populated by calibrate-on-detect; read by
# ChatSession._bm25_rerank_threshold. A non-empty rerank_scale is the
+7 -5
View File
@@ -2734,9 +2734,10 @@ class ChatSession:
else None
)
# Operator-instruction trust anchor — declared only on the fold path.
# The native mid-conversation-system path (claude-opus-4-8) delivers
# operator turns as real {"role":"system"} messages with no fence, so
# no <system-reminder_{nonce}> marker appears and no declaration applies.
# The native mid-conversation-system path (claude-opus-4-8,
# claude-fable-5) delivers operator turns as real {"role":"system"}
# messages with no fence, so no <system-reminder_{nonce}> marker
# appears and no declaration applies.
if caps is not None and not caps.supports_mid_conversation_system:
dev_parts.append("\n\n" + build_operator_instruction_declaration(self._envelope_nonce))
# Tool search hint (client-side mode only — native mode needs no hint).
@@ -2932,8 +2933,9 @@ class ChatSession:
:func:`turnstone.core.lowering.fold_system_turns`: non-native
models get each turn wrapped as a nonce-delimited
``<system-reminder>`` block on the preceding turn; native
mid-conversation-system models (claude-opus-4-8) keep them inline
for the Anthropic converter to emit as real ``system`` messages.
mid-conversation-system models (claude-opus-4-8, claude-fable-5)
keep them inline for the Anthropic converter to emit as real
``system`` messages.
Messages without a foldable system turn pass through unchanged
(same object reference) so the common case is allocation-free.
+7 -6
View File
@@ -4,9 +4,9 @@ Operator-level context injected mid-session (output-guard findings, user
interjections, metacognitive nudges, skill hints) lives in the conversation
trajectory as first-class ``{"role": "system", "_source": <kind>, "content":
...}`` turns (see :func:`make_system_turn`). At the wire boundary each turn is
either kept inline (native mid-conversation system messages — claude-opus-4-8)
or folded into the preceding turn as a nonce-delimited ``<system-reminder_
{nonce}>`` fence for every other model. The fence mechanism (mint / neutralise
either kept inline (native mid-conversation system messages — claude-opus-4-8,
claude-fable-5) or folded into the preceding turn as a nonce-delimited
``<system-reminder_{nonce}>`` fence for every other model. The fence mechanism (mint / neutralise
/ wrap) lives in :mod:`turnstone.core.fence`, shared with the output-guard judge
so the two trust boundaries cannot drift; ``lowering.fold_system_turns``
applies it.
@@ -99,8 +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) or folded into the preceding turn as a ``<system-reminder>``
# block (every other model). ``_source`` classifies the turn for UI rendering
# claude-opus-4-8, claude-fable-5) or folded into the preceding turn as a
# ``<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
# and the fold-or-keep pass.
@@ -149,7 +149,8 @@ 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) — sent to the model verbatim, so fence-escaping is NOT
(claude-opus-4-8, claude-fable-5) — sent to the model verbatim, so
fence-escaping is NOT
done here. It belongs to the fallback fold step, which wraps the content
in a nonce-delimited ``<system-reminder_{nonce}>`` fence via
:func:`turnstone.core.fence.wrap` (applied in
+3 -3
View File
@@ -92,9 +92,9 @@ def build_operator_instruction_declaration(nonce: str) -> str:
model trusts only ``<system-reminder_<nonce>>`` blocks and treats every
other ``<system-reminder>``-style marker (e.g. one forged in tool output,
files, or web pages) as untrusted data. Emitted only when the model uses
the fold path — the native mid-conversation-system path (claude-opus-4-8)
delivers operator turns as real ``{"role":"system"}`` messages with no
fence, so no marker appears.
the fold path — the native mid-conversation-system path (claude-opus-4-8,
claude-fable-5) delivers operator turns as real ``{"role":"system"}``
messages with no fence, so no marker appears.
"""
return (
"## Operator instructions\n"