Skip to content

fix(models): tolerate null template keys in the legacy thinking disable path - #6838

Merged
WillemJiang merged 2 commits into
bytedance:mainfrom
BlueX888:fix/prep-factory-legacy-thinking-null-extra-body-attributeerror
Oct 14, 2026
Merged

WillemJiang merged 2 commits into
bytedance:mainfrom
BlueX888:fix/prep-factory-legacy-thinking-null-extra-body-attributeerror

Conversation

@BlueX888

Copy link
Copy Markdown
Contributor

Why

A config.yaml profile like this crashed create_chat_model with an opaque AttributeError:

when_thinking_enabled:
  extra_body:        # blank YAML key -> explicit null
  thinking:
    type: enabled

ModelConfig.when_thinking_enabled is a plain dict | None with no per-key validation (backend/packages/harness/deerflow/config/model_config.py:166-169), so the loader accepts the profile silently. On the legacy (no reasoning: contract) disable path, _apply_legacy_thinking_settings read the template with effective_wte.get("extra_body", {}) (backend/packages/harness/deerflow/models/factory.py:232) — dict.get(key, {}) only defaults an absent key, so the explicit None came back and the chained .get raised AttributeError: 'NoneType' object has no attribute 'get'.

thinking_enabled=False is the default at every create_chat_model call site (title generation, summarization, subagent and lead-agent builds), so the crash hit the first model build, not an exotic flag combination. The sibling accesses in the same module already tolerate the identical shape — _infer_dialect (factory.py:162) and _apply_contract_thinking_settings (factory.py:274) both use effective_wte.get("extra_body") or {} — so only the legacy path diverged: the same profile under a reasoning: contract built fine.

What changed

  • The three template reads on the legacy disable path (factory.py:232, :236, :239) now use (effective_wte.get(key) or {}), matching the guarded sibling accesses: a null extra_body / thinking template key falls through the dialect heuristics exactly like an absent one.
  • No behavior change for values that did not already crash: truthy mappings pass through untouched, and a falsy non-mapping crashed the old chained .get too.
  • New regression test test_legacy_null_template_values_build_on_both_thinking_paths covering both thinking directions and all three null template shapes.

Surface area

  • Frontend UI
  • Backend API
  • Agents / LangGraph
  • Sandbox
  • Skills
  • Dependencies
  • Default behavior change
  • Docs / tests / CI only

(Change is confined to the model factory under backend/packages/harness/deerflow/models/; no previously-working configuration changes behavior — only previously-crashing profile shapes build.)

Bug fix verification

  • Test path that reproduces the bug: backend/tests/test_model_factory.py::test_legacy_null_template_values_build_on_both_thinking_paths
  • Did it go red on main and green on this branch? Yes. On main: 3 failed, 1 passed — AttributeError: 'NoneType' object has no attribute 'get' at factory.py:232 (null extra_body) and factory.py:239 (null thinking); the thinking-on case was never broken, which matches the contract path already tolerating the same shape. On this branch: 4 passed.

Validation

cd backend

# new regression test
PYTHONPATH=. PYTHONIOENCODING=utf-8 PYTHONUTF8=1 uv run pytest tests/test_model_factory.py::test_legacy_null_template_values_build_on_both_thinking_paths -q
# -> 4 passed in 1.39s

# full factory test module
PYTHONPATH=. PYTHONIOENCODING=utf-8 PYTHONUTF8=1 uv run pytest tests/test_model_factory.py -q
# -> 163 passed, 1 warning in 2.45s

# lint + format
make lint
# -> ruff check: All checks passed!
# -> ruff format --check: 2048 files already formatted

Full offline suite make test (4 parallel shards): 7510 passed, 6 failed, 131 skipped. All six failures are pre-existing on unpatched main and unrelated to this change: five in tests/test_web_fetch_relative_links.py (byte-identical failure sets when the file is run on patched vs unpatched trees) and one load-flake in tests/test_scheduled_goal_slow_rollback.py that passes 21 passed, 14 skipped in isolation.

AI assistance

Tool(s) used: Claude Code (ZCode agent)

How you used it: AI located the crash by code inspection, wrote the regression test and the three-line guard fix, and ran the red/green validation quoted above; the final diff and test results were reviewed before opening this PR.

  • I've read and understand every line of this change and take responsibility for it — it's not unreviewed AI output.

…le path

`ModelConfig.when_thinking_enabled` is a plain `dict | None` with no
per-key validation, so a template key left blank in config.yaml
(`extra_body:`, `thinking:`) loads as an explicit None. The legacy
disable path read those keys with `effective_wte.get(key, {})`, whose
default only applies to an absent key — so it called `.get` on None and
every create_chat_model for the profile crashed with
AttributeError: 'NoneType' object has no attribute 'get', on the
default thinking_enabled=False path (title generation, summarization,
subagent and lead-agent builds).

Guard the three reads with the same `or {}` idiom the sibling accesses
in this module already use (`_infer_dialect`, the contract path), so a
null value falls through the dialect heuristics like an absent one and
the legacy path matches the contract path, which already tolerates the
same profile shape.
@github-actions github-actions Bot added area:backend Gateway / runtime / core backend under backend/ risk:medium Medium risk: regular code changes size/S PR changes 20-100 lines labels Oct 13, 2026

@willem-bd willem-bd left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at head 0fb4b3b. The fix is correct and minimal: all three legacy disable-path template reads now treat an explicit null (blank YAML key) like an absent one, matching the guarded sibling accesses in _infer_dialect and the contract path, and no previously-working configuration changes behavior. I verified the new test's four parametrized expectations against the head expressions by simulation and they all check out. One residual gap worth a follow-up, posted inline: the same crash class survives one level deeper on line 232, where the inner .get("thinking", {}) still uses the default-arg pattern and raises on extra_body.thinking: null.

# User-provided disable settings win on conflicts; profile keys survive.
_merge_thinking_payload(settings, model_config.when_thinking_disabled)
elif has_thinking_settings and effective_wte.get("extra_body", {}).get("thinking", {}).get("type"):
elif has_thinking_settings and (effective_wte.get("extra_body") or {}).get("thinking", {}).get("type"):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix guards the top-level template keys, but the same crash class survives one level deeper: a blank nested key — when_thinking_enabled: {extra_body: {thinking: }}, which YAML loads as {"extra_body": {"thinking": None}} — still hits .get("thinking", {}) here and raises AttributeError: 'NoneType' object has no attribute 'get', because .get(key, default) only defaults an absent key. The neighboring reads already tolerate this shape: the vLLM elif on line 236 uses .get("chat_template_kwargs") or {}, and _infer_dialect (line 162-164) guards with isinstance. Minimal consistent fix: ((effective_wte.get("extra_body") or {}).get("thinking") or {}).get("type") — and consider adding a {"extra_body": {"thinking": None}} case to the parametrize list so the nested-null shape stays covered.

@github-actions github-actions Bot added the area:docs Documentation and Markdown only label Oct 13, 2026
@WillemJiang WillemJiang added this to the 2.2.0 milestone Oct 14, 2026
@WillemJiang
WillemJiang merged commit a4be043 into bytedance:main Oct 14, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:backend Gateway / runtime / core backend under backend/ area:docs Documentation and Markdown only risk:medium Medium risk: regular code changes size/S PR changes 20-100 lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants