diff --git a/dev-notes/claude-opus-5-model-support.md b/dev-notes/claude-opus-5-model-support.md new file mode 100644 index 000000000..f226b3e44 --- /dev/null +++ b/dev-notes/claude-opus-5-model-support.md @@ -0,0 +1,48 @@ +# Claude Opus 5 model support + +## What changed + +Tau's direct Anthropic catalog now includes `claude-opus-5` with metadata from +Anthropic's July 24, 2026 launch documentation: + +- 1,000,000-token context window +- 128,000-token maximum output +- text and image input +- $5 / million input tokens and $25 / million output tokens +- adaptive thinking with `low`, `medium`, `high`, `xhigh`, and `max` effort + +Tau retains `claude-sonnet-4-6` as Anthropic's default model. Users opt into +Opus 5 through `/model` or `--provider anthropic -m claude-opus-5`. + +## Thinking compatibility + +Tau has six UI levels while Opus 5 has five enabled-thinking effort levels plus +a disabled mode. `minimal` is unavailable for this model. Tau maps `xhigh` to +Anthropic's `max` wire value because both represent Tau's top reasoning tier. +`off` now sends `thinking: {"type": "disabled"}` explicitly; omitting the field +would not work because Opus 5 enables adaptive thinking by default. + +## Architecture + +This remains a catalog and provider-adapter change: + +- `tau_coding` owns model discovery, metadata, and thinking-level mapping. +- `tau_ai` serializes the resulting Anthropic Messages API request. +- `tau_agent` remains provider-independent. + +## Verification + +Run: + +```bash +uv run pytest tests/test_provider_catalog.py tests/test_provider_config.py tests/test_tau_ai.py +uv run ruff check . +uv run ruff format --check . +uv run mypy +``` + +Official sources: + +- https://platform.claude.com/docs/en/about-claude/models/whats-new-opus-5 +- https://platform.claude.com/docs/en/about-claude/models/overview +- https://platform.claude.com/docs/en/release-notes/overview diff --git a/src/tau_ai/anthropic.py b/src/tau_ai/anthropic.py index c385bb738..338ab1f70 100644 --- a/src/tau_ai/anthropic.py +++ b/src/tau_ai/anthropic.py @@ -362,7 +362,9 @@ def _build_messages_payload( ), "messages": [_anthropic_message(message) for message in messages], } - if thinking_mode == "adaptive" and thinking_effort is not None: + if thinking_mode == "disabled": + payload["thinking"] = {"type": "disabled"} + elif thinking_mode == "adaptive" and thinking_effort is not None: payload["thinking"] = {"type": "adaptive", "display": "summarized"} payload["output_config"] = {"effort": thinking_effort} elif thinking_budget_tokens is not None: diff --git a/src/tau_coding/data/catalog.toml b/src/tau_coding/data/catalog.toml index b75b7aab9..6eeae8fee 100644 --- a/src/tau_coding/data/catalog.toml +++ b/src/tau_coding/data/catalog.toml @@ -518,7 +518,7 @@ base_url = "https://api.anthropic.com" api_key_env = "ANTHROPIC_API_KEY" credential_name = "anthropic" auth_methods = ["api_key", "oauth"] -models = ["claude-fable-5", "claude-haiku-4-5", "claude-haiku-4-5-20251001", "claude-opus-4-1", "claude-opus-4-1-20250805", "claude-opus-4-5", "claude-opus-4-5-20251101", "claude-opus-4-6", "claude-opus-4-7", "claude-sonnet-4-5", "claude-sonnet-4-5-20250929", "claude-sonnet-4-6", "claude-sonnet-5"] +models = ["claude-fable-5", "claude-haiku-4-5", "claude-haiku-4-5-20251001", "claude-opus-4-1", "claude-opus-4-1-20250805", "claude-opus-4-5", "claude-opus-4-5-20251101", "claude-opus-4-6", "claude-opus-4-7", "claude-opus-5", "claude-sonnet-4-5", "claude-sonnet-4-5-20250929", "claude-sonnet-4-6", "claude-sonnet-5"] default_model = "claude-sonnet-4-6" docs_url = "https://docs.anthropic.com" api = "anthropic-messages" @@ -536,6 +536,7 @@ claude-opus-4-5 = 200000 claude-opus-4-5-20251101 = 200000 claude-opus-4-6 = 1000000 claude-opus-4-7 = 1000000 +claude-opus-5 = 1000000 claude-sonnet-4-5 = 200000 claude-sonnet-4-5-20250929 = 200000 claude-sonnet-4-6 = 1000000 @@ -621,6 +622,17 @@ cost = { input = 5, output = 25, cacheRead = 0.5, cacheWrite = 6.25 } thinking_level_map = { xhigh = "xhigh" } unsupported_thinking_levels = ["minimal"] +[providers.model_metadata.claude-opus-5] +name = "Claude Opus 5" +reasoning = true +input = ["text", "image"] +context_window = 1000000 +max_tokens = 128000 +compat = { forceAdaptiveThinking = true } +cost = { input = 5, output = 25, cacheRead = 0.5, cacheWrite = 6.25 } +thinking_level_map = { xhigh = "max" } +unsupported_thinking_levels = ["minimal"] + [providers.model_metadata.claude-sonnet-4-5] name = "Claude Sonnet 4.5 (latest)" reasoning = true diff --git a/src/tau_coding/provider_config.py b/src/tau_coding/provider_config.py index e19ddb6dc..f850bea45 100644 --- a/src/tau_coding/provider_config.py +++ b/src/tau_coding/provider_config.py @@ -1528,7 +1528,9 @@ def anthropic_config_from_provider( model=selected_model, thinking_level=thinking_level, ), - thinking_mode=_anthropic_thinking_mode(provider, selected_model), + thinking_mode=_anthropic_thinking_mode( + provider, selected_model, thinking_level=thinking_level + ), ) @@ -1688,9 +1690,16 @@ def _reasoning_effort_from_anthropic_provider( return mapped or normalized -def _anthropic_thinking_mode(provider: AnthropicProviderConfig, model: str) -> str: +def _anthropic_thinking_mode( + provider: AnthropicProviderConfig, + model: str, + *, + thinking_level: ThinkingLevel | None = None, +) -> str: compat = _model_compat(provider, model) if compat.get("forceAdaptiveThinking") is True: + if thinking_level is not None and normalize_thinking_level(thinking_level) == "off": + return "disabled" return "adaptive" return "budget" diff --git a/tests/test_provider_catalog.py b/tests/test_provider_catalog.py index c28e8ce66..f55546a77 100644 --- a/tests/test_provider_catalog.py +++ b/tests/test_provider_catalog.py @@ -102,6 +102,7 @@ def test_builtin_catalog_golden_anthropic_entry() -> None: "claude-opus-4-5-20251101", "claude-opus-4-6", "claude-opus-4-7", + "claude-opus-5", "claude-sonnet-4-5", "claude-sonnet-4-5-20250929", "claude-sonnet-4-6", @@ -119,11 +120,21 @@ def test_builtin_catalog_golden_anthropic_entry() -> None: "claude-opus-4-5-20251101": 200_000, "claude-opus-4-6": 1_000_000, "claude-opus-4-7": 1_000_000, + "claude-opus-5": 1_000_000, "claude-sonnet-4-5": 200_000, "claude-sonnet-4-5-20250929": 200_000, "claude-sonnet-4-6": 1_000_000, "claude-sonnet-5": 1_000_000, } + opus_5 = entry.model_metadata["claude-opus-5"] + assert opus_5.context_window == 1_000_000 + assert opus_5.max_tokens == 128_000 + assert opus_5.input == ("text", "image") + assert opus_5.cost is not None + assert opus_5.cost["input"] == 5 + assert opus_5.cost["output"] == 25 + assert opus_5.compat == {"forceAdaptiveThinking": True} + assert opus_5.thinking_level_map == {"minimal": None, "xhigh": "max"} assert entry.thinking_levels == ("off", "minimal", "low", "medium", "high", "xhigh") assert entry.thinking_models == () assert entry.thinking_default == "medium" diff --git a/tests/test_provider_config.py b/tests/test_provider_config.py index 60d7e424e..8f9321666 100644 --- a/tests/test_provider_config.py +++ b/tests/test_provider_config.py @@ -150,6 +150,13 @@ def test_builtin_openai_declares_model_scoped_thinking_capabilities() -> None: "medium", "high", ) + assert provider_thinking_levels(anthropic, model="claude-opus-5") == ( + "off", + "low", + "medium", + "high", + "xhigh", + ) def test_load_provider_settings_accepts_provider_preferences_with_user_catalog( @@ -931,6 +938,31 @@ def test_anthropic_config_from_provider_sets_thinking_budget( assert high_config.thinking_budget_tokens == 8192 +def test_anthropic_config_from_provider_maps_opus_5_adaptive_thinking( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key") + provider = ProviderSettings().get_provider("anthropic") + assert isinstance(provider, AnthropicProviderConfig) + + off_config = anthropic_config_from_provider( + provider, model="claude-opus-5", thinking_level="off" + ) + medium_config = anthropic_config_from_provider( + provider, model="claude-opus-5", thinking_level="medium" + ) + xhigh_config = anthropic_config_from_provider( + provider, model="claude-opus-5", thinking_level="xhigh" + ) + + assert off_config.thinking_mode == "disabled" + assert off_config.thinking_effort is None + assert medium_config.thinking_mode == "adaptive" + assert medium_config.thinking_effort == "medium" + assert xhigh_config.thinking_mode == "adaptive" + assert xhigh_config.thinking_effort == "max" + + @pytest.mark.parametrize( ("parameter", "expected"), [ diff --git a/tests/test_tau_ai.py b/tests/test_tau_ai.py index 78f314431..9fd767961 100644 --- a/tests/test_tau_ai.py +++ b/tests/test_tau_ai.py @@ -1599,6 +1599,42 @@ def handler(request: httpx.Request) -> httpx.Response: assert payload["thinking"] == {"type": "enabled", "budget_tokens": 8192} +@pytest.mark.anyio +async def test_anthropic_provider_explicitly_disables_default_adaptive_thinking() -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response( + 200, + text='data: {"type":"message_stop"}\n\n', + headers={"content-type": "text/event-stream"}, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + provider = AnthropicProvider( + AnthropicConfig( + api_key="test-key", + base_url="https://api.anthropic.test/v1", + thinking_mode="disabled", + ), + client=client, + ) + + await _collect( + provider.stream_response( + model="claude-opus-5", + system="You are Tau.", + messages=[UserMessage(content="Say hello")], + tools=[], + ) + ) + + payload = loads(requests[0].content) + assert payload["thinking"] == {"type": "disabled"} + assert "output_config" not in payload + + @pytest.mark.anyio async def test_anthropic_provider_streams_thinking_deltas() -> None: def handler(_request: httpx.Request) -> httpx.Response: diff --git a/website/content/guides/providers-and-models.md b/website/content/guides/providers-and-models.md index ae6d50f80..c10c14ac9 100644 --- a/website/content/guides/providers-and-models.md +++ b/website/content/guides/providers-and-models.md @@ -181,6 +181,20 @@ list before creating or refreshing a runtime provider. This prevents accidental provider/model mismatches, such as trying to send an API-only OpenAI model to the separate `openai-codex` subscription provider. +### Claude Opus 5 + +Tau supports Anthropic's `claude-opus-5` through the direct `anthropic` +provider. The model has a 1M-token context window, accepts text and images, +generates up to 128k tokens, and costs $5 / $25 per million input/output tokens. +Anthropic enables adaptive thinking by default. Tau maps its `low` through +`high` modes directly, maps `xhigh` to Anthropic's maximum effort, and sends an +explicit disabled-thinking request for `off`. + +Use `/login anthropic-api` or `/login anthropic-subscription`, then select +**Claude Opus 5** in `/model`. See Anthropic's +[Claude Opus 5 guide](https://platform.claude.com/docs/en/about-claude/models/whats-new-opus-5) +for current behavior and availability. + ## Adding a custom / local provider Any OpenAI-compatible endpoint works — including local servers like llama.cpp or