You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[bug] Claude credential loader raises TypeError instead of skipping the source when claudeAiOauth.expiresAt is a string or null #5589
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.
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:
@propertydefis_expired(self) ->bool:
ifself.expires_at<=0:
returnFalsereturntime.time() *1000>self.expires_at-60_000# 1 min buffer
If 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 missingexpiresAt 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.
The function's return annotation is ClaudeCodeCredential | None (line 150).
The module docstring (lines 186-193) documents the shape with "expiresAt": 1773430695128 as an int.
backend/tests/test_credential_loader.py pins the skip-and-fall-through contract for the container case: test_load_claude_code_credential_ignores_malformed_oauth_container asserts load_claude_code_credential() is None, and test_load_claude_code_credential_falls_back_to_default_when_override_container_is_malformed asserts the loader moves on to ~/.claude/.credentials.json. Neither parametrization includes a non-numeric expiresAt.
Steps to reproduce
No network or real credentials needed; CLAUDE_CODE_CREDENTIALS_PATH points at a scratch file.
importjson, os, tempfilefrompathlibimportPathtmp=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 casesoverride.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)
fromdeerflow.models.credential_loaderimportload_claude_code_credentialprint(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:
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 uv environment); the repro does not depend on the Python version
Node.js / pnpm: not involved
uv: backend uv environment used via uv 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.
No existing issue or PR was found for a non-numeric expiresAt (searches for expiresAt, 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.
Problem summary
The Claude credential loader raises
TypeErrorinstead of skipping the source when a credentials file'sclaudeAiOauth.expiresAtis a string ornull, which breaks everyClaudeChatModelconstruction.Affected area(s)
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 malformedexpiresAtvalue._extract_claude_code_credentialcopiesexpiresAtout of the JSON with no type check:and then evaluates
cred.is_expired:If the file contains
"expiresAt": "1773430695128"(a string) or"expiresAt": null, line 58 (if self.expires_at <= 0:) raisesTypeErrorout ofload_claude_code_credential(). It is not caught, so the loop never advances to~/.claude/.credentials.json.This is also internally inconsistent: a missing
expiresAtfalls back to the dataclass default0, whichis_expiredexplicitly tolerates (if self.expires_at <= 0: return False), while an explicitnull— which means the same "unknown expiry" — raises.Reachability:
load_claude_code_credential()is called fromClaudeChatModel.model_post_init(backend/packages/harness/deerflow/models/claude_provider.py:91) with notry/except, and perAGENTS.mda 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
expiresAtis 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, soload_claude_code_credential()either returns a credential from a later source or returnsNone. 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".ClaudeCodeCredential | None(line 150)."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_PATHpoints at a scratch file.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.jsonso the fall-through path is observable).Running
#5473-style direct construction shows the same failure escapes model construction:Relevant logs
Verbatim output, verifier 1 (
repro_claude_expiresat.py, HEADaa4e43a, backend uv env):Verbatim output, verifier 2 (
my_repro_expiresat.py, pristine HEADaa4e43a, backend uv env, no network/key):Reproduced a third time by the reporter of this issue with
uv run python /tmp/dfbug/repro_claude_expiresat.pyat 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 intoClaudeCodeCredential.expires_at, andcredential_loader.py:58(if self.expires_at <= 0:) then compares it to an int. A non-numericexpiresAtraises out ofload_claude_code_credential()instead of the source being skipped.Environment
main@aa4e43a2bcc8ffc3838b012c4583f561a0e199beuvenvironment); the repro does not depend on the Python versionuvenvironment used viauv runProposed approach
Validate/coerce
expiresAtat the point of extraction, mirroring the sibling guards already in this function, so a malformed value degrades to "unknown expiry" (the existingexpires_at <= 0path) 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.pyif it's useful — say the word and I'll send it.Related issues / PRs
claudeAiOauthcontainer aborting model construction.bec423188, merged) — the fix that added the sibling skip guards and the tests this report relies on as the contract.{"claudeAiOauth": {"accessToken": ..., "expiresAt": "x"}}is a dict at both levels, so neither touchescredential_loader.py:58.scripts/doctor.pyonly; does not modify the runtime loader.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.