Skip to content

fix(backends): model_options-level extra_body default is silently clobbered by an unrelated per-call extra_body #1539

Description

@planetf1

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.

Metadata

Metadata

Assignees

Labels

area/backendsProvider-specific work: Ollama, HF, LiteLLM, OpenAI, Bedrock, vLLMbugSomething isn't workingp2Medium/low: minor bugs, niche features, polish, docs, tests, cleanup. Scoped, lower urgency.

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions