diff --git a/src/models/configs.py b/src/models/configs.py index 3a9f1edb..dc024eea 100644 --- a/src/models/configs.py +++ b/src/models/configs.py @@ -51,6 +51,44 @@ class ModelConfig: cost_cache_read_per_mtok=1.50, ), + # Claude Opus 4.8 / Fable 5 (current frontier — 1M context window, + # 128K true output cap). Placed AFTER the legacy claude-opus-4-20250514 + # row on purpose: ``get_model_config``'s prefix fallback iterates in + # insertion order and both opus keys share the ``claude-opus-4`` base, + # so bare 4.x ids without an exact entry (opus-4-1/4-5/4-6/4-7 and + # dated snapshots) keep resolving to the legacy 200K row — under- + # estimating the window compacts early, the safe direction (see the + # GPT-5 note below). Register future dated 4-8 snapshots explicitly. + # + # max_output_tokens is the FIRST-ATTEMPT wire ``max_tokens`` for + # Anthropic providers (``resolve_max_output_tokens`` step 3), not the + # model's capability ceiling: 32_000 keeps the query loop's 64K + # truncation-escalation (``ESCALATED_MAX_TOKENS``) meaningful. + "claude-opus-4-8": ModelConfig( + model_id="claude-opus-4-8", + display_name="Claude Opus 4.8", + context_window=1_000_000, + max_output_tokens=32_000, + supports_thinking=True, + supports_computer_use=True, + cost_input_per_mtok=5.0, + cost_output_per_mtok=25.0, + cost_cache_create_per_mtok=6.25, + cost_cache_read_per_mtok=0.50, + ), + "claude-fable-5": ModelConfig( + model_id="claude-fable-5", + display_name="Claude Fable 5", + context_window=1_000_000, + max_output_tokens=32_000, + supports_thinking=True, + supports_computer_use=True, + cost_input_per_mtok=10.0, + cost_output_per_mtok=50.0, + cost_cache_create_per_mtok=12.50, + cost_cache_read_per_mtok=1.00, + ), + # Claude 3.7 series "claude-3-7-sonnet-20250219": ModelConfig( model_id="claude-3-7-sonnet-20250219", diff --git a/src/providers/__init__.py b/src/providers/__init__.py index 3e9811d4..e6dee752 100644 --- a/src/providers/__init__.py +++ b/src/providers/__init__.py @@ -21,12 +21,15 @@ class ProviderInfo(TypedDict): "default_base_url": "https://api.anthropic.com", "default_model": "claude-sonnet-4-6", "available_models": [ + # Frontier (above Opus tier) + "claude-fable-5", # Claude 4 series (latest) "claude-sonnet-4-6", "claude-sonnet-4-5", "claude-sonnet-4-5-20250929", "claude-sonnet-4-0", "claude-sonnet-4-20250514", + "claude-opus-4-8", "claude-opus-4-6", "claude-opus-4-5", "claude-opus-4-5-20251101", diff --git a/src/providers/anthropic_provider.py b/src/providers/anthropic_provider.py index afe52ed4..13ddad5f 100644 --- a/src/providers/anthropic_provider.py +++ b/src/providers/anthropic_provider.py @@ -133,9 +133,11 @@ def _extract_usage_dict(usage: Any) -> dict[str, Any]: # Older Claude 3.x snapshots cap at 4K-8K depending on tier; pulling the # default up to 32K would make the API reject those requests with a 400. # Detection is by model-name pattern so newer 4.x point releases -# (e.g. ``claude-opus-4-7-20260201``) opt in automatically. +# (e.g. ``claude-opus-4-7-20260201``) opt in automatically. ``fable`` +# covers the Claude 5 frontier family (claude-fable-5), which has a +# 128K output ceiling — well above the 32K default used here. _LARGE_MAX_TOKENS_MODEL_PATTERN = re.compile( - r"claude-(?:sonnet|opus|haiku)-(?:4-\d+|[5-9]\b|\d{2,})", + r"claude-(?:sonnet|opus|haiku|fable)-(?:4-\d+|[5-9]\b|\d{2,})", re.IGNORECASE, ) @@ -774,12 +776,15 @@ def get_available_models(self) -> list[str]: List of model names """ return [ + # Frontier (above Opus tier) + "claude-fable-5", # Claude 4 series (latest) "claude-sonnet-4-6", "claude-sonnet-4-5", "claude-sonnet-4-5-20250929", "claude-sonnet-4-0", "claude-sonnet-4-20250514", + "claude-opus-4-8", "claude-opus-4-6", "claude-opus-4-5", "claude-opus-4-5-20251101", diff --git a/src/query/query.py b/src/query/query.py index 48adb68b..e96a5fa5 100644 --- a/src/query/query.py +++ b/src/query/query.py @@ -417,7 +417,7 @@ def _is_hook_stopped_continuation(msg: Message | None) -> bool: _THINKING_ELIGIBLE_MODEL_PATTERN = re.compile( - r"claude-(?:sonnet|opus|haiku)-(?:4-\d+|[5-9]\b|\d{2,})", + r"claude-(?:sonnet|opus|haiku|fable)-(?:4-\d+|[5-9]\b|\d{2,})", re.IGNORECASE, ) @@ -448,29 +448,45 @@ def _model_supports_adaptive_thinking(model: str | None) -> bool: Only a subset of Claude 4 models accept ``thinking={"type": "adaptive"}``; the rest support thinking only with an explicit ``budget_tokens``. Sending adaptive to a non-adaptive model is rejected with HTTP 400 "adaptive - thinking is not supported on this model". Allowlist ported verbatim from - TS ``modelSupportsAdaptiveThinking`` (thinking.ts:152-169): Opus 4.6/4.7 - and Sonnet 4.6. Substring match mirrors the reference's ``.includes()`` - so dated snapshots (``claude-sonnet-4-6-20250929``) match. + thinking is not supported on this model". Allowlist ported from TS + ``modelSupportsAdaptiveThinking`` (thinking.ts:152-169): Opus 4.6/4.7 + and Sonnet 4.6; extended with Opus 4.8 and Fable 5, where adaptive is + the ONLY accepted thinking config — the budget fallback below would be + a hard 400 on them (``budget_tokens`` is removed on 4.7+; Fable 5 also + rejects ``{"type": "disabled"}``, and accepts adaptive or an omitted + param). Substring match mirrors the reference's ``.includes()`` so + dated snapshots (``claude-sonnet-4-6-20250929``) match. """ if not model: return False m = model.lower() - return "opus-4-7" in m or "opus-4-6" in m or "sonnet-4-6" in m + return ( + "fable-5" in m + or "opus-4-8" in m + or "opus-4-7" in m + or "opus-4-6" in m + or "sonnet-4-6" in m + ) def _model_supports_effort(model: str | None) -> bool: """True iff the model accepts ``output_config={"effort": ...}``. - Narrower than thinking support — Opus 4.6 and Sonnet 4.6 only (TS - ``modelSupportsEffort``, effort.ts:32-51). Sending effort to a model that - doesn't support it is rejected, so the caller gates on this independently - of the thinking type. + Narrower than thinking support — Opus 4.6/4.8, Sonnet 4.6, and Fable 5 + (TS ``modelSupportsEffort``, effort.ts:32-51, plus the 4.8/Fable + additions where effort is GA). Sending effort to a model that doesn't + support it is rejected, so the caller gates on this independently of + the thinking type. """ if not model: return False m = model.lower() - return "opus-4-6" in m or "sonnet-4-6" in m + return ( + "fable-5" in m + or "opus-4-8" in m + or "opus-4-6" in m + or "sonnet-4-6" in m + ) def _is_overloaded_error(e: Exception) -> bool: diff --git a/src/services/pricing.py b/src/services/pricing.py index 607c187d..1c773ec4 100644 --- a/src/services/pricing.py +++ b/src/services/pricing.py @@ -41,6 +41,12 @@ "cache_creation": 6.25 / 1_000_000, "cache_read": 0.50 / 1_000_000, } +_TIER_10_50 = { + "input": 10.0 / 1_000_000, + "output": 50.0 / 1_000_000, + "cache_creation": 12.50 / 1_000_000, + "cache_read": 1.00 / 1_000_000, +} _TIER_HAIKU_45 = { "input": 1.0 / 1_000_000, "output": 5.0 / 1_000_000, @@ -127,7 +133,10 @@ "claude-3-7-sonnet-20250219": _TIER_3_15, "claude-3-5-sonnet-20241022": _TIER_3_15, "claude-3-5-sonnet-20240620": _TIER_3_15, + # Fable family — frontier tier above Opus (10/50) + "claude-fable-5": _TIER_10_50, # Opus family — 4.5+ on the 5/25 tier, 4.0/4.1 on 15/75 + "claude-opus-4-8": _TIER_5_25, "claude-opus-4-7": _TIER_5_25, "claude-opus-4-6": _TIER_5_25, "claude-opus-4-5": _TIER_5_25, @@ -160,9 +169,11 @@ # tier. Unknown opus-4.x now falls through to None instead of being # tagged with the wrong price. _FAMILY_PREFIXES: list[tuple[str, dict[str, float]]] = [ + ("claude-fable-5", _TIER_10_50), ("claude-haiku-4", _TIER_HAIKU_45), ("claude-3-5-haiku", _TIER_HAIKU_45), ("claude-3-haiku", _TIER_HAIKU_3), + ("claude-opus-4-8", _TIER_5_25), ("claude-opus-4-7", _TIER_5_25), ("claude-opus-4-6", _TIER_5_25), ("claude-opus-4-5", _TIER_5_25),