mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 05:06:59 +00:00
fix(models): validate Claude extended-thinking budgets (#6060)
* fix(models): validate Claude extended thinking budgets * fix(models): preserve Claude thinking budget semantics * fix(models): isolate Claude auto thinking budgets * fix(models): preserve interleaved Claude thinking budgets Consolidate the additional thinking-budget boundary and type coverage from PR #6482 into #6060. Keep copied automatic budgets and preserve valid manual interleaved budgets with supported models, tools, and the effective beta header. Document the validation scope and cover native payload construction and SDK header precedence. * docs(models): avoid upstream Claude setup overlap --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
595000ac89
commit
340a5b63fa
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
249
backend/tests/test_claude_provider_thinking.py
Normal file
249
backend/tests/test_claude_provider_thinking.py
Normal file
@ -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
|
||||
Loading…
x
Reference in New Issue
Block a user