Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 47 additions & 16 deletions mellea/backends/openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,39 @@ def _make_backend_specific_and_remove(

return model_opts

@staticmethod
def _merge_user_extra_body(
base: dict[str, Any], user: dict[str, Any] | None
) -> dict[str, Any]:
"""Merges a user-supplied `extra_body` into the one Mellea built.

Both must end up in a single `extra_body` value; passing two spreads that
each contain one raises `TypeError` at call time.

Args:
base: the `extra_body` Mellea assembled for this request
user: `extra_body` taken from the caller's model_options, or None

Returns:
a new dict; `base` and `user` are left unmodified

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.

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.

Suggested change
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

"""
if user is None:
return base

# shallow copy so .pop() below doesn't mutate the caller's dict
user = dict(user)
merged = dict(base)
user_ctk = user.pop("chat_template_kwargs", None)
# shallow merge is safe: chat_template_kwargs is the only nested dict key
# Mellea writes into extra_body; it is deep-merged separately below
Comment on lines +459 to +460

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.

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).

Suggested change
# 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

merged.update(user)
if user_ctk is not None:
merged["chat_template_kwargs"] = {
**merged.get("chat_template_kwargs", {}),
**user_ctk,
}
return merged

async def _generate_from_context(
self,
action: Component[C] | CBlock,
Expand Down Expand Up @@ -661,6 +694,7 @@ async def _generate_from_intrinsic(
user_api_params = self._make_backend_specific_and_remove(
model_options, is_chat_context=True
)
user_extra_body = user_api_params.pop("extra_body", None)
api_params.update(user_api_params)

# Map THINKING to the correct backend parameter(s). Two mechanisms:
Expand All @@ -680,6 +714,8 @@ async def _generate_from_intrinsic(
else:
api_params["reasoning_effort"] = thinking

extra_body = self._merge_user_extra_body(extra_body, user_extra_body)

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.

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.


# --- call the OpenAI-compatible API --------------------------------
# The rewriter may add instruction messages where 'role' is a default
# (e.g. UserMessage with role="user"). exclude_unset would drop it,
Expand Down Expand Up @@ -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:

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.

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 copy so .pop() below doesn't mutate the caller's dict
user_extra_body = dict(user_extra_body)
eb = dict(extra_params.get("extra_body") or {})
user_ctk = user_extra_body.pop("chat_template_kwargs", None)
# shallow merge is safe: chat_template_kwargs is the only nested dict
# key Mellea writes into extra_body; it is deep-merged separately below
eb.update(user_extra_body)
if user_ctk is not None:
eb["chat_template_kwargs"] = {
**eb.get("chat_template_kwargs", {}),
**user_ctk,
}
extra_params["extra_body"] = eb
extra_params["extra_body"] = self._merge_user_extra_body(
extra_params.get("extra_body") or {}, user_extra_body
)

chat_response: Coroutine[
Any, Any, ChatCompletion | openai.AsyncStream[ChatCompletionChunk]
Expand Down Expand Up @@ -1204,15 +1230,20 @@ async def _generate_from_raw(

prompts = [self.formatter.print(action) for action in actions]

backend_specific = self._make_backend_specific_and_remove(
model_opts, is_chat_context=False
)
extra_body = self._merge_user_extra_body(
extra_body, backend_specific.pop("extra_body", None)
)

try:
completion_response: Completion = (
await self._async_client.completions.create(
model=self._model_id,
prompt=prompts,
extra_body=extra_body,
**self._make_backend_specific_and_remove(
model_opts, is_chat_context=False
),
**backend_specific,
)
) # type: ignore
except openai.BadRequestError as e:
Expand Down
107 changes: 107 additions & 0 deletions test/backends/test_openai_intrinsics_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import json
import pathlib
from copy import deepcopy
from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch

import pytest
Expand Down Expand Up @@ -479,6 +480,112 @@ async def test_reasoning_effort_bool_true():
)


async def test_user_extra_body_merges_into_intrinsic_extra_body():
"""User extra_body merges with intrinsic adapter keys without duplicate kwargs."""
backend = _make_backend_with_adapter(_SIMPLE_CONFIG)
ctx = _make_context()
mock_create = AsyncMock(return_value=_simple_chat_completion())

mock_client = MagicMock()
mock_client.chat.completions.create = mock_create

with patch.object(
OpenAIBackend,
"_async_client",
new_callable=PropertyMock,
return_value=mock_client,
):
mot, _ = await mfuncs.aact(
Intrinsic("answerability"),
ctx,
backend,
strategy=None,
model_options={
ModelOption.THINKING: True,
"extra_body": {
"guided_json": {"type": "string"},
"chat_template_kwargs": {"caller_key": "caller-value"},
},
},
)
await mot.avalue()

call_kwargs = mock_create.call_args.kwargs
assert "extra_body" in call_kwargs
extra_body = call_kwargs["extra_body"]
assert "documents" in extra_body
assert extra_body["guided_json"] == {"type": "string"}
assert extra_body["chat_template_kwargs"]["adapter_name"] == "answerability"
assert extra_body["chat_template_kwargs"]["enable_thinking"] is True
assert extra_body["chat_template_kwargs"]["caller_key"] == "caller-value"
Comment thread
planetf1 marked this conversation as resolved.
assert call_kwargs["reasoning_effort"] == "medium"


async def test_user_extra_body_without_thinking():
"""User extra_body merges when THINKING is unset; the #1241 reproduction."""
backend = _make_backend_with_adapter(_SIMPLE_CONFIG)
ctx = _make_context()
mock_create = AsyncMock(return_value=_simple_chat_completion())

mock_client = MagicMock()
mock_client.chat.completions.create = mock_create

with patch.object(
OpenAIBackend,
"_async_client",
new_callable=PropertyMock,
return_value=mock_client,
):
mot, _ = await mfuncs.aact(
Intrinsic("answerability"),
ctx,
backend,
strategy=None,
model_options={"extra_body": {"guided_json": {"type": "string"}}},
)
await mot.avalue()

call_kwargs = mock_create.call_args.kwargs
extra_body = call_kwargs["extra_body"]
assert "documents" in extra_body
assert extra_body["guided_json"] == {"type": "string"}
assert extra_body["chat_template_kwargs"]["adapter_name"] == "answerability"
assert "reasoning_effort" not in call_kwargs


async def test_user_extra_body_is_not_mutated():
"""The caller's extra_body dict survives the merge unchanged."""
backend = _make_backend_with_adapter(_SIMPLE_CONFIG)
ctx = _make_context()
mock_create = AsyncMock(return_value=_simple_chat_completion())

mock_client = MagicMock()
mock_client.chat.completions.create = mock_create

user_extra_body = {
"guided_json": {"type": "string"},
"chat_template_kwargs": {"caller_key": "caller-value"},
}
original = deepcopy(user_extra_body)

with patch.object(
OpenAIBackend,
"_async_client",
new_callable=PropertyMock,
return_value=mock_client,
):
mot, _ = await mfuncs.aact(
Intrinsic("answerability"),
ctx,
backend,
strategy=None,
model_options={"extra_body": user_extra_body},
)
await mot.avalue()

assert user_extra_body == original


async def test_reasoning_effort_bool_false():
"""THINKING: False sets chat_template_kwargs.enable_thinking=False; no reasoning_effort."""
backend = _make_backend_with_adapter(_SIMPLE_CONFIG)
Expand Down
84 changes: 84 additions & 0 deletions test/backends/test_openai_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@
_simplify_and_merge, and _make_backend_specific_and_remove.
"""

from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch

import pytest
from openai.types import Completion
from openai.types.chat import ChatCompletion, ChatCompletionChunk, ChatCompletionMessage
from openai.types.chat.chat_completion import Choice
from openai.types.completion_choice import CompletionChoice

from mellea.backends import ModelOption
from mellea.backends.openai import OpenAIBackend
Expand Down Expand Up @@ -315,5 +319,85 @@ async def test_processing_reasoning_content_takes_precedence_over_reasoning(back
assert mot._underlying_value == "answer"


# --- _merge_user_extra_body ---


def test_merge_user_extra_body_none_returns_base(backend):
"""A missing user extra_body leaves the base untouched."""
base = {"documents": ["d"]}
assert backend._merge_user_extra_body(base, None) is base


def test_merge_user_extra_body_user_keys_win(backend):
"""User keys overlay the base, and unrelated base keys survive."""
merged = backend._merge_user_extra_body(
{"documents": ["d"], "guided_json": {"type": "integer"}},
{"guided_json": {"type": "string"}},
)
assert merged == {"documents": ["d"], "guided_json": {"type": "string"}}


def test_merge_user_extra_body_deep_merges_chat_template_kwargs(backend):
"""chat_template_kwargs merges key-wise rather than being replaced wholesale."""
merged = backend._merge_user_extra_body(
{"chat_template_kwargs": {"adapter_name": "answerability"}},
{"chat_template_kwargs": {"caller_key": "caller-value"}},
)
assert merged["chat_template_kwargs"] == {
"adapter_name": "answerability",
"caller_key": "caller-value",
}


def test_merge_user_extra_body_does_not_mutate_inputs(backend):
"""Neither argument is modified; .pop() operates on a copy."""
base = {"chat_template_kwargs": {"adapter_name": "answerability"}}
user = {"chat_template_kwargs": {"caller_key": "caller-value"}}
backend._merge_user_extra_body(base, user)
assert base == {"chat_template_kwargs": {"adapter_name": "answerability"}}
assert user == {"chat_template_kwargs": {"caller_key": "caller-value"}}


async def test_generate_from_raw_merges_user_extra_body(backend):
"""The completions path passes one extra_body, not two spreads (#1241)."""
import pydantic

from mellea.core.base import CBlock
from mellea.stdlib.context import ChatContext

class Answer(pydantic.BaseModel):
value: int

mock_create = AsyncMock(
return_value=Completion(
id="raw-test",
created=0,
model="fake",
object="text_completion",
choices=[CompletionChoice(index=0, finish_reason="stop", text="ok")],
)
)
mock_client = MagicMock()
mock_client.completions.create = mock_create

with patch.object(
OpenAIBackend,
"_async_client",
new_callable=PropertyMock,
return_value=mock_client,
):
await backend._generate_from_raw(
[CBlock(value="what is 1+1?")],
ChatContext(),
format=Answer,
model_options={"extra_body": {"caller_key": "caller-value"}},
)

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

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.

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.

Suggested change
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



if __name__ == "__main__":
pytest.main([__file__, "-v"])
Loading