mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-27 13:40:37 +00:00
fix(models): skip undecodable Claude descriptor handoffs (#6323)
This commit is contained in:
parent
7abb973f72
commit
9f8a59da80
@ -311,6 +311,7 @@ For Google's official Gemini OpenAI-compatible endpoint, use the
|
||||
- Codex CLI reads `~/.codex/auth.json`
|
||||
- The Codex model provider returns completed responses without waiting for the SSE connection to close. Failed or incomplete responses report the provider's error or reason; partial output is not returned as a successful answer. Non-object error details are reported as text.
|
||||
- Claude Code accepts `CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_AUTH_TOKEN`, `CLAUDE_CODE_CREDENTIALS_PATH`, or `~/.claude/.credentials.json`
|
||||
- `CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR` accepts a UTF-8 token handoff and reuses it for later model instances in the same process. Undecodable handoffs are skipped so Claude Code can still try its override or default credentials file.
|
||||
- CLI credential JSON files accept UTF-8 with or without a BOM, independently of the host locale. `make doctor` accepts the same files when checking CLI authentication. Invalid text encoding is treated as an unreadable source; Claude Code can still try its default file after an invalid override.
|
||||
- ACP agent entries are separate from model providers — if you configure `acp_agents.codex`, point it at a Codex ACP adapter such as `npx -y @zed-industries/codex-acp`
|
||||
- Each ACP agent's `timeout_seconds` (default: 1800) is one shared budget for initialization, session creation, and the prompt, starting after the subprocess launches. On timeout, DeerFlow aborts the invocation and closes the subprocess before returning an error. Workspace/MCP preparation and subprocess cleanup are outside this budget. A `TimeoutError` raised by the SDK before this deadline expires retains its own error message.
|
||||
|
||||
@ -90,7 +90,7 @@ Offline HTTP-stream coverage: `tests/test_codex_stream_terminal_events.py`.
|
||||
### Claude Code Credentials (`packages/harness/deerflow/models/credential_loader.py`)
|
||||
|
||||
- `ClaudeChatModel.model_post_init` calls `load_claude_code_credential()` for every instance, and `create_chat_model` builds fresh instances per run (lead agent, title, summarization, subagents)
|
||||
- `$CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR` is a one-shot handoff: a pipe returns EOF and a file keeps its advanced offset. `_read_secret_from_file_descriptor` therefore caches a non-empty secret per `(env_var, fd)` under a lock held across the read. Do not drop the cache or the lock — later instances would get no credential, and the Anthropic SDK raises `TypeError: Could not resolve authentication method` before sending. Empty reads and `OSError` are not cached. The key is the descriptor number on purpose — a closed handoff keeps serving its token, and a secret placed on a recycled number in-process is not re-read unless the cache is cleared. The cache is per process, so a new process (e.g. a uvicorn `--reload` worker) cannot recover a drained descriptor. Pinned by `tests/test_credential_loader.py`, including a two-instance `ClaudeChatModel` test
|
||||
- `$CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR` is a one-shot handoff: a pipe returns EOF and a file keeps its advanced offset. `_read_secret_from_file_descriptor` therefore caches a non-empty secret per `(env_var, fd)` under a lock held across the read. Do not drop the cache or the lock — later instances would get no credential, and the Anthropic SDK raises `TypeError: Could not resolve authentication method` before sending. Empty reads, `OSError`, and UTF-8 decode failures are not cached; unreadable handoffs return `None` so the loader can try credential files. Warnings must not include token contents. The key is the descriptor number on purpose — a closed handoff keeps serving its token, and a secret placed on a recycled number in-process is not re-read unless the cache is cleared. The cache is per process, so a new process (e.g. a uvicorn `--reload` worker) cannot recover a drained descriptor. Pinned by `tests/test_credential_loader.py` and `tests/test_claude_fd_encoding.py`, including two-instance `ClaudeChatModel` tests
|
||||
|
||||
### Claude Prompt Caching (`packages/harness/deerflow/models/claude_provider.py`)
|
||||
|
||||
|
||||
@ -117,7 +117,7 @@ def _read_secret_from_file_descriptor(env_var: str) -> str | None:
|
||||
|
||||
try:
|
||||
secret = os.read(fd, 1024 * 1024).decode().strip()
|
||||
except OSError as e:
|
||||
except (OSError, UnicodeError) as e:
|
||||
logger.warning(f"Failed to read {env_var}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
104
backend/tests/test_claude_fd_encoding.py
Normal file
104
backend/tests/test_claude_fd_encoding.py
Normal file
@ -0,0 +1,104 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from deerflow.models import credential_loader
|
||||
from deerflow.models.claude_provider import ClaudeChatModel
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_credentials(tmp_path, monkeypatch):
|
||||
for name in (
|
||||
"CLAUDE_CODE_OAUTH_TOKEN",
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR",
|
||||
"CLAUDE_CODE_CREDENTIALS_PATH",
|
||||
):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.setattr(credential_loader, "_fd_secret_cache", {})
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _handoff(payload: bytes, monkeypatch) -> Iterator[int]:
|
||||
read_fd, write_fd = os.pipe()
|
||||
try:
|
||||
try:
|
||||
os.write(write_fd, payload)
|
||||
finally:
|
||||
os.close(write_fd)
|
||||
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR", str(read_fd))
|
||||
yield read_fd
|
||||
finally:
|
||||
os.close(read_fd)
|
||||
|
||||
|
||||
def _credential_file(path: Path) -> Path:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps({"claudeAiOauth": {"accessToken": "sk-ant-oat01-file-fallback"}}), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[b"sk-ant-oat01-invalid-byte\xff", b"\xff\xfe" + "sk-ant-oat01-utf16".encode("utf-16-le"), b"sk-ant-oat01-truncated\xe2\x82"],
|
||||
ids=["invalid-byte", "utf16", "truncated-utf8"],
|
||||
)
|
||||
@pytest.mark.parametrize("fallback", ["none", "override", "default"])
|
||||
def test_undecodable_descriptor_skips_to_file_credentials(tmp_path, monkeypatch, caplog, payload, fallback):
|
||||
if fallback == "override":
|
||||
monkeypatch.setenv("CLAUDE_CODE_CREDENTIALS_PATH", str(_credential_file(tmp_path / "override.json")))
|
||||
elif fallback == "default":
|
||||
_credential_file(tmp_path / ".claude" / ".credentials.json")
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger=credential_loader.__name__), _handoff(payload, monkeypatch) as read_fd:
|
||||
# A failed handoff must not strand either the first model or later loads.
|
||||
credentials = [credential_loader.load_claude_code_credential() for _ in range(2)]
|
||||
os.fstat(read_fd) # The loader does not own or close the descriptor.
|
||||
|
||||
if fallback == "none":
|
||||
assert credentials == [None, None]
|
||||
else:
|
||||
assert all(cred is not None and cred.access_token == "sk-ant-oat01-file-fallback" and cred.source == "claude-cli-file" for cred in credentials)
|
||||
assert credential_loader._fd_secret_cache == {}
|
||||
assert "Failed to read CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR" in caplog.text
|
||||
assert "sk-ant-oat01" not in caplog.text
|
||||
|
||||
|
||||
def test_claude_models_use_file_fallback_after_undecodable_descriptor(tmp_path, monkeypatch):
|
||||
_credential_file(tmp_path / ".claude" / ".credentials.json")
|
||||
|
||||
with _handoff(b"sk-ant-oat01-model-handoff\xff", monkeypatch):
|
||||
models = [ClaudeChatModel(model="claude-sonnet-4-6") for _ in range(2)]
|
||||
|
||||
for model in models:
|
||||
assert model._is_oauth is True
|
||||
assert model._client.api_key is None
|
||||
assert model._client.auth_token == "sk-ant-oat01-file-fallback"
|
||||
|
||||
|
||||
def test_valid_descriptor_still_wins_over_file_and_reuses_cached_token(tmp_path, monkeypatch):
|
||||
_credential_file(tmp_path / ".claude" / ".credentials.json")
|
||||
|
||||
with _handoff(b" sk-ant-oat01-valid-handoff\n", monkeypatch):
|
||||
credentials = [credential_loader.load_claude_code_credential() for _ in range(2)]
|
||||
|
||||
assert all(cred is not None and cred.access_token == "sk-ant-oat01-valid-handoff" and cred.source == "claude-cli-fd" for cred in credentials)
|
||||
|
||||
|
||||
def test_direct_environment_token_precedes_undecodable_descriptor(monkeypatch, caplog):
|
||||
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "sk-ant-oat01-direct")
|
||||
|
||||
with _handoff(b"sk-ant-oat01-bad-handoff\xff", monkeypatch) as read_fd:
|
||||
cred = credential_loader.load_claude_code_credential()
|
||||
assert os.read(read_fd, 1024) == b"sk-ant-oat01-bad-handoff\xff"
|
||||
|
||||
assert cred is not None and cred.access_token == "sk-ant-oat01-direct" and cred.source == "claude-cli-env"
|
||||
assert "Failed to read" not in caplog.text
|
||||
Loading…
x
Reference in New Issue
Block a user