mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-12 22:20:33 +00:00
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:
parent
70a4b83907
commit
7d45fea17e
10
CHANGELOG.md
10
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
|
||||
|
||||
@ -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 请求头;从文件读取的密钥(例如用
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
147
backend/tests/test_claude_provider_oauth_betas.py
Normal file
147
backend/tests/test_claude_provider_oauth_betas.py
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user