diff --git a/README.md b/README.md index bc78d1f7fa..d899c6c851 100644 --- a/README.md +++ b/README.md @@ -328,6 +328,8 @@ For MindIE XML tool calls, see the supports_thinking: true ``` + `ClaudeChatModel` normalizes manual extended-thinking budgets when `auto_thinking_budget: true` (the default). An omitted or null budget gets 80% of `max_tokens`, with a minimum of 1024 tokens. Explicit budgets must be integers of at least 1024 and, for ordinary thinking, strictly below `max_tokens`; output limits of 1024 or less fail locally. For supported manual interleaved thinking, configure tools and `betas: ["interleaved-thinking-2025-05-14"]`: the thinking budget may equal or exceed the positive output limit. See [Anthropic's interleaved-thinking rules](https://platform.claude.com/docs/en/build-with-claude/extended-thinking#interleaved-thinking-in-manual-mode) for supported models. `auto_thinking_budget: false` bypasses this normalization and validation; disabled and adaptive thinking are unchanged. + - Codex CLI reads `~/.codex/auth.json` - Codex function tools preserve explicit `strict: true` or `strict: false` in wrapped or flat dictionary definitions. Missing or null settings keep the provider default. `bind_tools` applies the same conversion to dictionaries and `BaseTool` schemas. - Completed Codex responses still return their text and tool calls when token usage is null, omitted, or empty; usage metadata remains unavailable. diff --git a/backend/packages/harness/deerflow/models/AGENTS.md b/backend/packages/harness/deerflow/models/AGENTS.md index 0681061b3a..bc2e54b29c 100644 --- a/backend/packages/harness/deerflow/models/AGENTS.md +++ b/backend/packages/harness/deerflow/models/AGENTS.md @@ -136,6 +136,13 @@ Offline HTTP-stream coverage: `tests/test_codex_stream_terminal_events.py`. - `_apply_prompt_caching` must call `_strip_cache_control` first: it then replaces slots in those payload-owned lists with marked copies and writes `msg["content"]` on copied message dicts, placing at most four breakpoints. Pinned by `tests/test_claude_provider_prompt_caching.py` - Exclude `thinking` and `redacted_thinking` blocks before selecting the last four cache candidates: Anthropic forbids direct `cache_control` on these blocks. Keep their content, signatures/data and order intact so they remain part of the prefix covered by a later eligible breakpoint. Stripping stale markers must not add them back to thinking blocks or mutate caller-owned history. The same suite exercises real sync/async SDK tool-followup requests through offline transports. +### Claude Thinking Budget (`packages/harness/deerflow/models/claude_provider.py`) + +- With `auto_thinking_budget=True`, manual `thinking.type=enabled` requests validate integer budgets of at least 1024. Ordinary thinking also requires `budget_tokens < max_tokens` and integer `max_tokens > 1024`; an absent/null budget uses `max(1024, int(max_tokens * 0.8))`, defaulting to an 8192 output limit. +- Manual interleaving permits a budget equal to or above a positive integer output limit, but only with tools, the effective `interleaved-thinking-2025-05-14` beta, and a supported Sonnet 4/4.5/4.6 or Opus 4/4.1/4.5 model (aliases and dated IDs). Haiku 4.5 and Opus 4.6 do not interleave in manual mode even with that header. Follow [Anthropic's model-specific rules](https://platform.claude.com/docs/en/build-with-claude/extended-thinking#interleaved-thinking-in-manual-mode) when updating this capability gate. +- Resolve the beta header as the SDK does: client defaults, then request `betas` (including an empty list), then `extra_headers`. Match comma-separated beta names exactly; a removed or replaced beta must not relax the budget bound. +- Automatic allocation replaces the payload's thinking mapping with a copy: LangChain aliases it to `self.thinking`, so in-place writes leak across requests and make smaller per-call output limits fail. `auto_thinking_budget=False` bypasses normalization/validation; absent, disabled and adaptive thinking remain untouched. Coverage: `tests/test_claude_provider_thinking.py`, including native request construction and offline SDK serialization. + ### vLLM Provider (`packages/harness/deerflow/models/vllm_provider.py`) - `VllmChatModel` subclasses `langchain_openai:ChatOpenAI` for vLLM 0.19.0 OpenAI-compatible endpoints diff --git a/backend/packages/harness/deerflow/models/claude_provider.py b/backend/packages/harness/deerflow/models/claude_provider.py index 6943fb0aaa..dffaa33b92 100644 --- a/backend/packages/harness/deerflow/models/claude_provider.py +++ b/backend/packages/harness/deerflow/models/claude_provider.py @@ -19,6 +19,7 @@ import hashlib import json import logging import os +import re import socket import time import uuid @@ -33,6 +34,12 @@ logger = logging.getLogger(__name__) MAX_RETRIES = 3 THINKING_BUDGET_RATIO = 0.8 +MIN_THINKING_BUDGET_TOKENS = 1024 +INTERLEAVED_THINKING_BETA = "interleaved-thinking-2025-05-14" +# Manual interleaving is supported by Sonnet 4/4.5/4.6 and Opus 4/4.1/4.5, +# including the 4-0 aliases and dated model IDs. Haiku 4.5 and Opus 4.6 +# accept the beta header but do not interleave in manual thinking mode. +_MANUAL_INTERLEAVED_MODEL = re.compile(r"claude-(?:sonnet-4(?:-[056])?|opus-4(?:-[015])?)(?:-\d{8})?") # Billing header required by Anthropic API for OAuth token access. # Must be the first system prompt block. Format mirrors Claude Code CLI. @@ -264,18 +271,45 @@ class ClaudeChatModel(ChatAnthropic): for container, index in candidates[-MAX_CACHE_BREAKPOINTS:]: container[index] = {**container[index], "cache_control": {"type": "ephemeral"}} + def _uses_manual_interleaved_thinking(self, payload: dict) -> bool: + """Resolve the beta header with the Anthropic SDK's request precedence.""" + if not payload.get("tools") or not _MANUAL_INTERLEAVED_MODEL.fullmatch(payload.get("model", self.model)): + return False + + headers = dict(self.default_headers or {}) + betas = payload.get("betas") + if isinstance(betas, (list, tuple)): + headers["anthropic-beta"] = ",".join(str(beta) for beta in betas) + headers.update(payload.get("extra_headers") or {}) + return any(INTERLEAVED_THINKING_BETA in (beta.strip() for beta in value.split(",")) for key, value in headers.items() if key.lower() == "anthropic-beta" and isinstance(value, str)) + def _apply_thinking_budget(self, payload: dict) -> None: - """Auto-allocate thinking budget (80% of max_tokens).""" + """Validate manual thinking and allocate an unset budget (80% of max_tokens).""" thinking = payload.get("thinking") if not thinking or not isinstance(thinking, dict): return if thinking.get("type") != "enabled": return - if thinking.get("budget_tokens"): + interleaved = self._uses_manual_interleaved_thinking(payload) + max_tokens = payload.get("max_tokens", 8192) + minimum_max_tokens = 0 if interleaved else MIN_THINKING_BUDGET_TOKENS + if not isinstance(max_tokens, int) or isinstance(max_tokens, bool) or max_tokens <= minimum_max_tokens: + raise ValueError(f"Claude extended thinking requires integer max_tokens > {minimum_max_tokens}; got {max_tokens}") + + budget_tokens = thinking.get("budget_tokens") + if budget_tokens is not None: + if not isinstance(budget_tokens, int) or isinstance(budget_tokens, bool) or budget_tokens < MIN_THINKING_BUDGET_TOKENS or (not interleaved and budget_tokens >= max_tokens): + upper_bound = "" if interleaved else f" and strictly less than max_tokens ({max_tokens})" + raise ValueError(f"Claude extended thinking requires budget_tokens to be an integer at least {MIN_THINKING_BUDGET_TOKENS}{upper_bound}; got {budget_tokens}") return - max_tokens = payload.get("max_tokens", 8192) - thinking["budget_tokens"] = int(max_tokens * THINKING_BUDGET_RATIO) + payload["thinking"] = { + **thinking, + "budget_tokens": max( + MIN_THINKING_BUDGET_TOKENS, + int(max_tokens * THINKING_BUDGET_RATIO), + ), + } @staticmethod def _strip_cache_control(payload: dict) -> None: diff --git a/backend/tests/test_claude_provider_thinking.py b/backend/tests/test_claude_provider_thinking.py new file mode 100644 index 0000000000..3b3b38b921 --- /dev/null +++ b/backend/tests/test_claude_provider_thinking.py @@ -0,0 +1,249 @@ +"""Tests for Claude extended-thinking request normalization.""" + +import json +from unittest import mock + +import anthropic +import httpx +import pytest + +from deerflow.models.claude_provider import ClaudeChatModel + +INTERLEAVED_BETA = "interleaved-thinking-2025-05-14" +TOOLS = [{"name": "lookup", "description": "Look up a value", "input_schema": {"type": "object", "properties": {}}}] + + +def _make_model(**kwargs) -> ClaudeChatModel: + with mock.patch.object(ClaudeChatModel, "model_post_init"): + model = ClaudeChatModel(model=kwargs.pop("model", "claude-sonnet-4-5"), anthropic_api_key="sk-ant-fake", enable_prompt_caching=False, **kwargs) + model._is_oauth = False + return model + + +def test_auto_budget_rejects_impossible_max_tokens(): + model = _make_model() + payload = {"thinking": {"type": "enabled"}, "max_tokens": 512} + + with pytest.raises(ValueError, match="max_tokens > 1024"): + model._apply_thinking_budget(payload) + + +def test_auto_budget_clamps_to_minimum_when_max_tokens_allows_it(): + model = _make_model() + payload = {"thinking": {"type": "enabled"}, "max_tokens": 1100} + + model._apply_thinking_budget(payload) + + assert payload["thinking"]["budget_tokens"] == 1024 + + +def test_auto_budget_uses_eighty_percent_for_large_max_tokens(): + model = _make_model() + payload = {"thinking": {"type": "enabled"}, "max_tokens": 8192} + + model._apply_thinking_budget(payload) + + assert payload["thinking"]["budget_tokens"] == 6553 + + +def test_null_budget_is_treated_as_unset(): + model = _make_model() + payload = {"thinking": {"type": "enabled", "budget_tokens": None}, "max_tokens": 8192} + + model._apply_thinking_budget(payload) + + assert payload["thinking"]["budget_tokens"] == 6553 + + +def test_disabled_thinking_does_not_validate_small_max_tokens(): + model = _make_model() + payload = {"thinking": {"type": "disabled"}, "max_tokens": 100} + + model._apply_thinking_budget(payload) + + assert payload == {"thinking": {"type": "disabled"}, "max_tokens": 100} + + +def test_explicit_budget_is_left_unchanged(): + model = _make_model() + payload = {"thinking": {"type": "enabled", "budget_tokens": 2048}, "max_tokens": 4096} + + model._apply_thinking_budget(payload) + + assert payload["thinking"]["budget_tokens"] == 2048 + + +@pytest.mark.parametrize("budget_tokens", [512, 4096]) +def test_explicit_budget_must_fit_anthropic_limits(budget_tokens): + model = _make_model() + payload = {"thinking": {"type": "enabled", "budget_tokens": budget_tokens}, "max_tokens": 4096} + + with pytest.raises(ValueError, match="at least 1024 and strictly less than max_tokens"): + model._apply_thinking_budget(payload) + + +def test_auto_budget_does_not_mutate_shared_thinking_config(): + model = _make_model() + thinking = {"type": "enabled"} + payload = {"thinking": thinking, "max_tokens": 8192} + + model._apply_thinking_budget(payload) + + assert payload["thinking"]["budget_tokens"] == 6553 + assert thinking == {"type": "enabled"} + + +def test_auto_budget_defaults_max_tokens(): + payload = {"thinking": {"type": "enabled"}} + _make_model()._apply_thinking_budget(payload) + assert payload["thinking"]["budget_tokens"] == 6553 + + +@pytest.mark.parametrize("max_tokens", [1025, 1200]) +def test_auto_budget_clamped_near_minimum(max_tokens): + payload = {"thinking": {"type": "enabled"}, "max_tokens": max_tokens} + _make_model()._apply_thinking_budget(payload) + assert payload["thinking"]["budget_tokens"] == 1024 + + +@pytest.mark.parametrize("max_tokens", [1024, 0, -1, True, False, "8192", 8192.5, None]) +def test_auto_budget_rejects_invalid_max_tokens(max_tokens): + payload = {"thinking": {"type": "enabled"}, "max_tokens": max_tokens} + with pytest.raises(ValueError, match="max_tokens"): + _make_model()._apply_thinking_budget(payload) + + +def test_explicit_minimum_budget_is_valid(): + payload = {"thinking": {"type": "enabled", "budget_tokens": 1024}, "max_tokens": 2048} + _make_model()._apply_thinking_budget(payload) + assert payload["thinking"]["budget_tokens"] == 1024 + + +@pytest.mark.parametrize("budget_tokens", [1023, 9000, "2048", True, False, 0, "", 0.0]) +def test_explicit_invalid_budgets_are_rejected(budget_tokens): + payload = {"thinking": {"type": "enabled", "budget_tokens": budget_tokens}, "max_tokens": 8192} + with pytest.raises(ValueError, match="budget_tokens"): + _make_model()._apply_thinking_budget(payload) + + +def test_no_thinking_block_is_untouched(): + payload = {"max_tokens": 512} + _make_model()._apply_thinking_budget(payload) + assert payload == {"max_tokens": 512} + + +def test_adaptive_thinking_is_untouched(): + payload = {"thinking": {"type": "adaptive"}, "max_tokens": 512} + _make_model()._apply_thinking_budget(payload) + assert payload == {"thinking": {"type": "adaptive"}, "max_tokens": 512} + + +def test_auto_budget_recomputed_for_each_request(): + model = _make_model(thinking={"type": "enabled"}) + first = model._get_request_payload("hello", max_tokens=16384) + second = model._get_request_payload("hello", max_tokens=2048) + assert first["thinking"]["budget_tokens"] == 13107 + assert second["thinking"]["budget_tokens"] == 1638 + assert model.thinking == {"type": "enabled"} + + +@pytest.mark.parametrize( + "model_name", ["claude-sonnet-4-0", "claude-sonnet-4-20250514", "claude-sonnet-4-5", "claude-sonnet-4-5-20250929", "claude-sonnet-4-6", "claude-opus-4-0", "claude-opus-4-20250514", "claude-opus-4-1-20250805", "claude-opus-4-5-20251101"] +) +def test_interleaved_thinking_allows_budget_above_max_tokens(model_name): + model = _make_model(model=model_name, thinking={"type": "enabled", "budget_tokens": 9000}, max_tokens=8192, betas=[INTERLEAVED_BETA]) + payload = model._get_request_payload("hello", tools=TOOLS) + assert payload["thinking"] == {"type": "enabled", "budget_tokens": 9000} + assert payload["max_tokens"] == 8192 + assert payload["betas"] == [INTERLEAVED_BETA] + assert model.thinking == {"type": "enabled", "budget_tokens": 9000} + + +def test_interleaved_thinking_allows_budget_equal_to_max_tokens(): + model = _make_model(thinking={"type": "enabled", "budget_tokens": 8192}, max_tokens=8192, betas=[INTERLEAVED_BETA]) + payload = model._get_request_payload("hello", tools=TOOLS) + assert payload["thinking"]["budget_tokens"] == 8192 + + +@pytest.mark.parametrize( + "kwargs", [{"betas": []}, {"betas": ["unrelated-beta"]}, {"betas": [INTERLEAVED_BETA + "-other"]}, {"tools": []}, {"tools": None}, {"model": "claude-haiku-4-5"}, {"model": "claude-opus-4-6"}, {"model": "claude-sonnet-4-7"}] +) +def test_upper_bound_still_applies_without_supported_interleaving(kwargs): + model = _make_model(thinking={"type": "enabled", "budget_tokens": 9000}, max_tokens=8192, betas=[INTERLEAVED_BETA]) + request_kwargs = {"tools": TOOLS, **kwargs} + with pytest.raises(ValueError, match="strictly less than max_tokens"): + model._get_request_payload("hello", **request_kwargs) + + +@pytest.mark.parametrize("budget_tokens", [None, 1024]) +def test_interleaved_thinking_allows_small_output_limit(budget_tokens): + thinking = {"type": "enabled", "budget_tokens": budget_tokens} + model = _make_model(thinking=thinking, max_tokens=512, betas=[INTERLEAVED_BETA]) + payload = model._get_request_payload("hello", tools=TOOLS) + assert payload["thinking"]["budget_tokens"] == 1024 + assert model.thinking == thinking + + +@pytest.mark.parametrize("max_tokens", [0, -1, True, "8192", 8192.5]) +def test_interleaving_still_requires_positive_integer_max_tokens(max_tokens): + model = _make_model(thinking={"type": "enabled", "budget_tokens": 9000}, betas=[INTERLEAVED_BETA]) + with pytest.raises(ValueError, match="max_tokens"): + model._get_request_payload("hello", tools=TOOLS, max_tokens=max_tokens) + + +@pytest.mark.parametrize("budget_tokens", [1023, True, "9000"]) +def test_interleaving_still_requires_integer_budget_at_least_minimum(budget_tokens): + model = _make_model(thinking={"type": "enabled", "budget_tokens": budget_tokens}, max_tokens=8192, betas=[INTERLEAVED_BETA]) + with pytest.raises(ValueError, match="budget_tokens"): + model._get_request_payload("hello", tools=TOOLS) + + +@pytest.mark.parametrize( + ("model_kwargs", "request_kwargs", "expected_beta", "allowed"), + [ + ({"betas": [INTERLEAVED_BETA]}, {}, INTERLEAVED_BETA, True), + ({"default_headers": {"anthropic-beta": "oauth-2025-04-20, " + INTERLEAVED_BETA}}, {}, "oauth-2025-04-20, " + INTERLEAVED_BETA, True), + ({}, {"extra_headers": {"Anthropic-Beta": INTERLEAVED_BETA}}, INTERLEAVED_BETA, True), + ({"default_headers": {"anthropic-beta": INTERLEAVED_BETA}, "betas": []}, {}, "", False), + ({"betas": [INTERLEAVED_BETA]}, {"extra_headers": {"anthropic-beta": "unrelated-beta"}}, "unrelated-beta", False), + ({"default_headers": {"anthropic-beta": INTERLEAVED_BETA}}, {"extra_headers": {"anthropic-beta": anthropic.Omit()}}, None, False), + ], +) +def test_interleaved_beta_matches_sdk_header_precedence(model_kwargs, request_kwargs, expected_beta, allowed): + captured = [] + + def respond(request): + captured.append((json.loads(request.content), request.headers.get("anthropic-beta"))) + return httpx.Response( + 200, + json={ + "id": "msg_test", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [{"type": "text", "text": "ok"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 1}, + }, + ) + + with httpx.Client(transport=httpx.MockTransport(respond)) as client: + model = _make_model(max_tokens=8192, thinking={"type": "enabled", "budget_tokens": 9000}, **model_kwargs) + model.__dict__["_client"] = anthropic.Anthropic(api_key="sk-ant-fake", http_client=client, default_headers=model.default_headers) + if allowed: + payload = model._get_request_payload("hello", tools=TOOLS, **request_kwargs) + assert model._create(payload).content[0].text == "ok" + body, beta = captured[0] + assert body["thinking"]["budget_tokens"] == 9000 + assert beta == expected_beta + else: + with pytest.raises(ValueError, match="strictly less than max_tokens"): + model._get_request_payload("hello", tools=TOOLS, **request_kwargs) + assert captured == [] + + +def test_manual_budget_opt_out_is_unchanged(): + model = _make_model(thinking={"type": "enabled", "budget_tokens": 409}, max_tokens=512, auto_thinking_budget=False) + payload = model._get_request_payload("hello") + assert payload["thinking"]["budget_tokens"] == 409