diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e60b99df1..4fb75c9ffe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -213,6 +213,16 @@ This section accumulates work toward the **2.3.0** milestone is now paused with the reason as its last error, and resuming it reports the problem until the schedule is fixed. ([#7993]) +- **models:** A Claude model using a Claude Code OAuth token no longer fails + every request once the request carries `betas`. The OAuth betas + (`oauth-2025-04-20`, …) were only the client's default `anthropic-beta` + header, and the SDK sends a request's `betas` as that header instead, so a + configured `betas` list (even `[]`), per-call `betas`, or a server tool for + which langchain-anthropic adds a beta left them out and Anthropic rejected + the token. The OAuth betas now lead any request `betas` and any + `anthropic-beta` in `extra_headers`. + ([#8061](https://github.com/bytedance/deer-flow/pull/8061)) + - **community:** A search or fetch API key that ends in a newline no longer leaks into the conversation. GroundRoute (`GROUNDROUTE_API_KEY`), InfoQuest, Tavily, Exa, Jina, Firecrawl, fastCRW and Crawl4AI put the key into an HTTP diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index 6324927e91..088fa6ad65 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -73,6 +73,15 @@ 不再接受该表达式),在调度器轮询中计算其下一次运行时间会抛出异常;其启动租约过期后, 之后每次轮询都会在接纳到期任务之前失败。现在该任务会被暂停,并把原因记录为最近错误; 在修复计划之前,恢复该任务会提示具体问题。([#7993]) + +- **模型:** 使用 Claude Code OAuth token 的 Claude 模型,不再在请求带有 `betas` 时每次 + 都失败。此前 OAuth 所需的 beta(`oauth-2025-04-20` 等)只放在客户端默认的 + `anthropic-beta` 请求头里,而 SDK 会用请求自身的 `betas` 替换这个请求头;因此只要配置了 + `betas`(即使是 `[]`)、调用时传入 `betas`,或使用了 langchain-anthropic 会为其追加 beta + 的服务端工具,OAuth beta 就会丢失,Anthropic 随即拒绝该 token。现在 OAuth beta 会排在 + 请求 `betas` 以及 `extra_headers` 中 `anthropic-beta` 的最前面。 + ([#8061](https://github.com/bytedance/deer-flow/pull/8061)) + - **社区工具:** 以换行结尾的搜索或抓取 API 密钥不再泄露到对话中。GroundRoute (`GROUNDROUTE_API_KEY`)、InfoQuest、Tavily、Exa、Jina、Firecrawl、fastCRW 和 Crawl4AI 此前把读取到的密钥原样写入 HTTP 请求头;从文件读取的密钥(例如用 diff --git a/backend/packages/harness/deerflow/models/AGENTS.md b/backend/packages/harness/deerflow/models/AGENTS.md index f6217301e4..9b1073d325 100644 --- a/backend/packages/harness/deerflow/models/AGENTS.md +++ b/backend/packages/harness/deerflow/models/AGENTS.md @@ -68,6 +68,12 @@ transport, so they check the payload that DeerFlow sends. - **`thinking` and `redacted_thinking` blocks take no cache marker.** Anthropic rejects one there. Keep their content, signature and order, so a later breakpoint still covers them. +- **Claude OAuth betas lead any request `betas` and any `anthropic-beta` in + `extra_headers`.** They sit in the client's default header, but request + `betas` replace that header and `extra_headers` replace both, and Anthropic + rejects the Bearer token without `oauth-2025-04-20`. Build a new list and a + new header dict, because the payload can hold the model's own `betas` and + `model_kwargs`. ## Credentials diff --git a/backend/packages/harness/deerflow/models/claude_provider.py b/backend/packages/harness/deerflow/models/claude_provider.py index 76cf63a83d..1458d91b3d 100644 --- a/backend/packages/harness/deerflow/models/claude_provider.py +++ b/backend/packages/harness/deerflow/models/claude_provider.py @@ -30,6 +30,7 @@ from langchain_anthropic import ChatAnthropic from langchain_core.messages import BaseMessage from pydantic import PrivateAttr +from deerflow.models.credential_loader import OAUTH_ANTHROPIC_BETAS from deerflow.utils.retry_after import bounded_retry_after_ms, parse_retry_after_date_ms logger = logging.getLogger(__name__) @@ -80,7 +81,6 @@ class ClaudeChatModel(ChatAnthropic): from pydantic import SecretStr from deerflow.models.credential_loader import ( - OAUTH_ANTHROPIC_BETAS, is_oauth_token, load_claude_code_credential, ) @@ -159,6 +159,7 @@ class ClaudeChatModel(ChatAnthropic): if self._is_oauth: self._apply_oauth_billing(payload) + self._apply_oauth_betas(payload) if self.enable_prompt_caching: self._apply_prompt_caching(payload) @@ -210,6 +211,43 @@ class ClaudeChatModel(ChatAnthropic): } ) + @staticmethod + def _apply_oauth_betas(payload: dict) -> None: + """Keep the OAuth betas in a request that sends its own ``betas``. + + ``model_post_init`` puts the OAuth betas in the client's default + ``anthropic-beta`` header, but the SDK sends a request's ``betas`` as + that header instead of the default. A request carries ``betas`` when + they are configured or passed per call, and langchain-anthropic adds + them for server tools, MCP servers and task budgets. Without + ``oauth-2025-04-20`` Anthropic rejects the Bearer token, so the OAuth + betas go first in any such list, including an empty one. Requests + without ``betas`` or a beta header keep the default header. + + An ``anthropic-beta`` in the request's ``extra_headers`` replaces both + the default and the ``betas`` header, so it gets the same merge. + + The list and the headers are rebuilt rather than changed in place: the + payload's ``betas`` can be ``self.betas`` itself, and its + ``extra_headers`` can be the dict in ``self.model_kwargs``. + """ + oauth_betas = OAUTH_ANTHROPIC_BETAS.split(",") + + def with_oauth_betas(betas: Any) -> list: + return [*oauth_betas, *(beta for beta in betas if beta not in oauth_betas)] + + betas = payload.get("betas") + if betas is not None: + payload["betas"] = with_oauth_betas(betas) + + extra_headers = payload.get("extra_headers") + if isinstance(extra_headers, dict): + headers = dict(extra_headers) + for key, value in extra_headers.items(): + if key.lower() == "anthropic-beta" and isinstance(value, str): + headers[key] = ",".join(with_oauth_betas(beta.strip() for beta in value.split(",") if beta.strip())) + payload["extra_headers"] = headers + def _apply_prompt_caching(self, payload: dict) -> None: """Apply ephemeral cache_control to system, recent messages, and last tool definition. diff --git a/backend/tests/test_claude_provider_oauth_betas.py b/backend/tests/test_claude_provider_oauth_betas.py new file mode 100644 index 0000000000..25c0a98eaf --- /dev/null +++ b/backend/tests/test_claude_provider_oauth_betas.py @@ -0,0 +1,147 @@ +"""OAuth beta header on Claude requests that send their own ``betas``.""" + +import json + +import httpx +import pytest + +from deerflow.models.claude_provider import ClaudeChatModel +from deerflow.models.credential_loader import OAUTH_ANTHROPIC_BETAS + +OAUTH_BETAS = OAUTH_ANTHROPIC_BETAS.split(",") +CONTEXT_1M_BETA = "context-1m-2025-08-07" + +_MESSAGE = { + "id": "msg-test", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-6", + "content": [{"type": "text", "text": "ok"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 1}, +} + +_STREAM_EVENTS = [ + {"type": "message_start", "message": {**_MESSAGE, "content": [], "stop_reason": None, "usage": {"input_tokens": 1, "output_tokens": 0}}}, + {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}, + {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "ok"}}, + {"type": "content_block_stop", "index": 0}, + {"type": "message_delta", "delta": {"stop_reason": "end_turn", "stop_sequence": None}, "usage": {"output_tokens": 1}}, + {"type": "message_stop"}, +] + + +def _respond(request: httpx.Request) -> httpx.Response: + if json.loads(request.content).get("stream"): + body = "".join(f"event: {event['type']}\ndata: {json.dumps(event)}\n\n" for event in _STREAM_EVENTS) + return httpx.Response(200, headers={"content-type": "text/event-stream"}, content=body.encode()) + return httpx.Response(200, json=_MESSAGE) + + +async def _sent_beta_header(model: ClaudeChatModel, mode: str, **call_kwargs) -> str | None: + """Send one request through the model's own SDK client and return its ``anthropic-beta`` header.""" + headers: list[str | None] = [] + + def respond(request: httpx.Request) -> httpx.Response: + headers.append(request.headers.get("anthropic-beta")) + return _respond(request) + + transport = httpx.MockTransport(respond) + if mode == "invoke": + model._client = model._client.with_options(http_client=httpx.Client(transport=transport), max_retries=0) + assert model.invoke("hi", **call_kwargs).content == "ok" + else: + model._async_client = model._async_client.with_options(http_client=httpx.AsyncClient(transport=transport), max_retries=0) + if mode == "ainvoke": + assert (await model.ainvoke("hi", **call_kwargs)).content == "ok" + else: + assert "".join([chunk.text async for chunk in model.astream("hi", **call_kwargs)]) == "ok" + assert len(headers) == 1 + return headers[0] + + +def _oauth_model(**kwargs) -> ClaudeChatModel: + model = ClaudeChatModel(model="claude-sonnet-4-6", anthropic_api_key="sk-ant-oat01-fake-token", max_tokens=2048, **kwargs) + assert model._is_oauth + return model + + +@pytest.mark.asyncio +@pytest.mark.parametrize("mode", ["invoke", "ainvoke", "astream"]) +async def test_configured_betas_keep_oauth_betas(mode): + model = _oauth_model(betas=[CONTEXT_1M_BETA]) + + assert await _sent_beta_header(model, mode) == ",".join([*OAUTH_BETAS, CONTEXT_1M_BETA]) + assert model.betas == [CONTEXT_1M_BETA] + + +@pytest.mark.asyncio +async def test_empty_betas_keep_oauth_betas(): + assert await _sent_beta_header(_oauth_model(betas=[]), "ainvoke") == OAUTH_ANTHROPIC_BETAS + + +@pytest.mark.asyncio +async def test_configured_oauth_beta_is_not_repeated(): + model = _oauth_model(betas=["interleaved-thinking-2025-05-14", CONTEXT_1M_BETA]) + + assert await _sent_beta_header(model, "ainvoke") == ",".join([*OAUTH_BETAS, CONTEXT_1M_BETA]) + + +@pytest.mark.asyncio +async def test_call_time_betas_keep_oauth_betas(): + assert await _sent_beta_header(_oauth_model(), "ainvoke", betas=[CONTEXT_1M_BETA]) == ",".join([*OAUTH_BETAS, CONTEXT_1M_BETA]) + + +@pytest.mark.asyncio +async def test_server_tool_beta_keeps_oauth_betas(): + """langchain-anthropic adds a beta for some server tools without any configured ``betas``.""" + model = _oauth_model().bind_tools([{"type": "web_fetch_20250910", "name": "web_fetch", "max_uses": 1}]) + + assert await _sent_beta_header(model.bound, "ainvoke", **model.kwargs) == ",".join([*OAUTH_BETAS, "web-fetch-2025-09-10"]) + + +@pytest.mark.asyncio +async def test_oauth_request_without_betas_uses_default_header(): + assert await _sent_beta_header(_oauth_model(), "ainvoke") == OAUTH_ANTHROPIC_BETAS + + +@pytest.mark.asyncio +async def test_api_key_betas_are_sent_unchanged(): + model = ClaudeChatModel(model="claude-sonnet-4-6", anthropic_api_key="sk-ant-api03-fake-key", max_tokens=2048, betas=[CONTEXT_1M_BETA]) + assert not model._is_oauth + + assert await _sent_beta_header(model, "ainvoke") == CONTEXT_1M_BETA + + +@pytest.mark.asyncio +async def test_configured_extra_beta_header_keeps_oauth_betas(): + """An ``anthropic-beta`` in ``extra_headers`` replaces both the default header and the ``betas`` header.""" + model_kwargs = {"extra_headers": {"anthropic-beta": CONTEXT_1M_BETA, "x-trace": "1"}} + model = _oauth_model(model_kwargs=model_kwargs) + + assert await _sent_beta_header(model, "ainvoke") == ",".join([*OAUTH_BETAS, CONTEXT_1M_BETA]) + assert model_kwargs == {"extra_headers": {"anthropic-beta": CONTEXT_1M_BETA, "x-trace": "1"}} + + +@pytest.mark.asyncio +async def test_call_time_extra_beta_header_keeps_oauth_betas(): + call_kwargs = {"betas": ["files-api-2025-04-14"], "extra_headers": {"anthropic-beta": f"{CONTEXT_1M_BETA}, oauth-2025-04-20"}} + + assert await _sent_beta_header(_oauth_model(), "ainvoke", **call_kwargs) == ",".join([*OAUTH_BETAS, CONTEXT_1M_BETA]) + + +def test_extra_beta_header_name_is_case_insensitive(): + payload = {"extra_headers": {"Anthropic-Beta": CONTEXT_1M_BETA}} + + ClaudeChatModel._apply_oauth_betas(payload) + + assert payload == {"extra_headers": {"Anthropic-Beta": ",".join([*OAUTH_BETAS, CONTEXT_1M_BETA])}} + + +@pytest.mark.asyncio +async def test_api_key_extra_beta_header_is_sent_unchanged(): + model = ClaudeChatModel(model="claude-sonnet-4-6", anthropic_api_key="sk-ant-api03-fake-key", max_tokens=2048, model_kwargs={"extra_headers": {"anthropic-beta": CONTEXT_1M_BETA}}) + assert not model._is_oauth + + assert await _sent_beta_header(model, "ainvoke") == CONTEXT_1M_BETA