Repository navigation
Description
Problem summary
The Claude credential loader raises TypeError instead of skipping the source when a credentials file's claudeAiOauth.expiresAt is a string or null, which breaks every ClaudeChatModel construction.
Affected area(s)
- Agents / LangGraph (graph, prompts, langgraph.json)
- Config / setup (make, config.yaml, env)
What happened?
load_claude_code_credential() documents a lookup order that degrades gracefully — a source that cannot be used is skipped and the next one is tried. That contract holds for a malformed container (merged in #5494 / bec423188), but not for a malformed expiresAt value.
_extract_claude_code_credential copies expiresAt out of the JSON with no type check:
expires_at=oauth.get("expiresAt", 0),and then evaluates cred.is_expired:
@property
def is_expired(self) -> bool:
if self.expires_at <= 0:
return False
return time.time() * 1000 > self.expires_at - 60_000 # 1 min bufferIf the file contains "expiresAt": "1773430695128" (a string) or "expiresAt": null, line 58 (if self.expires_at <= 0:) raises TypeError out of load_claude_code_credential(). It is not caught, so the loop never advances to ~/.claude/.credentials.json.
This is also internally inconsistent: a missing expiresAt falls back to the dataclass default 0, which is_expired explicitly tolerates (if self.expires_at <= 0: return False), while an explicit null — which means the same "unknown expiry" — raises.
Reachability: load_claude_code_credential() is called from ClaudeChatModel.model_post_init (backend/packages/harness/deerflow/models/claude_provider.py:91) with no try/except, and per AGENTS.md a fresh model instance is built per run (lead agent, title, summarization, subagents). One such credentials file therefore makes every Claude model construction raise instead of degrading to the next source.
Expected behavior
A source whose expiresAt is not a number should be treated like the other malformed inputs in the same function: skip that source (debug log) and continue down the documented lookup order, so load_claude_code_credential() either returns a credential from a later source or returns None. It should not raise.
Basis in the repo:
_extract_claude_code_credentialwas hardened inbec423188(merged fix(models): guard Claude credential loader against malformed claudeAiOauth (#5473) #5494, issue [bug] Claude credential loader raises AttributeError when ~/.claude/.credentials.json has a non-object claudeAiOauth container #5473) to skip malformed input rather than abort:if not isinstance(data, dict): ... return Noneandif not isinstance(oauth, dict): logger.debug("Claude Code credentials source %s has a non-object claudeAiOauth container; skipping", source); return None. The PR body states the invariant — a bad source must become "no credential from this source ... soload_claude_code_credential()falls through to the next source".- The function's return annotation is
ClaudeCodeCredential | None(line 150). - The module docstring (lines 186-193) documents the shape with
"expiresAt": 1773430695128as an int. backend/tests/test_credential_loader.pypins the skip-and-fall-through contract for the container case:test_load_claude_code_credential_ignores_malformed_oauth_containerassertsload_claude_code_credential() is None, andtest_load_claude_code_credential_falls_back_to_default_when_override_container_is_malformedasserts the loader moves on to~/.claude/.credentials.json. Neither parametrization includes a non-numericexpiresAt.
Steps to reproduce
No network or real credentials needed; CLAUDE_CODE_CREDENTIALS_PATH points at a scratch file.
import json, os, tempfile
from pathlib import Path
tmp = Path(tempfile.mkdtemp())
os.environ.pop("CLAUDE_CODE_OAUTH_TOKEN", None)
os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
os.environ["HOME"] = str(tmp)
override = tmp / "credentials.json"
# also try None, and the baseline int / absent cases
override.write_text(json.dumps({"claudeAiOauth": {"accessToken": "sk-ant-oat01-override",
"refreshToken": "sk-ant-ort01-override",
"expiresAt": "1773430695128"}}))
os.environ["CLAUDE_CODE_CREDENTIALS_PATH"] = str(override)
from deerflow.models.credential_loader import load_claude_code_credential
print(load_claude_code_credential())Repro scripts used by the reporters: /tmp/dfbug/repro_claude_expiresat.py, /tmp/dfbug/my_repro_expiresat.py (the second also writes a valid ~/.claude/.credentials.json so the fall-through path is observable).
Running #5473-style direct construction shows the same failure escapes model construction:
ClaudeChatModel(model="claude-sonnet-4-5", anthropic_api_key="your-anthropic-api-key")Relevant logs
Verbatim output, verifier 1 (repro_claude_expiresat.py, HEAD aa4e43a, backend uv env):
int(baseline): returned source=claude-cli-file token=sk-ant-oat01-default
string: RAISED TypeError: '<=' not supported between instances of 'str' and 'int'
null: RAISED TypeError: '<=' not supported between instances of 'NoneType' and 'int'
missing: returned source=claude-cli-file token=sk-ant-oat01-override
File "credential_loader.py", line 217, in load_claude_code_credential
cred = _extract_claude_code_credential(data, "claude-cli-file")
File "credential_loader.py", line 170, in _extract_claude_code_credential
if cred.is_expired:
File "credential_loader.py", line 58, in is_expired
if self.expires_at <= 0:
TypeError: '<=' not supported between instances of 'str' and 'int'
Verbatim output, verifier 2 (my_repro_expiresat.py, pristine HEAD aa4e43a, backend uv env, no network/key):
=== claim repro, pristine HEAD aa4e43a (backend uv env, no network/key) ===
string expiresAt: RAISED TypeError: '<=' not supported between instances of 'str' and 'int' at if self.expires_at <= 0:
null expiresAt: RAISED TypeError: '<=' not supported between instances of 'NoneType' and 'int' at if self.expires_at <= 0:
ClaudeChatModel(...) -> RAISED TypeError: '<=' not supported between instances of 'str' and 'int'
=== my own repro (/tmp/dfbug/my_repro_expiresat.py, fallback file present) ===
expiresAt=str : RAISED TypeError ... credential_loader.py:58 if self.expires_at <= 0:
expiresAt=null : RAISED TypeError ... credential_loader.py:58
expiresAt=int : -> ('claude-cli-file','sk-ant-oat01-override',4102444800000); expiresAt=absent -> (...,0)
Reproduced a third time by the reporter of this issue with uv run python /tmp/dfbug/repro_claude_expiresat.py at the same HEAD, same three lines.
Root cause
backend/packages/harness/deerflow/models/credential_loader.py:166 — expires_at=oauth.get("expiresAt", 0) copies an unvalidated JSON value into ClaudeCodeCredential.expires_at, and credential_loader.py:58 (if self.expires_at <= 0:) then compares it to an int. A non-numeric expiresAt raises out of load_claude_code_credential() instead of the source being skipped.
Environment
- DeerFlow:
main@aa4e43a2bcc8ffc3838b012c4583f561a0e199be - How are you running DeerFlow?: Other (backend unit/dev environment; no sandbox or gateway involved)
- OS: macOS 26.6.2 (arm64, zsh)
- Python: 3.12.14 (backend
uvenvironment); the repro does not depend on the Python version - Node.js / pnpm: not involved
- uv: backend
uvenvironment used viauv run - Support bundle: not applicable — this is a pure library-level loader bug reproducible from a single JSON file, with no local config, sandbox, or gateway state involved
Proposed approach
Validate/coerce expiresAt at the point of extraction, mirroring the sibling guards already in this function, so a malformed value degrades to "unknown expiry" (the existing expires_at <= 0 path) and the loop continues to the next source; and add {"expiresAt": "..."} / {"expiresAt": None} to the parametrized malformed test.
Happy to open a PR with that change against credential_loader.py if it's useful — say the word and I'll send it.
Related issues / PRs
- [bug] Claude credential loader raises AttributeError when ~/.claude/.credentials.json has a non-object claudeAiOauth container #5473 — original report of the non-object
claudeAiOauthcontainer aborting model construction. - fix(models): guard Claude credential loader against malformed claudeAiOauth (#5473) #5494 (
bec423188, merged) — the fix that added the sibling skip guards and the tests this report relies on as the contract. - fix(models): degrade a non-object Codex auth file to no credential #5584, fix(models): reject non-object JSON in the shared credential file loader #5553 — open PRs about a non-object top-level / container JSON. In both,
{"claudeAiOauth": {"accessToken": ..., "expiresAt": "x"}}is a dict at both levels, so neither touchescredential_loader.py:58. - fix(doctor): validate CLI credential contents #5567 —
scripts/doctor.pyonly; does not modify the runtime loader. - No existing issue or PR was found for a non-numeric
expiresAt(searches forexpiresAt,is_expired,credential).
AI assistance disclosure
This issue was drafted with Claude Code assistance; the failure was independently reproduced by three separate runs on the same commit, and a human has read, understands, and takes responsibility for this report.
Activity
BlueX888 commentedon Sep 19, 2026
Reproduced this. Root cause: backend/packages/harness/deerflow/models/credential_loader.py:166 copies an unvalidated expiresAt into ClaudeCodeCredential.expires_at, and is_expired (line 58) then compares it to an int, so the TypeError escapes load_claude_code_credential() instead of skipping the source.
Minimal reproduction:
Observed output:
Fix is ready on
BlueX888:fix/prep-claude-credential-nonnumeric-expiresat-typeerror(commitf2104139). Validate expiresAt at extraction and skip the source with a debug log when it is not a number, matching the sibling malformed-container guards, so the lookup falls through to the next source.Verified with:
cd backend && PYTHONPATH=. uv run pytest tests/test_credential_loader.pyHappy to open the PR once you confirm the approach or assign this to me.