fix(backends): merge intrinsic extra_body once - #1300
Conversation
The intrinsic OpenAI path passed extra_body explicitly and also let user-supplied extra_body remain in api_params, so model_options containing extra_body raised TypeError before the request was sent. Pop user extra_body before updating api_params and merge it into the intrinsic extra_body, preserving chat_template_kwargs from both sides. Fixes generative-computing#1241 Assisted-by: Codex Signed-off-by: Vincent Gao <gaobing1230@gmail.com>
|
Thanks for this, @gaoflow — logic looks correct and matches the #1204 pattern. Still needs the items from @ajbozarth's review above before merging though. Also found the same Let me know if you'd like a hand getting this over the line. |
Both the intrinsic and standard chat paths merged user extra_body with duplicated logic; the intrinsic copy had dropped the comments explaining the shallow copy and why a shallow merge is safe. Extract one helper so they stay in sync and the reasoning lives in a single place. Apply it to _generate_from_raw as well, which passed extra_body explicitly while also spreading backend-specific params that can contain it - the same TypeError as generative-computing#1241 on the completions endpoint. Add tests for the merge helper, for the intrinsic path without THINKING, and for the completions path. Assisted-by: Claude Code Signed-off-by: Vincent Gao <gaobing1230@gmail.com>
|
All of @ajbozarth's items are in a1a58b2, and I folded in the Confirmed it's real and still unfixed on Added |
planetf1
left a comment
There was a problem hiding this comment.
All three review comments addressed and verified — helper extraction, docstring rationale, and new tests all check out. Ruff, mypy, and the full openai backend test suite pass. LGTM.
planetf1
left a comment
There was a problem hiding this comment.
Follow-up pass after a second review surfaced a few consistency/documentation nits on top of the already-resolved threads. Nothing blocking here — flagging for optional follow-up.
| user: `extra_body` taken from the caller's model_options, or None | ||
|
|
||
| Returns: | ||
| a new dict; `base` and `user` are left unmodified |
There was a problem hiding this comment.
NIT: the docstring says the return value leaves base unmodified, but when user is None this returns base by identity (lines 452-453), and test_merge_user_extra_body_none_returns_base asserts exactly that with is base. Worth tightening the wording so it doesn't overpromise a copy in that branch.
| a new dict; `base` and `user` are left unmodified | |
| a new dict when `user` is not None; when `user` is None, `base` is returned unmodified (same object), skipping the copy |
| else: | ||
| api_params["reasoning_effort"] = thinking | ||
|
|
||
| extra_body = self._merge_user_extra_body(extra_body, user_extra_body) |
There was a problem hiding this comment.
When ModelOption.THINKING is set, api_params["reasoning_effort"] is derived from it, but a caller overriding chat_template_kwargs.enable_thinking through their own extra_body only touches the CTK side — reasoning_effort still reflects the original thinking value. E.g. THINKING: True plus extra_body.chat_template_kwargs.enable_thinking: False ends up sending reasoning_effort="medium" alongside enable_thinking=False. Not a blocker, but a short comment on the precedence (or a test pinning down the intended behaviour) would help future readers.
| @@ -925,19 +961,9 @@ async def _generate_from_chat_context_standard( | |||
| ) | |||
| user_extra_body = backend_specific.pop("extra_body", None) | |||
| if user_extra_body is not None: | |||
There was a problem hiding this comment.
NIT: this path still guards the merge with if user_extra_body is not None:, while the other two call sites call _merge_user_extra_body unconditionally and rely on its own None short-circuit. Removing the guard here would make all three call sites consistent — only caveat is it would change extra_params["extra_body"] from absent to an always-present (possibly empty) dict when there's nothing to merge, so worth checking nothing downstream depends on the key being absent.
| # shallow merge is safe: chat_template_kwargs is the only nested dict key | ||
| # Mellea writes into extra_body; it is deep-merged separately below |
There was a problem hiding this comment.
NIT: "deep-merged separately below" reads like a forward reference to a later step, but it's actually the very next if block (462-466).
| # shallow merge is safe: chat_template_kwargs is the only nested dict key | |
| # Mellea writes into extra_body; it is deep-merged separately below | |
| # shallow update is safe here: chat_template_kwargs is the only nested | |
| # dict key, and it gets its own key-wise merge in the next block |
| call_kwargs = mock_create.call_args.kwargs | ||
| extra_body = call_kwargs["extra_body"] | ||
| assert extra_body["caller_key"] == "caller-value" | ||
| assert "guided_json" in extra_body or "structured_outputs" in extra_body |
There was a problem hiding this comment.
NIT: worth a short comment on why this is an or — the key name depends on self._use_structured_output_for_raw (openai.py:1220-1223), so it's correct, just non-obvious at a glance.
| assert "guided_json" in extra_body or "structured_outputs" in extra_body | |
| # key name depends on _use_structured_output_for_raw (vLLM vs. guided-decoding backends) | |
| assert "guided_json" in extra_body or "structured_outputs" in extra_body |
|
Re-reviewed and my concerns were all addressed, I'll defer to Nigels review above for the remaining nits. |
|
We're ok to merge this @gaoflow - ok with you? |
Summary
extra_bodyout of intrinsic OpenAI API params before spreading them intocreate().extra_body, preservingdocumentsand combinedchat_template_kwargs.THINKING=Truewith userextra_body.Fixes #1241
Tests
uv run pytest test/backends/test_openai_intrinsics_unit.py test/backends/test_openai_ollama.py -quv run ruff check .uv run ruff format --check .uv run python -m compileall -q mellea/backends/openai.py test/backends/test_openai_intrinsics_unit.pygit diff --check