fix(models): keep the OAuth betas when a Claude request sends its own betas (#8061)

* fix(models): keep the OAuth betas when a Claude request sends its own betas

ClaudeChatModel put the Claude Code OAuth betas (oauth-2025-04-20, ...)
only in the client's default anthropic-beta header. A request whose
payload carries betas is sent through beta.messages.create, and the
Anthropic SDK turns that list into a per-request anthropic-beta header
that replaces the default. So a configured betas list (even []),
per-call betas, or a server tool for which langchain-anthropic appends a
beta sent the request without oauth-2025-04-20, and Anthropic rejects
the Bearer token.

_get_request_payload now puts the OAuth betas first in any request betas
list before the thinking budget resolves the header. Requests without
betas keep the default header, and API-key models are unchanged. The
list is rebuilt because the payload's betas can be self.betas.

Fixes #8060

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): link the Claude OAuth betas fix to #8061

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(models): keep the OAuth betas in a caller's anthropic-beta extra header

The Anthropic SDK lets a request's extra_headers override both the
client default and the header built from betas. An anthropic-beta in
extra_headers, from model_kwargs or passed per call, therefore still
dropped the OAuth betas and Anthropic rejected the Bearer token.

_apply_oauth_betas now gives that header the same merge as betas: the
OAuth betas go first and the caller's betas follow without repeats.
The headers dict is copied because it can be the one in model_kwargs.
API-key models are unchanged.

Refs #8060

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: AochenShen99 <sac1063467113@hotmail.com>
This commit is contained in:
coder-abu 2026-11-12 18:29:48 +08:00 committed by GitHub
parent 70a4b83907
commit 7d45fea17e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 211 additions and 1 deletions

View File

@ -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

View File

@ -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 请求头;从文件读取的密钥(例如用

View File

@ -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

View File

@ -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.

View File

@ -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