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
72 changes: 72 additions & 0 deletions docs/examples/provider_fields.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# pytest: unit
"""Emit a provider-specific wire field without a core-type change.

A component author can attach `provider_fields` to a `Message` to declare extra
keys that Mellea does not model, targeted at a specific provider. The fields are
merged into the wire message at serialization time. Mellea's known fields always
win on a collision, and a declaration that names a provider the request never
reaches raises a `ValueError` (add `"*"` to opt out of that check).

See issue #1565 for the design.
"""

import pytest

from mellea.helpers.openai_compatible_helpers import message_to_openai_message
from mellea.stdlib.components import Message


def targeted_provider_field() -> dict:
"""Attach an OpenAI-only `prediction` field and serialize for OpenAI.

The `"openai"` key matches the OpenAI wire family (openai, litellm, watsonx,
huggingface), so the field lands on the wire message for any of them.
"""
msg = Message(
"user",
"Refactor this function.",
provider_fields={"openai": {"prediction": {"type": "content"}}},
)
wire = message_to_openai_message(msg, provider="openai")
assert wire["prediction"] == {"type": "content"}
return wire


def portable_field_with_wildcard() -> dict:
"""Use `"*"` to declare a field valid on every backend.

A `"*"` target never raises on a provider mismatch — it is the author's
portability contract that the field is safe to send everywhere.
"""
msg = Message("user", "Hello", provider_fields={"*": {"metadata_tag": "demo"}})
wire = message_to_openai_message(msg, provider="openai")
assert wire["metadata_tag"] == "demo"
return wire


def known_fields_always_win() -> dict:
"""An author key that collides with a Mellea-known field is dropped."""
msg = Message(
"user",
"real content",
provider_fields={"openai": {"content": "hijacked", "extra": "kept"}},
)
wire = message_to_openai_message(msg, provider="openai")
assert wire["content"] == "real content" # Mellea's field wins
assert wire["extra"] == "kept" # non-colliding author field lands
return wire


def provider_mismatch_raises() -> None:
"""Targeting a provider the request never reaches is a hard error."""
msg = Message("user", "Hi", provider_fields={"ollama": {"keep_alive": "5m"}})
with pytest.raises(ValueError):
message_to_openai_message(msg, provider="openai")


if __name__ == "__main__":
print("Targeted field:", targeted_provider_field())
print("Wildcard field:", portable_field_with_wildcard())
print("Known fields win:", known_fields_always_win())
provider_mismatch_raises()
print("Provider mismatch raised as expected.")
7 changes: 6 additions & 1 deletion mellea/backends/huggingface.py
Original file line number Diff line number Diff line change
Expand Up @@ -655,7 +655,12 @@ async def _generate_from_intrinsic(
# conversation, not multi-turn generation, so reasoning is never replayed
# here (no `replay_reasoning=`). The HF chat path serializes via
# `to_chat`/`apply_chat_template`, not this helper.
conversation.extend([message_to_openai_message(m) for m in ctx_as_message_list])
conversation.extend(
[
message_to_openai_message(m, provider=self._provider)
for m in ctx_as_message_list
]
)

docs = messages_to_docs(ctx_as_message_list)

Expand Down
4 changes: 3 additions & 1 deletion mellea/backends/litellm.py
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,9 @@ async def _generate_from_chat_context_standard(
replay_flags = should_replay_reasoning(messages, self._provider)
conversation.extend(
[
message_to_openai_message(m, self.formatter, replay_reasoning=replay)
message_to_openai_message(
m, self.formatter, replay_reasoning=replay, provider=self._provider
)
for m, replay in zip(messages, replay_flags)
]
)
Expand Down
6 changes: 6 additions & 0 deletions mellea/backends/ollama.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
DEFAULT_CHUNK_TIMEOUT,
ClientCache,
get_current_event_loop,
merge_provider_fields,
send_to_queue,
should_replay_reasoning,
)
Expand Down Expand Up @@ -548,6 +549,11 @@ async def generate_from_chat_context(
tool_name = m.tool_name or getattr(m, "name", None)
if tool_name is not None:
message_dict["tool_name"] = tool_name
# Merge any author-declared provider fields (Mellea's known fields win;
# a mismatched target raises). Must run after the known fields are set.
message_dict = merge_provider_fields(
message_dict, m.provider_fields, self._provider
)
conversation.append(message_dict)

# Append tool call information if applicable.
Expand Down
8 changes: 6 additions & 2 deletions mellea/backends/openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -760,7 +760,9 @@ async def _generate_from_intrinsic(
# conversation, not multi-turn generation, so reasoning is never replayed
# here (no `replay_reasoning=`) — unlike the chat path in
# `_generate_from_context`, which applies `should_replay_reasoning`.
conversation.extend([message_to_openai_message(m) for m in messages])
conversation.extend(
[message_to_openai_message(m, provider=self._provider) for m in messages]
)

docs = messages_to_docs(messages)

Expand Down Expand Up @@ -992,7 +994,9 @@ async def _generate_from_chat_context_standard(
replay_flags = should_replay_reasoning(messages, self._provider)
conversation.extend(
[
message_to_openai_message(m, self.formatter, replay_reasoning=replay)
message_to_openai_message(
m, self.formatter, replay_reasoning=replay, provider=self._provider
)
for m, replay in zip(messages, replay_flags)
]
)
Expand Down
4 changes: 4 additions & 0 deletions mellea/backends/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from ..core import Context, MelleaLogger, ModelToolCall, Span
from ..core.base import AbstractMelleaTool, ModelOutputThunk
from ..formatters import ChatFormatter
from ..helpers import merge_provider_fields
from ..stdlib.components import Message
from .tools import parse_tools, validate_tool_arguments

Expand Down Expand Up @@ -112,6 +113,9 @@ def to_chat(
msg_dict["tool_calls"] = m.tool_calls
if m.tool_call_id:
msg_dict["tool_call_id"] = m.tool_call_id
# Merge any author-declared provider fields (Mellea's known fields win;
# a mismatched target raises). Must run after the known fields are set.
msg_dict = merge_provider_fields(msg_dict, m.provider_fields, "huggingface")
ctx_as_conversation.append(msg_dict)

# Check that we ddin't accidentally end up with CBlocks. Only string values can
Expand Down
6 changes: 6 additions & 0 deletions mellea/backends/watsonx.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
chat_completion_delta_merge,
extract_model_tool_requests,
get_current_event_loop,
merge_provider_fields,
send_to_queue,
should_replay_reasoning,
)
Expand Down Expand Up @@ -432,6 +433,11 @@ async def generate_from_chat_context(
message_dict["tool_call_id"] = m.tool_call_id
if replay and m.thinking:
message_dict["reasoning_content"] = m.thinking
# Merge any author-declared provider fields (Mellea's known fields win;
# a mismatched target raises). Must run after the known fields are set.
message_dict = merge_provider_fields(
message_dict, m.provider_fields, self._provider
)
conversation.append(message_dict)

if _format is not None:
Expand Down
8 changes: 8 additions & 0 deletions mellea/core/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1970,6 +1970,13 @@ class TemplateRepresentation:
tool_name (str | None): For a `role="tool"` component, the name of the tool
whose result this message carries (e.g. Ollama's tool-result turn keys on
it). Defaults to `None`.
provider_fields (dict[str, dict[str, Any]] | None): Optional author-declared
extra wire fields, keyed by provider target. Each outer key is a provider
(matched against the backend's provider string, with the `"openai"`
wire-family alias and a `"*"` wildcard); each inner dict holds fields
merged into the wire message for that target. Mellea's known fields always
win on a key collision. A named target that the request does not hit (and
no `"*"`) is a hard error at serialization. Defaults to `None`.

"""

Expand All @@ -1991,6 +1998,7 @@ class TemplateRepresentation:
tool_calls: list[dict[str, Any]] | None = None
tool_call_id: str | None = None
tool_name: str | None = None
provider_fields: dict[str, dict[str, Any]] | None = None


@dataclass
Expand Down
4 changes: 4 additions & 0 deletions mellea/helpers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@
)
from .event_loop_helper import _run_async_in_thread
from .openai_compatible_helpers import (
OPENAI_COMPATIBLE_WIRE_PROVIDERS,
chat_completion_delta_merge,
extract_model_tool_requests,
merge_provider_fields,
message_to_openai_message,
messages_to_docs,
should_replay_reasoning,
Expand All @@ -35,6 +37,7 @@

__all__ = [
"DEFAULT_CHUNK_TIMEOUT",
"OPENAI_COMPATIBLE_WIRE_PROVIDERS",
"ClientCache",
"_ServerType",
"_run_async_in_thread",
Expand All @@ -43,6 +46,7 @@
"extract_model_tool_requests",
"get_current_event_loop",
"is_vllm_server_with_structured_output",
"merge_provider_fields",
"message_to_openai_message",
"messages_to_docs",
"send_to_queue",
Expand Down
93 changes: 91 additions & 2 deletions mellea/helpers/openai_compatible_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,91 @@
from pydantic import BaseModel

from ..core.base import AudioBlock, AudioUrlBlock, ImageUrlBlock
from ..core.utils import MelleaLogger

if TYPE_CHECKING:
from ..core import Formatter, ModelToolCall
from ..core.base import AbstractMelleaTool, ModelOutputThunk
from ..stdlib.components import Document, Message


# The providers whose request/wire serialization is OpenAI-compatible — the set a
# `provider_fields` `{"openai": ...}` declaration reaches. This is the *serialization*
# family: `message_to_openai_message` is the shared serializer for `openai`, `litellm`,
# and `huggingface` (huggingface.py), and `watsonx` emits the same wire shape via its
# own inline loop. It is deliberately NOT `Message._parse`'s response tuple
# `("openai", "watsonx", "litellm")`, which groups by *response* shape and excludes HF
# (HF returns token tensors, not a `choices[0].message` dict). Keeping these separate
# prevents a `{"openai": ...}` declaration from wrongly raising on the HuggingFace path.
OPENAI_COMPATIBLE_WIRE_PROVIDERS = frozenset(
{"openai", "litellm", "watsonx", "huggingface"}
)


def merge_provider_fields(
base: dict[str, Any],
provider_fields: dict[str, dict[str, Any]] | None,
provider: str,
) -> dict[str, Any]:
"""Merge author-declared `provider_fields` into a wire message dict.

A key matches `provider` iff it is `"*"`, equals `provider` exactly, or is
`"openai"` and `provider` is in `OPENAI_COMPATIBLE_WIRE_PROVIDERS`. Fields from
every matching key are merged into `base`, but only for keys `base` did not
already set — Mellea's known fields always win, and a dropped colliding key is
debug-logged. `"*"` always matches, so its presence never triggers the mismatch
error.

Args:
base: The wire message dict built from Mellea's known fields; mutated and
returned.
provider_fields: The author's provider-keyed extra fields, or `None`.
provider: The provider string of the backend performing serialization.

Returns:
`base`, with fields from every matching provider key merged in.

Raises:
ValueError: If `provider_fields` is non-empty, contains no `"*"`, and no key
matches `provider` — the component targeted a backend it did not hit.
"""
if not provider_fields:
return base

def _matches(key: str) -> bool:
return (
key == "*"
or key == provider
or (key == "openai" and provider in OPENAI_COMPATIBLE_WIRE_PROVIDERS)
)

matched_any = False
for key, fields in provider_fields.items():
if not _matches(key):
continue
matched_any = True
for field, value in fields.items():
if field in base:
MelleaLogger.get_logger().debug(
"provider_fields[%r][%r] collides with a Mellea-known field; "
"dropping the author value (known field wins).",
key,
field,
)
continue
base[field] = value

if not matched_any:
raise ValueError(
f"provider_fields declares target(s) {sorted(provider_fields)} but the "
f"request is running on provider {provider!r}, which none of them match. "
'Add a "*" key to declare the field valid on every backend, or target the '
"provider this component actually runs on."
)

return base


class ToolCallFunction(TypedDict):
"""Function details in a tool call."""

Expand Down Expand Up @@ -244,7 +322,11 @@ def should_replay_reasoning(


def message_to_openai_message(
msg: Message, formatter: Formatter | None = None, *, replay_reasoning: bool = False
msg: Message,
formatter: Formatter | None = None,
*,
replay_reasoning: bool = False,
provider: str = "openai",
) -> dict:
"""Serialise a Mellea `Message` to the format required by OpenAI-compatible API providers.

Expand All @@ -258,6 +340,10 @@ def message_to_openai_message(
the provider receives the model's prior reasoning. Defaults to `False`
(reasoning is stripped), preserving the historical behaviour; callers
decide per-turn via their replay policy (see `should_replay_reasoning`).
provider: The calling backend's provider string, used to match the message's
`provider_fields` declaration. Defaults to `"openai"`; OpenAI-compatible
callers pass their own provider (`"litellm"`, `"watsonx"`, `"huggingface"`)
so an `"openai"`-family declaration still reaches the wire.

Returns:
A dict with `"role"` and `"content"` fields. When the message carries
Expand All @@ -277,6 +363,8 @@ def message_to_openai_message(
ValueError: If the message contains an `AudioUrlBlock`. The OpenAI Chat
Completions audio schema does not support audio by URL; fetch the
audio and pass it as an `AudioBlock` with base64 data instead.
ValueError: If the message's `provider_fields` names a target that does not
match `provider` and includes no `"*"` (see `merge_provider_fields`).
"""
# NOTE: `self.formatter.to_chat_messages` explicitly skips `Message` objects. However, we need
# to print `Message`s to correctly serialize any documents with the message. Do the printing here.
Expand Down Expand Up @@ -346,7 +434,8 @@ def message_to_openai_message(

if replay_reasoning and msg.thinking:
result["reasoning_content"] = msg.thinking
return result

return merge_provider_fields(result, msg.provider_fields, provider)


def messages_to_docs(msgs: list[Message]) -> list[dict[str, str]]:
Expand Down
Loading
Loading