Summary
OpenAIBackend's (and other backends') construction-time model_options are merged with per-call model_options via resolve_model_options / merge_model_options in mellea/backends/_options.py. That merge is a flat, top-level dict.update — correct for scalar options (temperature, seed, ...), but wrong for extra_body: if a caller sets a backend-level default under model_options={"extra_body": {...}} (the pattern currently documented in docs/docs/integrations/openai.md) and any per-call model_options["extra_body"] is also supplied — even for something unrelated — the entire backend-level extra_body dict is silently replaced, not merged.
Concretely: a chat_template_kwargs.enable_thinking default set at construction time is silently dropped on any call that also passes its own extra_body, with no error or warning.
Reproduction
from mellea.backends.openai import OpenAIBackend
backend = OpenAIBackend(
model_id="gpt-4o", base_url="http://localhost:8000/v1", api_key="unused",
model_options={"extra_body": {"chat_template_kwargs": {"enable_thinking": False}}},
)
# A per-call extra_body for something entirely unrelated to thinking:
model_opts = backend._simplify_and_merge(
{"extra_body": {"some_unrelated_field": 123}}, is_chat_context=True
)
print(model_opts.get("extra_body"))
# {'some_unrelated_field': 123} <-- enable_thinking silently gone
Concrete use case this breaks: thinking default + an adapter call in the same session
This isn't just an abstract merge-order concern — it's the realistic shape of a session that wants a thinking default and uses an intrinsic/aLoRA adapter for some of its calls. That combination is generally useful on its own (a session-wide reasoning policy shouldn't have to be re-specified per call, and adapter calls need their own per-call routing options), which is exactly why the two are likely to collide in the same session.
OpenAIBackend._generate_from_intrinsic is the concrete trigger: it commonly runs with call-specific model_options (routing to a particular adapter, tuning its options) and it independently writes into extra_body["chat_template_kwargs"] itself (adapter_name, to activate the correct adapter via the chat template). By the time that call-specific model_options reaches _generate_from_intrinsic, it has already been through _simplify_and_merge/resolve_model_options (see mellea/backends/openai.py, the model_opts = self._simplify_and_merge(...) call ahead of the isinstance(action, Intrinsic) dispatch) — so a backend-level thinking default set via model_options={"extra_body": ...} is already gone before the adapter-activation code that would otherwise deep-merge safely with it ever runs. The reproduction above only needs _simplify_and_merge, which is exactly the step every intrinsic call passes through on its way in.
Related work
#1453 / #1536 added OpenAIBackend(default_extra_body=...), a separate construction-time field that is immune to this bug — it's merged only inside OpenAIBackend._merge_user_extra_body, never through resolve_model_options. That's the currently-recommended way to set a persistent extra_body/chat_template_kwargs default. This issue is about the older, already-documented model_options={"extra_body": ...} pattern, and about the shared merge function itself, which is used by more than one backend (mellea/backends/huggingface.py also calls resolve_model_options with backend_defaults=self.model_options).
Proposed fix
In merge_model_options (or a wrapper around it used by resolve_model_options), special-case the extra_body key: deep-merge its chat_template_kwargs sub-dict across backend_defaults and call_options (mirroring the deep-merge OpenAIBackend._merge_user_extra_body already does), while leaving every other model-option key on the existing flat merge — a scalar or a single-purpose dict value (e.g. response_format) should still fully replace, only chat_template_kwargs has multiple independent writers.
Scope
mellea/backends/_options.py (merge_model_options, resolve_model_options)
- Affects any backend that calls
resolve_model_options with a model_options constructor default containing extra_body — currently at least OpenAIBackend and LocalHFBackend.
Tests needed
- A regression test reproducing the snippet above: backend-level
extra_body default + unrelated per-call extra_body → default must survive.
- A test through an actual intrinsic/adapter call, since that's the realistic trigger path.
Docs needed
docs/docs/integrations/openai.md's thinking-mode section currently recommends the affected pattern (model_options={"extra_body": ...}) as the way to control thinking at construction time. Once this is fixed (or in the interim, pointing at default_extra_body instead), that section should be updated.
Summary
OpenAIBackend's (and other backends') construction-timemodel_optionsare merged with per-callmodel_optionsviaresolve_model_options/merge_model_optionsinmellea/backends/_options.py. That merge is a flat, top-leveldict.update— correct for scalar options (temperature,seed, ...), but wrong forextra_body: if a caller sets a backend-level default undermodel_options={"extra_body": {...}}(the pattern currently documented indocs/docs/integrations/openai.md) and any per-callmodel_options["extra_body"]is also supplied — even for something unrelated — the entire backend-levelextra_bodydict is silently replaced, not merged.Concretely: a
chat_template_kwargs.enable_thinkingdefault set at construction time is silently dropped on any call that also passes its ownextra_body, with no error or warning.Reproduction
Concrete use case this breaks: thinking default + an adapter call in the same session
This isn't just an abstract merge-order concern — it's the realistic shape of a session that wants a thinking default and uses an intrinsic/aLoRA adapter for some of its calls. That combination is generally useful on its own (a session-wide reasoning policy shouldn't have to be re-specified per call, and adapter calls need their own per-call routing options), which is exactly why the two are likely to collide in the same session.
OpenAIBackend._generate_from_intrinsicis the concrete trigger: it commonly runs with call-specificmodel_options(routing to a particular adapter, tuning its options) and it independently writes intoextra_body["chat_template_kwargs"]itself (adapter_name, to activate the correct adapter via the chat template). By the time that call-specificmodel_optionsreaches_generate_from_intrinsic, it has already been through_simplify_and_merge/resolve_model_options(seemellea/backends/openai.py, themodel_opts = self._simplify_and_merge(...)call ahead of theisinstance(action, Intrinsic)dispatch) — so a backend-level thinking default set viamodel_options={"extra_body": ...}is already gone before the adapter-activation code that would otherwise deep-merge safely with it ever runs. The reproduction above only needs_simplify_and_merge, which is exactly the step every intrinsic call passes through on its way in.Related work
#1453 / #1536 added
OpenAIBackend(default_extra_body=...), a separate construction-time field that is immune to this bug — it's merged only insideOpenAIBackend._merge_user_extra_body, never throughresolve_model_options. That's the currently-recommended way to set a persistentextra_body/chat_template_kwargsdefault. This issue is about the older, already-documentedmodel_options={"extra_body": ...}pattern, and about the shared merge function itself, which is used by more than one backend (mellea/backends/huggingface.pyalso callsresolve_model_optionswithbackend_defaults=self.model_options).Proposed fix
In
merge_model_options(or a wrapper around it used byresolve_model_options), special-case theextra_bodykey: deep-merge itschat_template_kwargssub-dict acrossbackend_defaultsandcall_options(mirroring the deep-mergeOpenAIBackend._merge_user_extra_bodyalready does), while leaving every other model-option key on the existing flat merge — a scalar or a single-purpose dict value (e.g.response_format) should still fully replace, onlychat_template_kwargshas multiple independent writers.Scope
mellea/backends/_options.py(merge_model_options,resolve_model_options)resolve_model_optionswith amodel_optionsconstructor default containingextra_body— currently at leastOpenAIBackendandLocalHFBackend.Tests needed
extra_bodydefault + unrelated per-callextra_body→ default must survive.Docs needed
docs/docs/integrations/openai.md's thinking-mode section currently recommends the affected pattern (model_options={"extra_body": ...}) as the way to control thinking at construction time. Once this is fixed (or in the interim, pointing atdefault_extra_bodyinstead), that section should be updated.