From 67a91565afcc6f8d7d76114dc9c799a65995c092 Mon Sep 17 00:00:00 2001 From: Nigel Jones Date: Tue, 18 Aug 2026 13:17:52 +0100 Subject: [PATCH 01/10] feat(backends): EmbeddedBinding implements apply_activation; remove render_controls + set_request_adapter (Epic #929 Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EmbeddedBinding gets one method, apply_activation(request, identity), for adapters embedded in the served base model (Granite Switch) — there is no weights lifecycle to prepare/activate/deactivate/release, only a field on the outgoing request. OpenAIBackend's inline isinstance(adapter, EmbeddedIntrinsicAdapter) block that wrote chat_template_kwargs.adapter_name and dropped the rewriter-set model param is now owned by the binding, reached through EmbeddedIntrinsicAdapter.weights. Removes the two dead activation methods the previous scope was built around (AdapterMixin.render_controls, AdapterMixin.set_request_adapter) along with OpenAIBackend's no-op render_controls override — neither had a working implementation or caller. adapter_scope() now rejects a non-WeightsBinding adapter with a clear TypeError instead of an AttributeError, since EmbeddedBinding has no activate()/deactivate() to scope. Assisted-by: Claude Code Signed-off-by: Nigel Jones --- mellea/backends/adapters/_core.py | 182 ++++++++++++++++-- mellea/backends/adapters/adapter.py | 63 ++---- mellea/backends/openai.py | 31 +-- .../test_adapters/test_adapter_mixin.py | 30 ++- .../backends/test_adapters/test_core_types.py | 12 +- .../test_adapters/test_embedded_adapter.py | 13 -- .../test_adapters/test_embedded_binding.py | 153 +++++++++++++++ .../test_embedded_integration.py | 142 ++++++++++++++ 8 files changed, 503 insertions(+), 123 deletions(-) create mode 100644 test/backends/test_adapters/test_embedded_binding.py create mode 100644 test/backends/test_adapters/test_embedded_integration.py diff --git a/mellea/backends/adapters/_core.py b/mellea/backends/adapters/_core.py index 25fd09fa58..c681e49cc8 100644 --- a/mellea/backends/adapters/_core.py +++ b/mellea/backends/adapters/_core.py @@ -12,7 +12,8 @@ Also provides: - :class:`LocalFileBinding` -- :class:`EmbeddedBinding` — stub :class:`WeightsBinding` subclass +- :class:`EmbeddedBinding` — Embedded/Granite Switch binding; `apply_activation`, + no weights lifecycle (issue #1142) - :class:`ServerMediatedBinding` — stub :class:`WeightsBinding` subclass - :class:`AdapterSchemaMismatchError` - :class:`_DictContract`, :class:`_ListContract` — generic, capability-agnostic @@ -37,7 +38,7 @@ import time import warnings from dataclasses import dataclass -from typing import TYPE_CHECKING, ClassVar, Literal +from typing import TYPE_CHECKING, Any, ClassVar, Literal from ...core import Component, MelleaLogger from ...helpers.event_loop_helper import _run_async_in_thread @@ -680,31 +681,169 @@ def _fire_phase_complete(self, phase: str, duration_s: float) -> None: ) -class EmbeddedBinding(WeightsBinding): - """Stub binding for weights embedded in a model artifact.""" +@dataclass +class EmbeddedActivationRequest: + """Mutable outgoing-request state that `EmbeddedBinding.apply_activation` edits. + + Bundles the two dicts an OpenAI-compatible call site builds separately — + `extra_body` and the top-level API call kwargs — so a binding can edit + both in one call. Both are mutated in place; the caller keeps its own + references and can keep layering other edits (tool wiring, thinking mode, + user overrides) on top after `apply_activation` returns. + + Attributes: + extra_body (dict[str, Any]): The provider's `extra_body` payload. + `apply_activation` writes the activation field here (e.g. + `chat_template_kwargs.adapter_name` for Granite Switch). + api_params (dict[str, Any]): Top-level request kwargs (e.g. `model`). + `apply_activation` removes entries an embedded adapter's + activation would make incorrect. + """ + + extra_body: dict[str, Any] + api_params: dict[str, Any] + + +class EmbeddedBinding: + """Weights binding for the Embedded/Granite Switch reality (Epic #929 Phase 2). + + Adapter weights for this reality are already baked into the base model — + there is nothing to download, load, toggle, or unload. The only thing + that varies per call is one field on the outgoing request, so this + binding has a single method, `apply_activation`, rather than the four + `WeightsBinding` lifecycle verbs (see discussion #1486, which rescoped + issue #1142: declaring the four verbs here would mean four raises with + nothing behind them, as the previous stub did). + + Stateless across calls: `apply_activation` reads only its arguments, so + activating one adapter for a call never leaks into the request built for + the next. + + Attributes: + binding_type (ClassVar[str]): `"embedded"`. + source (str): Base model identifier this binding activates adapters + against (e.g. the Hugging Face repo id served by the backend). + Recorded for the future `mellea.adapter_function.source` span + attribute (#1466); not otherwise used by `apply_activation`. + """ binding_type: ClassVar[str] = "embedded" - def prepare(self) -> None: - raise NotImplementedError( - _PHASE_2_NOT_IMPLEMENTED.format(cls="EmbeddedBinding") - ) + def __init__(self, source: str = "") -> None: + """Constructs an EmbeddedBinding. - def activate(self) -> None: - raise NotImplementedError( - _PHASE_2_NOT_IMPLEMENTED.format(cls="EmbeddedBinding") - ) + Args: + source: Base model identifier this binding activates adapters + against. Prefer `from_base_model` when a backend is on hand. + """ + self.source = source - def deactivate(self) -> None: - raise NotImplementedError( - _PHASE_2_NOT_IMPLEMENTED.format(cls="EmbeddedBinding") + @classmethod + def from_base_model(cls, backend: "AdapterMixin") -> "EmbeddedBinding": + """Builds an EmbeddedBinding recording `backend`'s base model as the source. + + Args: + backend: The backend whose base model has the adapter embedded + (e.g. an `OpenAIBackend` pointed at a Granite Switch deployment). + + Returns: + An `EmbeddedBinding` with `source` set to `backend.base_model_name`. + """ + return cls(source=backend.base_model_name) + + def apply_activation( + self, request: EmbeddedActivationRequest, identity: "Identity" + ) -> None: + """Edits `request` so the served model activates `identity`'s adapter. + + Granite Switch (the only Embedded deployment today) reads the + adapter to activate from `chat_template_kwargs["adapter_name"]` in + the chat template. The rewriter config can also set the top-level + `model` parameter to the adapter's name; for an embedded adapter the + real model is the base model already being served, so that value is + dropped here rather than sent to the API. + + Fires `adapter_function_phase_complete` (phase `"activate"`) and + `adapter_function_invocation_complete`, so Embedded calls are counted + by `AdapterFunctionMetricsPlugin` like every other binding. + + Args: + request: The outgoing request state to edit; both of its dicts + are mutated in place. + identity: Identifies the adapter to activate. + """ + started_at = time.monotonic() + chat_template_kwargs = request.extra_body.pop("chat_template_kwargs", {}) or {} + chat_template_kwargs["adapter_name"] = identity.name + request.extra_body["chat_template_kwargs"] = chat_template_kwargs + request.api_params.pop("model", None) + duration_s = time.monotonic() - started_at + + self._fire_phase_complete(identity.name, duration_s) + self._fire_invocation_complete(identity) + + def _fire_phase_complete(self, name: str, duration_s: float) -> None: + """Fires `adapter_function_phase_complete` for the activate phase. + + Args: + name: Adapter function name, used as the metric's `name` field. + duration_s: Wall-clock duration of `apply_activation`'s request edit. + """ + if not has_plugins(HookType.ADAPTER_FUNCTION_PHASE_COMPLETE): + return + + from ...plugins.hooks.adapter_function import ( + AdapterFunctionPhaseCompletePayload, ) - def release(self) -> None: - raise NotImplementedError( - _PHASE_2_NOT_IMPLEMENTED.format(cls="EmbeddedBinding") + try: + payload = AdapterFunctionPhaseCompletePayload( + name=name, phase="activate", duration_ms=duration_s * 1000.0 + ) + hook_coro = invoke_hook(HookType.ADAPTER_FUNCTION_PHASE_COMPLETE, payload) + _run_async_in_thread(hook_coro) + except Exception: + MelleaLogger.get_logger().warning( + f"adapter_function_phase_complete hook dispatch failed for {name!r} " + "during 'activate'; ignoring so it does not turn a completed " + "request edit into an operation failure.", + exc_info=True, + ) + + def _fire_invocation_complete(self, identity: "Identity") -> None: + """Fires `adapter_function_invocation_complete` for a completed activation. + + Args: + identity: Identifies the adapter that was activated. + """ + if not has_plugins(HookType.ADAPTER_FUNCTION_INVOCATION_COMPLETE): + return + + from ...plugins.hooks.adapter_function import ( + AdapterFunctionInvocationCompletePayload, ) + try: + payload = AdapterFunctionInvocationCompletePayload( + name=identity.name, + revision=None, + binding_type=self.binding_type, + adapter_type=identity.adapter_type, + outcome="success", + error=None, + ) + hook_coro = invoke_hook( + HookType.ADAPTER_FUNCTION_INVOCATION_COMPLETE, payload + ) + _run_async_in_thread(hook_coro) + except Exception: + MelleaLogger.get_logger().warning( + "adapter_function_invocation_complete hook dispatch failed for " + f"{identity.name!r}; ignoring so it does not turn a completed " + "activation into an operation failure.", + exc_info=True, + ) + class ServerMediatedBinding(WeightsBinding): """Stub binding for server-managed adapter weights.""" @@ -742,12 +881,15 @@ class Adapter: Attributes: identity (Identity): Name, type, and capability for this adapter. io_contract (IOContract): Prompt builder and output parser. - weights (WeightsBinding): Pluggable weights lifecycle handler. + weights (WeightsBinding | EmbeddedBinding): Pluggable weights handler — + either a `WeightsBinding` (a lifecycle to stage and switch on) or + an `EmbeddedBinding` (nothing to stage; activation edits the + outgoing request instead). """ identity: Identity io_contract: IOContract - weights: WeightsBinding + weights: WeightsBinding | EmbeddedBinding # NOTE(#1516): a construction-time cross-check that `weights.adapter_type` # agrees with `identity.adapter_type` was tried here and backed out. It is the diff --git a/mellea/backends/adapters/adapter.py b/mellea/backends/adapters/adapter.py index 528b284223..d119d13c6d 100644 --- a/mellea/backends/adapters/adapter.py +++ b/mellea/backends/adapters/adapter.py @@ -33,6 +33,7 @@ from ._core import ( Adapter as _AdapterCore, AdapterSchemaMismatchError, + EmbeddedBinding, Identity, LocalFileBinding, WeightsBinding, @@ -598,49 +599,6 @@ def _adapter_activation_lock( """ return contextlib.nullcontext() - def render_controls(self, adapter_qualified_name: str, active: bool) -> None: - """Render or clear the control tokens for a baked-in embedded adapter. - - Embedded/Granite Switch reality only. Weights are already baked into - the model; this only toggles the control-token rendering that - activates or deactivates the adapter's behaviour for subsequent - requests. - - Args: - adapter_qualified_name (str): The `adapter.qualified_name` of the - adapter to activate or deactivate. - active (bool): `True` to render the adapter's control tokens, - `False` to clear them. - - Raises: - NotImplementedError: If this backend's adapter reality is not - Embedded/Granite Switch. - """ - raise NotImplementedError( - f"Backend type {type(self)} does not support render_controls()." - ) - - def set_request_adapter(self, adapter_qualified_name: str) -> None: - """Select the adapter to use for the next request. - - ServerMediated reality only — for servers that accept an adapter - selection per request rather than loading/unloading weights or - toggling control tokens locally. No backend implements this reality - yet. - - Args: - adapter_qualified_name (str): The `adapter.qualified_name` of the - adapter to select. - - Raises: - NotImplementedError: Always — the ServerMediated adapter reality - has no implementation yet. - """ - raise NotImplementedError( - f"Backend type {type(self)} does not support set_request_adapter(); " - "the ServerMediated adapter reality is not implemented yet." - ) - def resolve_adapter(self, name: str) -> _AdapterCore: """Find or lazily register an adapter by capability name. @@ -803,6 +761,9 @@ def adapter_scope(self, adapter: "_AdapterCore | None"): # type: ignore[type-ar adapter: The adapter to activate, or `None` (no-op). Raises: + TypeError: `adapter.weights` is not a `WeightsBinding` (e.g. an + `EmbeddedBinding`, which has no activate()/deactivate() to + scope — call its `apply_activation()` directly instead). BaseException: An error raised by activation, the `with` body, or deactivation. If both the body and deactivation fail, the body error remains primary and the deactivation error is chained. @@ -829,6 +790,16 @@ def adapter_scope(self, adapter: "_AdapterCore | None"): # type: ignore[type-ar binding_type = adapter.weights.binding_type adapter_type = adapter.identity.adapter_type + # adapter_scope drives the WeightsBinding lifecycle (activate/deactivate); + # a binding with no lifecycle (e.g. EmbeddedBinding) activates through its + # own apply_activation() instead (issue #1142) and never reaches this scope. + if not isinstance(adapter.weights, WeightsBinding): + raise TypeError( + f"adapter_scope() requires a WeightsBinding-backed adapter; " + f"{binding_type!r} bindings have no activate()/deactivate() to " + "scope. Call apply_activation() directly instead." + ) + outcome: Literal["success", "schema_error", "error"] = "success" exception: BaseException | None = None activated = False @@ -982,8 +953,8 @@ class EmbeddedIntrinsicAdapter(_AdapterCore): - `io_contract`: the real, declared contract for `intrinsic_name` (issue #1516); no longer a placeholder. - - `weights`: Phase 1 `_ShimWeightsBinding` placeholder; raises - `NotImplementedError` until issue #1142 replaces it. + - `weights`: a real `EmbeddedBinding` (issue #1142); activation + runs through it. """ def __setattr__(self, name: str, value: object) -> None: @@ -1032,7 +1003,7 @@ def __init__(self, intrinsic_name: str, config: dict, technology: str = "lora"): io_contract = get_io_contract(intrinsic_name) - weights = _ShimWeightsBinding() + weights = EmbeddedBinding() _AdapterCore.__init__( self, identity=identity, io_contract=io_contract, weights=weights diff --git a/mellea/backends/openai.py b/mellea/backends/openai.py index 739f5581b7..1990d35131 100644 --- a/mellea/backends/openai.py +++ b/mellea/backends/openai.py @@ -53,6 +53,7 @@ from ..stdlib.requirements import LLMaJRequirement from ..telemetry.context import generate_request_id, with_context from ._options import resolve_model_options +from .adapters._core import EmbeddedActivationRequest, EmbeddedBinding from .adapters.adapter import AdapterInput, AdapterMixin, EmbeddedIntrinsicAdapter from .backend import FormatterBackend from .model_options import ModelOption @@ -325,21 +326,6 @@ def add_adapter(self, adapter: AdapterInput) -> None: adapter.backend = self self._added_adapters[adapter.qualified_name] = adapter - def render_controls(self, adapter_qualified_name: str, active: bool) -> None: - """No-op for embedded adapters — weights are baked into the model. - - Args: - adapter_qualified_name (str): The `adapter.qualified_name` of the - adapter to activate or deactivate. - active (bool): `True` to activate the adapter, `False` to - deactivate it. - """ - MelleaLogger.get_logger().debug( - "render_controls is a no-op for OpenAIBackends (adapter: %s, active: %s)", - adapter_qualified_name, - active, - ) - def list_adapters(self) -> list[str]: """Return qualified names of all registered adapters. @@ -782,14 +768,13 @@ async def _generate_from_intrinsic( if rewriter.parameters: api_params.update(rewriter.parameters) - # Embedded adapters activate via control tokens in the chat template. - if isinstance(adapter, EmbeddedIntrinsicAdapter): - chat_template_kwargs = extra_body.pop("chat_template_kwargs", {}) or {} - chat_template_kwargs["adapter_name"] = action.intrinsic_name - extra_body["chat_template_kwargs"] = chat_template_kwargs - # The rewriter config may set `model` to the adapter name, but - # for embedded adapters the actual model is self._model_id. - api_params.pop("model", None) + # Embedded adapters activate via control tokens in the chat template; + # the binding owns the request edit (issue #1142). + if isinstance(adapter.weights, EmbeddedBinding): + activation_request = EmbeddedActivationRequest( + extra_body=extra_body, api_params=api_params + ) + adapter.weights.apply_activation(activation_request, adapter.identity) # Collect tools if tool_calls is enabled. tools: dict[str, AbstractMelleaTool] = dict() diff --git a/test/backends/test_adapters/test_adapter_mixin.py b/test/backends/test_adapters/test_adapter_mixin.py index 1fd5c1b51d..b04b353c36 100644 --- a/test/backends/test_adapters/test_adapter_mixin.py +++ b/test/backends/test_adapters/test_adapter_mixin.py @@ -5,11 +5,13 @@ Verifies that: - the reality-specific verbs (`load_peft_adapter`, `unload_peft_adapter`, - `remove_adapter`, `activate_peft_adapter`, `deactivate_peft_adapter`, - `render_controls`, `set_request_adapter`) raise `NotImplementedError` by - default on the mixin + `remove_adapter`, `activate_peft_adapter`, `deactivate_peft_adapter`) + raise `NotImplementedError` by default on the mixin - each concrete backend overrides only the verb(s) matching its own adapter reality, leaving the others on the default (raising) implementation + +Embedded/Granite Switch activation is not a mixin verb — it lives on +`EmbeddedBinding.apply_activation` (issue #1142); see test_embedded_binding.py. """ from unittest.mock import MagicMock @@ -26,8 +28,6 @@ "remove_adapter", "activate_peft_adapter", "deactivate_peft_adapter", - "render_controls", - "set_request_adapter", ) @@ -36,10 +36,9 @@ def test_default_reality_specific_verb_raises_not_implemented(verb): """Each reality-specific verb raises NotImplementedError by default on the mixin.""" mock_backend = MagicMock(spec=AdapterMixin) method = getattr(AdapterMixin, verb) - args = ("some_adapter", True) if verb == "render_controls" else ("some_adapter",) with pytest.raises(NotImplementedError): - method(mock_backend, *args) + method(mock_backend, "some_adapter") def test_hf_backend_overrides_only_peft_verbs(): @@ -49,23 +48,16 @@ def test_hf_backend_overrides_only_peft_verbs(): assert "remove_adapter" in vars(LocalHFBackend) assert "activate_peft_adapter" in vars(LocalHFBackend) assert "deactivate_peft_adapter" in vars(LocalHFBackend) - assert "render_controls" not in vars(LocalHFBackend) - assert "set_request_adapter" not in vars(LocalHFBackend) -def test_openai_backend_overrides_only_render_controls(): - """OpenAIBackend (Embedded/Granite Switch reality) overrides render_controls only.""" - assert "render_controls" in vars(OpenAIBackend) +def test_openai_backend_overrides_no_reality_specific_verbs(): + """OpenAIBackend (Embedded/Granite Switch reality) activates through the + binding, not a mixin verb, so it overrides none of the PEFT verbs.""" assert "load_peft_adapter" not in vars(OpenAIBackend) assert "unload_peft_adapter" not in vars(OpenAIBackend) assert "remove_adapter" not in vars(OpenAIBackend) - assert "set_request_adapter" not in vars(OpenAIBackend) - - -def test_no_backend_implements_server_mediated_reality(): - """set_request_adapter has no concrete implementation anywhere yet.""" - assert "set_request_adapter" not in vars(LocalHFBackend) - assert "set_request_adapter" not in vars(OpenAIBackend) + assert "activate_peft_adapter" not in vars(OpenAIBackend) + assert "deactivate_peft_adapter" not in vars(OpenAIBackend) def test_default_adapter_activation_lock_is_a_noop(): diff --git a/test/backends/test_adapters/test_core_types.py b/test/backends/test_adapters/test_core_types.py index 72fe311ac3..f896009eaf 100644 --- a/test/backends/test_adapters/test_core_types.py +++ b/test/backends/test_adapters/test_core_types.py @@ -119,7 +119,7 @@ def deactivate(self) -> None: PartialBinding() # type: ignore[abstract] -@pytest.mark.parametrize("cls", [EmbeddedBinding, ServerMediatedBinding]) +@pytest.mark.parametrize("cls", [ServerMediatedBinding]) @pytest.mark.parametrize("verb", ["prepare", "activate", "deactivate", "release"]) def test_stub_binding_subclasses_raise_not_implemented(cls, verb): binding = cls() @@ -130,7 +130,15 @@ def test_stub_binding_subclasses_raise_not_implemented(cls, verb): def test_local_file_binding_not_a_phase_0_stub(): # LocalFileBinding graduated out of the stub set in Epic #929 Phase 2 # (issue #1141) — see test_local_file_binding.py for its real behavior. - assert LocalFileBinding.prepare is not EmbeddedBinding.prepare + assert LocalFileBinding.prepare is not ServerMediatedBinding.prepare + + +def test_embedded_binding_is_not_a_weights_binding(): + # EmbeddedBinding graduated out of the WeightsBinding stub set in Epic #929 + # Phase 2 (issue #1142) — it has no weights lifecycle at all, so it is not + # even a WeightsBinding subclass. See test_embedded_binding.py for its real + # behavior (`apply_activation`). + assert not isinstance(EmbeddedBinding(), WeightsBinding) def test_adapter_schema_mismatch_error_format(): diff --git a/test/backends/test_adapters/test_embedded_adapter.py b/test/backends/test_adapters/test_embedded_adapter.py index 734cddba08..a70e8352f9 100644 --- a/test/backends/test_adapters/test_embedded_adapter.py +++ b/test/backends/test_adapters/test_embedded_adapter.py @@ -468,19 +468,6 @@ def test_list_adapters(self, backend): ) assert set(backend.list_adapters()) == {"answerability_alora", "citations_lora"} - def test_render_controls_is_noop(self, backend): - """render_controls succeeds silently for embedded adapters.""" - backend.add_adapter( - EmbeddedIntrinsicAdapter( - "answerability", config=_ANSWERABILITY_CONFIG, technology="alora" - ) - ) - # These should not raise. - backend.render_controls("answerability_alora", active=True) - backend.render_controls("answerability_alora", active=False) - # Adapter is still registered after activating/deactivating. - assert "answerability_alora" in backend._added_adapters - def test_base_model_name(self, backend): assert backend.base_model_name == "granite-switch" diff --git a/test/backends/test_adapters/test_embedded_binding.py b/test/backends/test_adapters/test_embedded_binding.py new file mode 100644 index 0000000000..d4a76a471f --- /dev/null +++ b/test/backends/test_adapters/test_embedded_binding.py @@ -0,0 +1,153 @@ +# Copyright IBM Corp. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for EmbeddedBinding (Epic #929 Phase 2, issue #1142). + +No real backend or network access. `apply_activation` is exercised directly +against `EmbeddedActivationRequest` instances built in-test. +""" + +from typing import Literal +from unittest.mock import MagicMock, patch + +import pytest + +from mellea.backends.adapters._core import ( + EmbeddedActivationRequest, + EmbeddedBinding, + Identity, +) + + +def _identity( + name: str = "answerability", adapter_type: Literal["lora", "alora"] = "alora" +) -> Identity: + return Identity(name=name, adapter_type=adapter_type, capability=name) + + +def test_apply_activation_sets_adapter_name(): + binding = EmbeddedBinding() + request = EmbeddedActivationRequest(extra_body={}, api_params={}) + + binding.apply_activation(request, _identity("answerability")) + + assert request.extra_body["chat_template_kwargs"]["adapter_name"] == "answerability" + + +def test_apply_activation_removes_model_param(): + # The rewriter config can set `model` to the adapter name; for an embedded + # adapter the real model is the base model already being served, so this + # must be dropped rather than sent to the API. + binding = EmbeddedBinding() + request = EmbeddedActivationRequest( + extra_body={}, api_params={"model": "answerability_alora", "seed": 1} + ) + + binding.apply_activation(request, _identity("answerability")) + + assert "model" not in request.api_params + assert request.api_params["seed"] == 1 + + +def test_apply_activation_preserves_existing_chat_template_kwargs(): + binding = EmbeddedBinding() + request = EmbeddedActivationRequest( + extra_body={"chat_template_kwargs": {"enable_thinking": True}}, api_params={} + ) + + binding.apply_activation(request, _identity("citations")) + + ctk = request.extra_body["chat_template_kwargs"] + assert ctk["enable_thinking"] is True + assert ctk["adapter_name"] == "citations" + + +def test_no_weights_verbs_on_embedded_binding(): + binding = EmbeddedBinding() + for verb in ("prepare", "activate", "deactivate", "release"): + assert not hasattr(binding, verb), f"EmbeddedBinding must not have {verb!r}" + + +def test_multi_call_isolation(): + # EmbeddedBinding is stateless across calls: activating one adapter must not + # leak into the request built for the next call. + binding = EmbeddedBinding() + + request_one = EmbeddedActivationRequest(extra_body={}, api_params={"model": "m"}) + binding.apply_activation(request_one, _identity("answerability")) + + request_two = EmbeddedActivationRequest(extra_body={}, api_params={"model": "m"}) + binding.apply_activation(request_two, _identity("citations", adapter_type="lora")) + + assert request_one.extra_body["chat_template_kwargs"]["adapter_name"] == ( + "answerability" + ) + assert request_two.extra_body["chat_template_kwargs"]["adapter_name"] == ( + "citations" + ) + + +def test_from_base_model_records_backend_base_model_name(): + backend = MagicMock(base_model_name="granite-switch") + binding = EmbeddedBinding.from_base_model(backend) + assert binding.source == "granite-switch" + + +def test_metrics_invocation_counter_increments_for_embedded(): + # AdapterFunctionMetricsPlugin (mellea/telemetry/metrics_plugins.py) hooks + # into `adapter_function_invocation_complete` to record the + # `mellea.adapter_function.invocations` counter, keyed in part by + # `binding_type`. Pin that apply_activation fires it correctly for the + # "embedded" binding type. + pytest.importorskip("cpex", reason="cpex not installed — install mellea[hooks]") + binding = EmbeddedBinding() + request = EmbeddedActivationRequest(extra_body={}, api_params={}) + + with ( + patch("mellea.backends.adapters._core.has_plugins", return_value=True), + patch( + "mellea.backends.adapters._core.invoke_hook", new_callable=MagicMock + ) as mock_invoke, + patch("mellea.backends.adapters._core._run_async_in_thread"), + ): + binding.apply_activation(request, _identity("answerability")) + + payloads = [call.args[1] for call in mock_invoke.call_args_list] + invocation_payloads = [p for p in payloads if hasattr(p, "outcome")] + + assert len(invocation_payloads) == 1 + payload = invocation_payloads[0] + assert payload.name == "answerability" + assert payload.binding_type == "embedded" + assert payload.adapter_type == "alora" + assert payload.outcome == "success" + + +def test_activate_span_binding_type_is_embedded(): + # Span emission is deferred to #1466 (blocked on the missing start hooks — + # see this issue's OTel section). This pins the underlying + # `adapter_function_phase_complete` payload's shape ahead of that: once + # #1466 lands a tracing plugin, it opens the `adapter_function.activate` + # span from this same phase-complete event and sets + # `mellea.adapter_function.binding_type="embedded"` from it. + pytest.importorskip("cpex", reason="cpex not installed — install mellea[hooks]") + binding = EmbeddedBinding() + request = EmbeddedActivationRequest(extra_body={}, api_params={}) + + with ( + patch("mellea.backends.adapters._core.has_plugins", return_value=True), + patch( + "mellea.backends.adapters._core.invoke_hook", new_callable=MagicMock + ) as mock_invoke, + patch("mellea.backends.adapters._core._run_async_in_thread"), + ): + binding.apply_activation(request, _identity("answerability")) + + payloads = [call.args[1] for call in mock_invoke.call_args_list] + phase_payloads = [p for p in payloads if hasattr(p, "phase")] + invocation_payloads = [p for p in payloads if hasattr(p, "outcome")] + + assert len(phase_payloads) == 1 + assert phase_payloads[0].phase == "activate" + assert phase_payloads[0].name == "answerability" + assert invocation_payloads[0].binding_type == "embedded" diff --git a/test/backends/test_adapters/test_embedded_integration.py b/test/backends/test_adapters/test_embedded_integration.py new file mode 100644 index 0000000000..dc680db89e --- /dev/null +++ b/test/backends/test_adapters/test_embedded_integration.py @@ -0,0 +1,142 @@ +# Copyright IBM Corp. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Integration test: OpenAIBackend activating embedded adapters through the +new EmbeddedBinding (Epic #929 Phase 2, issue #1142). + +A real `OpenAIBackend` and its adapter registration/generation path are used; +only the outer network boundary (the OpenAI async client) is mocked, per +test/README.md's definition of `integration`. No vLLM server or Granite +Switch model is required — see `test/backends/test_openai_intrinsics.py` for +the GPU-backed e2e counterpart. +""" + +from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch + +import pytest +from openai.types.chat import ChatCompletion, ChatCompletionMessage +from openai.types.chat.chat_completion import Choice +from openai.types.completion_usage import CompletionUsage + +from mellea.backends.adapters._core import EmbeddedBinding +from mellea.backends.adapters.adapter import EmbeddedIntrinsicAdapter +from mellea.backends.openai import OpenAIBackend +from mellea.stdlib import functional as mfuncs +from mellea.stdlib.components import Intrinsic, Message +from mellea.stdlib.context import ChatContext + +pytestmark = [pytest.mark.integration, pytest.mark.openai] + +_SIMPLE_CONFIG = { + "model": None, + "response_format": None, + "transformations": None, + "instruction": None, + "parameters": {"max_completion_tokens": 64}, + "sentence_boundaries": None, +} + + +def _chat_completion(content: str = '{"result": "ok"}') -> ChatCompletion: + return ChatCompletion( + id="test-embedded-integration", + created=0, + model="granite-switch", + object="chat.completion", + choices=[ + Choice( + index=0, + finish_reason="stop", + message=ChatCompletionMessage(role="assistant", content=content), + ) + ], + usage=CompletionUsage(prompt_tokens=10, completion_tokens=4, total_tokens=14), + ) + + +def _backend_with_adapter(technology: str) -> OpenAIBackend: + backend = OpenAIBackend( + model_id="granite-switch", + api_key="fake-key", + base_url="http://localhost:9999/v1", + ) + backend.add_adapter( + EmbeddedIntrinsicAdapter( + intrinsic_name="answerability", config=_SIMPLE_CONFIG, technology=technology + ) + ) + return backend + + +@pytest.mark.parametrize("technology", ["lora", "alora"]) +async def test_activation_goes_through_embedded_binding(technology): + """The registered adapter's weights are a real EmbeddedBinding, and it is + that binding's `apply_activation` — not an inline isinstance check — that + ends up writing the request the API call receives.""" + backend = _backend_with_adapter(technology) + adapter = backend._added_adapters[f"answerability_{technology}"] + assert isinstance(adapter.weights, EmbeddedBinding) + + mock_create = AsyncMock(return_value=_chat_completion()) + mock_client = MagicMock() + mock_client.chat.completions.create = mock_create + + original_apply_activation = EmbeddedBinding.apply_activation + with ( + patch.object( + OpenAIBackend, + "_async_client", + new_callable=PropertyMock, + return_value=mock_client, + ), + patch.object( + EmbeddedBinding, + "apply_activation", + autospec=True, + side_effect=original_apply_activation, + ) as mock_apply, + ): + ctx = ChatContext().add(Message("user", "What is the square root of 4?")) + mot, _ = await mfuncs.aact( + Intrinsic("answerability"), ctx, backend, strategy=None + ) + await mot.avalue() + + mock_apply.assert_called_once() + _, called_identity = mock_apply.call_args.args[1:] + assert called_identity.name == "answerability" + + call_kwargs = mock_create.call_args.kwargs + assert call_kwargs["extra_body"]["chat_template_kwargs"]["adapter_name"] == ( + "answerability" + ) + assert call_kwargs["model"] == "granite-switch" + + +async def test_existing_switch_call_shape_unchanged(): + """Regression guard: the observable request shape through the binding + matches what the old inline isinstance(adapter, EmbeddedIntrinsicAdapter) + block produced — same chat_template_kwargs, no stray top-level `model` + override from the rewriter.""" + backend = _backend_with_adapter("alora") + mock_create = AsyncMock(return_value=_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, + ): + ctx = ChatContext().add(Message("user", "What is the square root of 4?")) + mot, _ = await mfuncs.aact( + Intrinsic("answerability"), ctx, backend, strategy=None + ) + await mot.avalue() + + call_kwargs = mock_create.call_args.kwargs + assert call_kwargs["model"] == "granite-switch" + assert call_kwargs["extra_body"]["chat_template_kwargs"]["adapter_name"] == ( + "answerability" + ) From 39d1decf2ff92f6fc7626194bc93d5fda5925e5a Mon Sep 17 00:00:00 2001 From: Nigel Jones Date: Tue, 18 Aug 2026 13:18:40 +0100 Subject: [PATCH 02/10] docs: document EmbeddedBinding construction and weights-binding shapes (#1142) Adds an Embedded/Granite Switch construction example to docs/docs/advanced/intrinsics.md (Adapter(weights=EmbeddedBinding.from_base_model(backend))) alongside the existing LocalFileBinding one, plus a backend x reality support matrix. Adds a weights-binding shapes reference table to AGENTS.md Section 14, comparing LocalFileBinding's activate()/deactivate() lifecycle against EmbeddedBinding's single apply_activation(request, identity) request edit. Assisted-by: Claude Code Signed-off-by: Nigel Jones --- AGENTS.md | 12 +++++++ docs/docs/advanced/intrinsics.md | 58 ++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 1c4f4374c3..fefca48ee1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -213,6 +213,18 @@ score = core.check_certainty(context, backend) For lower-level control (custom adapters, model options), use `mfuncs.act()` with `Intrinsic` directly — see examples in `docs/examples/intrinsics/`. +### Weights binding shapes + +`Adapter.weights` normalizes each deployment's activation mechanism behind one of +two shapes — a `WeightsBinding` lifecycle for weights you stage yourself, or +`EmbeddedBinding.apply_activation` for weights already in the served model. The +post-activation shape each produces: + +| Binding | Reality | Activation call | Normalized post-activation state | Lifecycle verbs | +|---------|---------|------------------|-----------------------------------|------------------| +| `LocalFileBinding` | LocalFile/PEFT | `activate()` / `deactivate()` | Backend-internal PEFT adapter state toggled; the outgoing request is untouched | `prepare` / `activate` / `deactivate` / `release` | +| `EmbeddedBinding` | Embedded/Granite Switch | `apply_activation(request, identity)` | `request.extra_body["chat_template_kwargs"]["adapter_name"]` set; `request.api_params["model"]` removed if present | none — weights are already in the served model | + ### Project Resources - **Canonical catalog**: `mellea/backends/adapters/catalog.py` — source of truth for adapter function names, HF repo IDs, and adapter types diff --git a/docs/docs/advanced/intrinsics.md b/docs/docs/advanced/intrinsics.md index e75b9b7974..0e93214fb0 100644 --- a/docs/docs/advanced/intrinsics.md +++ b/docs/docs/advanced/intrinsics.md @@ -227,6 +227,64 @@ For OpenAI backends with Granite Switch, adapters are loaded from the model's Hugging Face repository configuration instead of the adapter function catalog. Output format is task-specific — `requirement-check` returns `{"requirement_check": {"score": }}`. +## Composable adapter construction (advanced) + +> **Advanced:** `Adapter` composes an `Identity`, an `IOContract`, and a weights +> binding. Most callers should use the wrapper functions above; construct an +> `Adapter` directly when writing a new backend integration or adapter function. + +Each weights binding models how its deployment turns an adapter on. `LocalFileBinding` +downloads and loads LoRA/aLoRA weights, so it exposes a `prepare`/`activate`/ +`deactivate`/`release` lifecycle. `EmbeddedBinding` has no weights to manage — the +adapter is already part of the served base model — so it exposes a single method, +`apply_activation`, that edits the outgoing request instead: + +```python +# Requires: mellea[hf] +from mellea.backends.adapters import Adapter, EmbeddedBinding, Identity, IOContract, LocalFileBinding +from mellea.backends.huggingface import LocalHFBackend +from mellea.backends.openai import OpenAIBackend +from mellea.core import Component + + +class AnswerabilityContract(IOContract): + def build_prompt(self, **kwargs: object) -> Component: + raise NotImplementedError # request formatting lands with #1516 + + def parse(self, raw: str) -> dict[str, object]: + import json + + return json.loads(raw) + + +# LocalFile/PEFT reality — LocalHFBackend downloads and loads the weights. +hf_backend = LocalHFBackend(model_id="ibm-granite/granite-4.1-3b") +hf_adapter = Adapter( + identity=Identity(name="answerability", adapter_type="alora"), + io_contract=AnswerabilityContract(), + weights=LocalFileBinding.from_catalog("answerability"), +) + +# Embedded/Granite Switch reality — the adapter is already in the served model. +switch_backend = OpenAIBackend( + model_id="granite-switch", base_url="http://localhost:8000/v1" +) +switch_adapter = Adapter( + identity=Identity(name="answerability", adapter_type="alora"), + io_contract=AnswerabilityContract(), + weights=EmbeddedBinding.from_base_model(switch_backend), +) +``` + +Backend support for each weights binding today: + +| Backend | `LocalFileBinding` (LocalFile/PEFT) | `EmbeddedBinding` (Embedded/Granite Switch) | `ServerMediatedBinding` | +| --- | --- | --- | --- | +| `LocalHFBackend` | ✅ shipping | 🔜 planned (#1018) | — | +| `OpenAIBackend` | — | ✅ shipping (Granite Switch) | — | + +`ServerMediatedBinding` has no backend implementation yet — see discussion #1486. + --- ## Guardian adapter functions From 6a1f869f1668488de0ecfa601472ac98b1813c66 Mon Sep 17 00:00:00 2001 From: Nigel Jones Date: Tue, 18 Aug 2026 15:49:08 +0100 Subject: [PATCH 03/10] fix(backends): address review findings on EmbeddedBinding.apply_activation Independent review (3 perspectives) found one BLOCKER and several correctness/design issues in the initial #1142 implementation, all verified against the pinned source before fixing: - BLOCKER: apply_activation fired its two telemetry hooks via _run_async_in_thread from inside an already-running coroutine (OpenAIBackend._generate_from_intrinsic), spawning a throwaway asyncio loop + daemon thread per call that was never reclaimed (10 calls with metrics enabled leaked 20 threads, confirmed independently). Fixed by making apply_activation async and awaiting invoke_hook directly. - WARNING: apply_activation fired adapter_function_invocation_complete with outcome="success" hardcoded, before generation/parsing (which OpenAIBackend resolves lazily) could possibly have failed. Fixed by no longer firing invocation_complete from apply_activation -- only phase_complete (phase="activate"), which the method genuinely completes. Wiring a real invocation-complete signal in requires the caller to fire it once generation/parsing resolve; documented as a follow-up rather than solved here. - WARNING: EmbeddedActivationRequest, required to call the public apply_activation, wasn't exported from mellea.backends.adapters. Exported it and switched openai.py to import both it and EmbeddedBinding from the public package. - Hardened the openai.py activation branch with an explicit else: raise TypeError for a reassigned .weights (previously would have silently skipped activation), and wired base_model_name into EmbeddedBinding.source in OpenAIBackend.add_adapter (was always ""). - Added the missing adapter_scope TypeError regression test, fixed a _fire_phase_complete naming collision (same name, different first parameter, across two binding classes), corrected the docs' composable-construction example (built a backend it never bound, and implied backend support the code doesn't have), dropped a misapplied openai backend marker from a fully-mocked integration test, and removed a duplicate integration test. Assisted-by: Claude Code Signed-off-by: Nigel Jones --- AGENTS.md | 8 +- docs/docs/advanced/intrinsics.md | 35 +++++--- mellea/backends/adapters/__init__.py | 2 + mellea/backends/adapters/_core.py | 81 ++++++++---------- mellea/backends/openai.py | 21 ++++- .../backends/test_adapters/test_core_types.py | 5 +- .../test_adapters/test_embedded_binding.py | 82 +++++++++---------- .../test_embedded_integration.py | 31 +------ test/backends/test_adapters/test_shims.py | 24 ++++++ 9 files changed, 146 insertions(+), 143 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fefca48ee1..9f2185abe4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -220,10 +220,10 @@ two shapes — a `WeightsBinding` lifecycle for weights you stage yourself, or `EmbeddedBinding.apply_activation` for weights already in the served model. The post-activation shape each produces: -| Binding | Reality | Activation call | Normalized post-activation state | Lifecycle verbs | -|---------|---------|------------------|-----------------------------------|------------------| -| `LocalFileBinding` | LocalFile/PEFT | `activate()` / `deactivate()` | Backend-internal PEFT adapter state toggled; the outgoing request is untouched | `prepare` / `activate` / `deactivate` / `release` | -| `EmbeddedBinding` | Embedded/Granite Switch | `apply_activation(request, identity)` | `request.extra_body["chat_template_kwargs"]["adapter_name"]` set; `request.api_params["model"]` removed if present | none — weights are already in the served model | +| Binding | Reality | Lifecycle verbs | Caller invokes | Normalized post-activation state | +|---------|---------|------------------|-----------------|-----------------------------------| +| `LocalFileBinding` | LocalFile/PEFT | `prepare` / `activate` / `deactivate` / `release` | `activate()` / `deactivate()`, via `adapter_scope` | Backend-internal PEFT adapter state toggled; the outgoing request is untouched | +| `EmbeddedBinding` | Embedded/Granite Switch | none — weights are already in the served model | `apply_activation(request, identity)` | `request.extra_body["chat_template_kwargs"]["adapter_name"]` set; `request.api_params["model"]` removed if present | ### Project Resources diff --git a/docs/docs/advanced/intrinsics.md b/docs/docs/advanced/intrinsics.md index 0e93214fb0..a6fac25e30 100644 --- a/docs/docs/advanced/intrinsics.md +++ b/docs/docs/advanced/intrinsics.md @@ -230,14 +230,16 @@ Output format is task-specific — `requirement-check` returns `{"requirement_ch ## Composable adapter construction (advanced) > **Advanced:** `Adapter` composes an `Identity`, an `IOContract`, and a weights -> binding. Most callers should use the wrapper functions above; construct an -> `Adapter` directly when writing a new backend integration or adapter function. +> binding into a single, inspectable object. It's scaffolding for a future +> backend-integration surface (Epic #929) — today, `LocalHFBackend.add_adapter` +> and `OpenAIBackend.add_adapter` accept a weights binding or a shim class +> directly, not a composed `Adapter`. The construction below is illustrative +> of the binding shapes; write a new backend integration against the bindings +> themselves. -Each weights binding models how its deployment turns an adapter on. `LocalFileBinding` -downloads and loads LoRA/aLoRA weights, so it exposes a `prepare`/`activate`/ -`deactivate`/`release` lifecycle. `EmbeddedBinding` has no weights to manage — the -adapter is already part of the served base model — so it exposes a single method, -`apply_activation`, that edits the outgoing request instead: +Each weights binding models how its deployment turns an adapter on. +`LocalFileBinding` downloads and loads LoRA/aLoRA weights, so it exposes a +`prepare`/`activate`/`deactivate`/`release` lifecycle: ```python # Requires: mellea[hf] @@ -259,13 +261,21 @@ class AnswerabilityContract(IOContract): # LocalFile/PEFT reality — LocalHFBackend downloads and loads the weights. hf_backend = LocalHFBackend(model_id="ibm-granite/granite-4.1-3b") +hf_binding = LocalFileBinding.from_catalog("answerability") +hf_binding.bind_backend(hf_backend) +# hf_binding.prepare() downloads the weights and loads them into hf_backend. hf_adapter = Adapter( identity=Identity(name="answerability", adapter_type="alora"), io_contract=AnswerabilityContract(), - weights=LocalFileBinding.from_catalog("answerability"), + weights=hf_binding, ) +``` + +`EmbeddedBinding` has no weights to manage — the adapter is already part of +the served base model — so it exposes a single method, `apply_activation`, +that edits the outgoing request instead of a lifecycle: -# Embedded/Granite Switch reality — the adapter is already in the served model. +```python switch_backend = OpenAIBackend( model_id="granite-switch", base_url="http://localhost:8000/v1" ) @@ -276,12 +286,13 @@ switch_adapter = Adapter( ) ``` -Backend support for each weights binding today: +Weights-binding support by backend today — this tracks the binding +implementations, not whether a composed `Adapter` can be registered directly: | Backend | `LocalFileBinding` (LocalFile/PEFT) | `EmbeddedBinding` (Embedded/Granite Switch) | `ServerMediatedBinding` | | --- | --- | --- | --- | -| `LocalHFBackend` | ✅ shipping | 🔜 planned (#1018) | — | -| `OpenAIBackend` | — | ✅ shipping (Granite Switch) | — | +| `LocalHFBackend` | ✅ shipping — `add_adapter` accepts a `LocalFileBinding` directly | 🔜 planned (#1018) | — | +| `OpenAIBackend` | — | ✅ shipping, via the deprecated `EmbeddedIntrinsicAdapter` shim above, which builds an `EmbeddedBinding` internally | — | `ServerMediatedBinding` has no backend implementation yet — see discussion #1486. diff --git a/mellea/backends/adapters/__init__.py b/mellea/backends/adapters/__init__.py index 932f3f55b0..1c16445ad1 100644 --- a/mellea/backends/adapters/__init__.py +++ b/mellea/backends/adapters/__init__.py @@ -6,6 +6,7 @@ from ._core import ( Adapter, AdapterSchemaMismatchError, + EmbeddedActivationRequest, EmbeddedBinding, Identity, IOContract, @@ -34,6 +35,7 @@ "AdapterMixin", "AdapterSchemaMismatchError", "AdapterType", + "EmbeddedActivationRequest", "EmbeddedBinding", "EmbeddedIntrinsicAdapter", "IOContract", diff --git a/mellea/backends/adapters/_core.py b/mellea/backends/adapters/_core.py index c681e49cc8..69aea08906 100644 --- a/mellea/backends/adapters/_core.py +++ b/mellea/backends/adapters/_core.py @@ -751,7 +751,7 @@ def from_base_model(cls, backend: "AdapterMixin") -> "EmbeddedBinding": """ return cls(source=backend.base_model_name) - def apply_activation( + async def apply_activation( self, request: EmbeddedActivationRequest, identity: "Identity" ) -> None: """Edits `request` so the served model activates `identity`'s adapter. @@ -763,9 +763,28 @@ def apply_activation( real model is the base model already being served, so that value is dropped here rather than sent to the API. - Fires `adapter_function_phase_complete` (phase `"activate"`) and - `adapter_function_invocation_complete`, so Embedded calls are counted - by `AdapterFunctionMetricsPlugin` like every other binding. + Fires `adapter_function_phase_complete` (phase `"activate"`), so + Embedded calls contribute to the `mellea.adapter_function.phase_duration` + metric like every other binding. Does **not** fire + `adapter_function_invocation_complete`: unlike `LocalFileBinding`'s + verbs (driven by `adapter_scope`, which wraps the whole call and + knows the real outcome), this method only edits a request — the + actual generation and parsing happen later, asynchronously, once the + caller awaits the resulting `ModelOutputThunk`. Firing an + invocation-complete event here would have to guess an `outcome` that + this method cannot know, which is worse than not firing it: it would + report `outcome="success"` for calls that go on to fail. Wiring a + real invocation-complete signal in requires the caller (currently + `OpenAIBackend._generate_from_intrinsic`) to fire it once generation + and parsing resolve — tracked as a follow-up, not part of this method. + + This method is `async` (unlike the rest of `EmbeddedBinding`'s + surface) purely because hook dispatch (`invoke_hook`) is async; its + own work is synchronous. Its one caller, + `OpenAIBackend._generate_from_intrinsic`, is already a coroutine, so + `await`ing here — rather than bridging through + `_run_async_in_thread`, which is for calling async code from sync + code — avoids spawning a throwaway event loop and thread per call. Args: request: The outgoing request state to edit; both of its dicts @@ -779,12 +798,18 @@ def apply_activation( request.api_params.pop("model", None) duration_s = time.monotonic() - started_at - self._fire_phase_complete(identity.name, duration_s) - self._fire_invocation_complete(identity) + await self._fire_activate_phase_complete(identity.name, duration_s) - def _fire_phase_complete(self, name: str, duration_s: float) -> None: + async def _fire_activate_phase_complete(self, name: str, duration_s: float) -> None: """Fires `adapter_function_phase_complete` for the activate phase. + `duration_s` is the cost of editing a dict, not of an adapter + activation in the sense `LocalFileBinding`'s real PEFT activation is — + the resulting `phase_duration` samples for `binding_type="embedded"` + are not comparable to `binding_type="local_file"` samples for the + same adapter name; the histogram carries no `binding_type` attribute + to separate them. + Args: name: Adapter function name, used as the metric's `name` field. duration_s: Wall-clock duration of `apply_activation`'s request edit. @@ -800,8 +825,7 @@ def _fire_phase_complete(self, name: str, duration_s: float) -> None: payload = AdapterFunctionPhaseCompletePayload( name=name, phase="activate", duration_ms=duration_s * 1000.0 ) - hook_coro = invoke_hook(HookType.ADAPTER_FUNCTION_PHASE_COMPLETE, payload) - _run_async_in_thread(hook_coro) + await invoke_hook(HookType.ADAPTER_FUNCTION_PHASE_COMPLETE, payload) except Exception: MelleaLogger.get_logger().warning( f"adapter_function_phase_complete hook dispatch failed for {name!r} " @@ -810,40 +834,6 @@ def _fire_phase_complete(self, name: str, duration_s: float) -> None: exc_info=True, ) - def _fire_invocation_complete(self, identity: "Identity") -> None: - """Fires `adapter_function_invocation_complete` for a completed activation. - - Args: - identity: Identifies the adapter that was activated. - """ - if not has_plugins(HookType.ADAPTER_FUNCTION_INVOCATION_COMPLETE): - return - - from ...plugins.hooks.adapter_function import ( - AdapterFunctionInvocationCompletePayload, - ) - - try: - payload = AdapterFunctionInvocationCompletePayload( - name=identity.name, - revision=None, - binding_type=self.binding_type, - adapter_type=identity.adapter_type, - outcome="success", - error=None, - ) - hook_coro = invoke_hook( - HookType.ADAPTER_FUNCTION_INVOCATION_COMPLETE, payload - ) - _run_async_in_thread(hook_coro) - except Exception: - MelleaLogger.get_logger().warning( - "adapter_function_invocation_complete hook dispatch failed for " - f"{identity.name!r}; ignoring so it does not turn a completed " - "activation into an operation failure.", - exc_info=True, - ) - class ServerMediatedBinding(WeightsBinding): """Stub binding for server-managed adapter weights.""" @@ -875,8 +865,9 @@ def release(self) -> None: class Adapter: """Composable adapter dataclass (Epic #929 Phase 0). - Composes an :class:`Identity`, an :class:`IOContract`, and a - :class:`WeightsBinding` into a single, inspectable object. + Composes an :class:`Identity`, an :class:`IOContract`, and a weights + binding (a :class:`WeightsBinding` or :class:`EmbeddedBinding`) into a + single, inspectable object. Attributes: identity (Identity): Name, type, and capability for this adapter. diff --git a/mellea/backends/openai.py b/mellea/backends/openai.py index 1990d35131..9fd0a6f524 100644 --- a/mellea/backends/openai.py +++ b/mellea/backends/openai.py @@ -53,7 +53,7 @@ from ..stdlib.requirements import LLMaJRequirement from ..telemetry.context import generate_request_id, with_context from ._options import resolve_model_options -from .adapters._core import EmbeddedActivationRequest, EmbeddedBinding +from .adapters import EmbeddedActivationRequest, EmbeddedBinding from .adapters.adapter import AdapterInput, AdapterMixin, EmbeddedIntrinsicAdapter from .backend import FormatterBackend from .model_options import ModelOption @@ -324,6 +324,8 @@ def add_adapter(self, adapter: AdapterInput) -> None: f"Got: {type(adapter).__name__}" ) adapter.backend = self + if isinstance(adapter.weights, EmbeddedBinding): + adapter.weights.source = self.base_model_name self._added_adapters[adapter.qualified_name] = adapter def list_adapters(self) -> list[str]: @@ -690,7 +692,9 @@ async def _generate_from_intrinsic( Raises: ValueError: If no embedded adapter is registered for the requested intrinsic. - TypeError: If the adapter isn't an EmbeddedIntrinsicAdapter. + TypeError: If the adapter isn't an EmbeddedIntrinsicAdapter, or its + `weights` isn't an EmbeddedBinding (only reachable if a caller + reassigns `.weights` after construction). """ if not ctx.is_chat_context: raise NotImplementedError("Intrinsics require a chat context.") @@ -769,12 +773,21 @@ async def _generate_from_intrinsic( api_params.update(rewriter.parameters) # Embedded adapters activate via control tokens in the chat template; - # the binding owns the request edit (issue #1142). + # the binding owns the request edit (issue #1142). `adapter.weights` is + # always an EmbeddedBinding here — EmbeddedIntrinsicAdapter.__init__ + # constructs one unconditionally — but the shim permits attribute + # mutation, so a caller reassigning `.weights` must fail loudly here + # rather than silently skip activation and send an unactivated request. if isinstance(adapter.weights, EmbeddedBinding): activation_request = EmbeddedActivationRequest( extra_body=extra_body, api_params=api_params ) - adapter.weights.apply_activation(activation_request, adapter.identity) + await adapter.weights.apply_activation(activation_request, adapter.identity) + else: + raise TypeError( + f"EmbeddedIntrinsicAdapter.weights must be an EmbeddedBinding; " + f"got {type(adapter.weights).__name__}. Activation cannot proceed." + ) # Collect tools if tool_calls is enabled. tools: dict[str, AbstractMelleaTool] = dict() diff --git a/test/backends/test_adapters/test_core_types.py b/test/backends/test_adapters/test_core_types.py index f896009eaf..fee071244f 100644 --- a/test/backends/test_adapters/test_core_types.py +++ b/test/backends/test_adapters/test_core_types.py @@ -119,10 +119,9 @@ def deactivate(self) -> None: PartialBinding() # type: ignore[abstract] -@pytest.mark.parametrize("cls", [ServerMediatedBinding]) @pytest.mark.parametrize("verb", ["prepare", "activate", "deactivate", "release"]) -def test_stub_binding_subclasses_raise_not_implemented(cls, verb): - binding = cls() +def test_server_mediated_binding_raises_not_implemented(verb): + binding = ServerMediatedBinding() with pytest.raises(NotImplementedError, match="Phase 0 stub"): getattr(binding, verb)() diff --git a/test/backends/test_adapters/test_embedded_binding.py b/test/backends/test_adapters/test_embedded_binding.py index d4a76a471f..a95edcceb4 100644 --- a/test/backends/test_adapters/test_embedded_binding.py +++ b/test/backends/test_adapters/test_embedded_binding.py @@ -8,7 +8,7 @@ """ from typing import Literal -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -25,16 +25,16 @@ def _identity( return Identity(name=name, adapter_type=adapter_type, capability=name) -def test_apply_activation_sets_adapter_name(): +async def test_apply_activation_sets_adapter_name(): binding = EmbeddedBinding() request = EmbeddedActivationRequest(extra_body={}, api_params={}) - binding.apply_activation(request, _identity("answerability")) + await binding.apply_activation(request, _identity("answerability")) assert request.extra_body["chat_template_kwargs"]["adapter_name"] == "answerability" -def test_apply_activation_removes_model_param(): +async def test_apply_activation_removes_model_param(): # The rewriter config can set `model` to the adapter name; for an embedded # adapter the real model is the base model already being served, so this # must be dropped rather than sent to the API. @@ -43,19 +43,19 @@ def test_apply_activation_removes_model_param(): extra_body={}, api_params={"model": "answerability_alora", "seed": 1} ) - binding.apply_activation(request, _identity("answerability")) + await binding.apply_activation(request, _identity("answerability")) assert "model" not in request.api_params assert request.api_params["seed"] == 1 -def test_apply_activation_preserves_existing_chat_template_kwargs(): +async def test_apply_activation_preserves_existing_chat_template_kwargs(): binding = EmbeddedBinding() request = EmbeddedActivationRequest( extra_body={"chat_template_kwargs": {"enable_thinking": True}}, api_params={} ) - binding.apply_activation(request, _identity("citations")) + await binding.apply_activation(request, _identity("citations")) ctk = request.extra_body["chat_template_kwargs"] assert ctk["enable_thinking"] is True @@ -68,16 +68,18 @@ def test_no_weights_verbs_on_embedded_binding(): assert not hasattr(binding, verb), f"EmbeddedBinding must not have {verb!r}" -def test_multi_call_isolation(): +async def test_multi_call_isolation(): # EmbeddedBinding is stateless across calls: activating one adapter must not # leak into the request built for the next call. binding = EmbeddedBinding() request_one = EmbeddedActivationRequest(extra_body={}, api_params={"model": "m"}) - binding.apply_activation(request_one, _identity("answerability")) + await binding.apply_activation(request_one, _identity("answerability")) request_two = EmbeddedActivationRequest(extra_body={}, api_params={"model": "m"}) - binding.apply_activation(request_two, _identity("citations", adapter_type="lora")) + await binding.apply_activation( + request_two, _identity("citations", adapter_type="lora") + ) assert request_one.extra_body["chat_template_kwargs"]["adapter_name"] == ( "answerability" @@ -93,12 +95,11 @@ def test_from_base_model_records_backend_base_model_name(): assert binding.source == "granite-switch" -def test_metrics_invocation_counter_increments_for_embedded(): +async def test_apply_activation_fires_phase_complete_metric(): # AdapterFunctionMetricsPlugin (mellea/telemetry/metrics_plugins.py) hooks - # into `adapter_function_invocation_complete` to record the - # `mellea.adapter_function.invocations` counter, keyed in part by - # `binding_type`. Pin that apply_activation fires it correctly for the - # "embedded" binding type. + # into `adapter_function_phase_complete` to record the + # `mellea.adapter_function.phase_duration` histogram. Pin that + # apply_activation fires it correctly for the "activate" phase. pytest.importorskip("cpex", reason="cpex not installed — install mellea[hooks]") binding = EmbeddedBinding() request = EmbeddedActivationRequest(extra_body={}, api_params={}) @@ -106,30 +107,26 @@ def test_metrics_invocation_counter_increments_for_embedded(): with ( patch("mellea.backends.adapters._core.has_plugins", return_value=True), patch( - "mellea.backends.adapters._core.invoke_hook", new_callable=MagicMock + "mellea.backends.adapters._core.invoke_hook", new_callable=AsyncMock ) as mock_invoke, - patch("mellea.backends.adapters._core._run_async_in_thread"), ): - binding.apply_activation(request, _identity("answerability")) - - payloads = [call.args[1] for call in mock_invoke.call_args_list] - invocation_payloads = [p for p in payloads if hasattr(p, "outcome")] + await binding.apply_activation(request, _identity("answerability")) - assert len(invocation_payloads) == 1 - payload = invocation_payloads[0] + mock_invoke.assert_awaited_once() + payload = mock_invoke.call_args.args[1] assert payload.name == "answerability" - assert payload.binding_type == "embedded" - assert payload.adapter_type == "alora" - assert payload.outcome == "success" - - -def test_activate_span_binding_type_is_embedded(): - # Span emission is deferred to #1466 (blocked on the missing start hooks — - # see this issue's OTel section). This pins the underlying - # `adapter_function_phase_complete` payload's shape ahead of that: once - # #1466 lands a tracing plugin, it opens the `adapter_function.activate` - # span from this same phase-complete event and sets - # `mellea.adapter_function.binding_type="embedded"` from it. + assert payload.phase == "activate" + + +async def test_apply_activation_does_not_fire_invocation_complete(): + # apply_activation only edits the request — the real generate+parse + # outcome isn't known yet at this point (OpenAIBackend resolves it later, + # lazily, when the caller awaits the ModelOutputThunk). Firing + # `adapter_function_invocation_complete` here would have to guess an + # `outcome`, which would misreport failed calls as "success". Pin that it + # doesn't, so nobody "fixes" this back to a hardcoded success outcome + # without solving the underlying problem (see the docstring on + # apply_activation for the follow-up this needs). pytest.importorskip("cpex", reason="cpex not installed — install mellea[hooks]") binding = EmbeddedBinding() request = EmbeddedActivationRequest(extra_body={}, api_params={}) @@ -137,17 +134,12 @@ def test_activate_span_binding_type_is_embedded(): with ( patch("mellea.backends.adapters._core.has_plugins", return_value=True), patch( - "mellea.backends.adapters._core.invoke_hook", new_callable=MagicMock + "mellea.backends.adapters._core.invoke_hook", new_callable=AsyncMock ) as mock_invoke, - patch("mellea.backends.adapters._core._run_async_in_thread"), ): - binding.apply_activation(request, _identity("answerability")) + await binding.apply_activation(request, _identity("answerability")) - payloads = [call.args[1] for call in mock_invoke.call_args_list] - phase_payloads = [p for p in payloads if hasattr(p, "phase")] - invocation_payloads = [p for p in payloads if hasattr(p, "outcome")] + fired_hook_types = [call.args[0] for call in mock_invoke.call_args_list] + from mellea.plugins.types import HookType - assert len(phase_payloads) == 1 - assert phase_payloads[0].phase == "activate" - assert phase_payloads[0].name == "answerability" - assert invocation_payloads[0].binding_type == "embedded" + assert HookType.ADAPTER_FUNCTION_INVOCATION_COMPLETE not in fired_hook_types diff --git a/test/backends/test_adapters/test_embedded_integration.py b/test/backends/test_adapters/test_embedded_integration.py index dc680db89e..e84084d909 100644 --- a/test/backends/test_adapters/test_embedded_integration.py +++ b/test/backends/test_adapters/test_embedded_integration.py @@ -25,7 +25,7 @@ from mellea.stdlib.components import Intrinsic, Message from mellea.stdlib.context import ChatContext -pytestmark = [pytest.mark.integration, pytest.mark.openai] +pytestmark = pytest.mark.integration _SIMPLE_CONFIG = { "model": None, @@ -111,32 +111,3 @@ async def test_activation_goes_through_embedded_binding(technology): "answerability" ) assert call_kwargs["model"] == "granite-switch" - - -async def test_existing_switch_call_shape_unchanged(): - """Regression guard: the observable request shape through the binding - matches what the old inline isinstance(adapter, EmbeddedIntrinsicAdapter) - block produced — same chat_template_kwargs, no stray top-level `model` - override from the rewriter.""" - backend = _backend_with_adapter("alora") - mock_create = AsyncMock(return_value=_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, - ): - ctx = ChatContext().add(Message("user", "What is the square root of 4?")) - mot, _ = await mfuncs.aact( - Intrinsic("answerability"), ctx, backend, strategy=None - ) - await mot.avalue() - - call_kwargs = mock_create.call_args.kwargs - assert call_kwargs["model"] == "granite-switch" - assert call_kwargs["extra_body"]["chat_template_kwargs"]["adapter_name"] == ( - "answerability" - ) diff --git a/test/backends/test_adapters/test_shims.py b/test/backends/test_adapters/test_shims.py index b7bdfd93f0..a8234d844b 100644 --- a/test/backends/test_adapters/test_shims.py +++ b/test/backends/test_adapters/test_shims.py @@ -336,6 +336,30 @@ def test_adapter_scope_raises_for_a_shim_backed_adapter(): pytest.fail("body must not run when the shim's activate() raises") +def test_adapter_scope_rejects_an_embedded_binding(): + """adapter_scope drives the WeightsBinding lifecycle; EmbeddedBinding has none. + + Deliberate behaviour change from #1142: `adapter_scope` used to raise + `NotImplementedError` from `_ShimWeightsBinding.activate()` for an + `EmbeddedIntrinsicAdapter` and fire `invocation_complete(outcome="error")`; + it now raises `TypeError` before entering the try/finally and fires + nothing. This pins the new guard (`adapter.py`'s `isinstance(adapter.weights, + WeightsBinding)` check) rather than leaving it to surface as an + `AttributeError` from `adapter.weights.activate()` the next time someone + wires embedded generation through this scope. + """ + mock_backend = MagicMock(spec=AdapterMixin) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + adapter = EmbeddedIntrinsicAdapter( + "answerability", config={}, technology="alora" + ) + + with pytest.raises(TypeError, match=r"have no activate\(\)/deactivate\(\)"): + with AdapterMixin.adapter_scope(mock_backend, adapter): + pytest.fail("body must not run for a binding with no lifecycle") + + def test_resolve_adapter_returns_existing_by_capability(): """resolve_adapter must return an already-registered adapter without creating a new one.""" existing = _make_intrinsic_adapter("answerability") From dbf4dea7d80f37dd76d524ca783aa83ecb9db2cd Mon Sep 17 00:00:00 2001 From: Nigel Jones Date: Wed, 19 Aug 2026 21:19:35 +0100 Subject: [PATCH 04/10] fix(backends): address review findings on EmbeddedBinding docs and guards - Restate the composable-construction callout in intrinsics.md per backend: OpenAIBackend.add_adapter accepts only the deprecated EmbeddedIntrinsicAdapter shim, not a raw weights binding. - Pair the LocalFileBinding example's identity with the LoRA adapter type that from_catalog actually loads (first catalog-listed type), and give the OpenAIBackend example an explicit api_key. - Document the NotImplementedError cases in _generate_from_intrinsic's Raises section. - Pin the registration-time source stamp and the reassigned-.weights fail-loud TypeError in the embedded integration test. Assisted-by: opencode Signed-off-by: Nigel Jones --- docs/docs/advanced/intrinsics.md | 24 ++++++++++++------- mellea/backends/openai.py | 3 +++ .../test_embedded_integration.py | 21 +++++++++++++++- 3 files changed, 38 insertions(+), 10 deletions(-) diff --git a/docs/docs/advanced/intrinsics.md b/docs/docs/advanced/intrinsics.md index a6fac25e30..8d3ac98064 100644 --- a/docs/docs/advanced/intrinsics.md +++ b/docs/docs/advanced/intrinsics.md @@ -229,13 +229,15 @@ Output format is task-specific — `requirement-check` returns `{"requirement_ch ## Composable adapter construction (advanced) -> **Advanced:** `Adapter` composes an `Identity`, an `IOContract`, and a weights -> binding into a single, inspectable object. It's scaffolding for a future -> backend-integration surface (Epic #929) — today, `LocalHFBackend.add_adapter` -> and `OpenAIBackend.add_adapter` accept a weights binding or a shim class -> directly, not a composed `Adapter`. The construction below is illustrative -> of the binding shapes; write a new backend integration against the bindings -> themselves. +> **Advanced:** `Adapter` composes an `Identity`, an `IOContract`, and a +> weights binding into a single, inspectable object. It's scaffolding for a +> future backend-integration surface (Epic #929) — today, neither backend +> accepts a composed `Adapter` directly: `LocalHFBackend.add_adapter` takes a +> `LocalFileBinding` or the `LocalHFAdapter` shim, while +> `OpenAIBackend.add_adapter` takes only the deprecated +> `EmbeddedIntrinsicAdapter` shim, which builds an `EmbeddedBinding` +> internally. The construction below is illustrative of the binding shapes; +> write a new backend integration against the bindings themselves. Each weights binding models how its deployment turns an adapter on. `LocalFileBinding` downloads and loads LoRA/aLoRA weights, so it exposes a @@ -264,8 +266,10 @@ hf_backend = LocalHFBackend(model_id="ibm-granite/granite-4.1-3b") hf_binding = LocalFileBinding.from_catalog("answerability") hf_binding.bind_backend(hf_backend) # hf_binding.prepare() downloads the weights and loads them into hf_backend. +# adapter_type must match the binding — from_catalog loads the first +# catalog-listed adapter type, which is LoRA for answerability. hf_adapter = Adapter( - identity=Identity(name="answerability", adapter_type="alora"), + identity=Identity(name="answerability", adapter_type="lora"), io_contract=AnswerabilityContract(), weights=hf_binding, ) @@ -277,7 +281,9 @@ that edits the outgoing request instead of a lifecycle: ```python switch_backend = OpenAIBackend( - model_id="granite-switch", base_url="http://localhost:8000/v1" + model_id="granite-switch", + api_key="EMPTY", + base_url="http://localhost:8000/v1", ) switch_adapter = Adapter( identity=Identity(name="answerability", adapter_type="alora"), diff --git a/mellea/backends/openai.py b/mellea/backends/openai.py index 9fd0a6f524..018cc075b7 100644 --- a/mellea/backends/openai.py +++ b/mellea/backends/openai.py @@ -690,6 +690,9 @@ async def _generate_from_intrinsic( intrinsic output. Raises: + NotImplementedError: If the context isn't a chat context, or if + streaming is requested (intrinsic post-processing requires + the complete response). ValueError: If no embedded adapter is registered for the requested intrinsic. TypeError: If the adapter isn't an EmbeddedIntrinsicAdapter, or its diff --git a/test/backends/test_adapters/test_embedded_integration.py b/test/backends/test_adapters/test_embedded_integration.py index e84084d909..dd07591f50 100644 --- a/test/backends/test_adapters/test_embedded_integration.py +++ b/test/backends/test_adapters/test_embedded_integration.py @@ -18,7 +18,7 @@ from openai.types.chat.chat_completion import Choice from openai.types.completion_usage import CompletionUsage -from mellea.backends.adapters._core import EmbeddedBinding +from mellea.backends.adapters import EmbeddedBinding, ServerMediatedBinding from mellea.backends.adapters.adapter import EmbeddedIntrinsicAdapter from mellea.backends.openai import OpenAIBackend from mellea.stdlib import functional as mfuncs @@ -76,6 +76,9 @@ async def test_activation_goes_through_embedded_binding(technology): backend = _backend_with_adapter(technology) adapter = backend._added_adapters[f"answerability_{technology}"] assert isinstance(adapter.weights, EmbeddedBinding) + # add_adapter stamps the registration-time source (openai.py), distinct + # from the from_base_model() classmethod covered in test_embedded_binding.py. + assert adapter.weights.source == "granite-switch" mock_create = AsyncMock(return_value=_chat_completion()) mock_client = MagicMock() @@ -111,3 +114,19 @@ async def test_activation_goes_through_embedded_binding(technology): "answerability" ) assert call_kwargs["model"] == "granite-switch" + + +async def test_reassigned_weights_fail_loudly(): + """Reassigning `.weights` off the EmbeddedBinding must fail at generation. + + The shim permits attribute mutation, so a caller reassigning `.weights` + after construction must hit the explicit TypeError rather than silently + skipping activation and sending an unactivated request (issue #1142). + """ + backend = _backend_with_adapter("alora") + adapter = backend._added_adapters["answerability_alora"] + adapter.weights = ServerMediatedBinding() + + ctx = ChatContext().add(Message("user", "What is the square root of 4?")) + with pytest.raises(TypeError, match=r"weights must be an EmbeddedBinding"): + await mfuncs.aact(Intrinsic("answerability"), ctx, backend, strategy=None) From 87c2f934b4bf51531a4e6e9e968e391c18315c39 Mon Sep 17 00:00:00 2001 From: Nigel Jones Date: Thu, 20 Aug 2026 07:29:50 +0100 Subject: [PATCH 05/10] docs(backends): document add_adapter's EmbeddedBinding source stamp The registration method mutates the caller's EmbeddedBinding (stamping base_model_name into source) but its docstring did not say so; the sibling LocalHFBackend.add_adapter documents its side effects explicitly. Assisted-by: opencode Signed-off-by: Nigel Jones --- mellea/backends/openai.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mellea/backends/openai.py b/mellea/backends/openai.py index 018cc075b7..6cf113a5bc 100644 --- a/mellea/backends/openai.py +++ b/mellea/backends/openai.py @@ -309,7 +309,9 @@ def add_adapter(self, adapter: AdapterInput) -> None: Accepts the full `AdapterInput` union to honour the mixin contract, but currently only `EmbeddedIntrinsicAdapter` (the Embedded/Granite Switch - reality) is supported; other realities are rejected at runtime. + reality) is supported; other realities are rejected at runtime. As a + side effect, an `EmbeddedBinding` weights handler is stamped with this + backend's `base_model_name` in its `source` field. Args: adapter (AdapterInput): The adapter to register. Must be an From 094e6264984a9bcb6765959cc898456c32b6a51d Mon Sep 17 00:00:00 2001 From: Nigel Jones Date: Thu, 20 Aug 2026 11:21:04 +0100 Subject: [PATCH 06/10] docs(backends): document shim scaffolding as current behaviour, no tracker refs The IntrinsicAdapter/EmbeddedIntrinsicAdapter notes and the _Shim* placeholders cited Phase-2 plans and issue numbers that had gone stale (#1137 is closed and out of scope; #1141 shipped without replacing the shims). Both shim notes now state current behaviour only and agree with each other; the shim raise messages drop the phase/issue prefixes. test_shims.py's message match follows the new wording. Shims themselves are slated for removal, so no forward references are introduced. Assisted-by: opencode Signed-off-by: Nigel Jones --- mellea/backends/adapters/adapter.py | 25 +++++++++-------------- test/backends/test_adapters/test_shims.py | 2 +- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/mellea/backends/adapters/adapter.py b/mellea/backends/adapters/adapter.py index d119d13c6d..a7cf7342d7 100644 --- a/mellea/backends/adapters/adapter.py +++ b/mellea/backends/adapters/adapter.py @@ -98,27 +98,23 @@ def get_local_hf_path(self, base_model_name: str) -> str: class _ShimWeightsBinding(WeightsBinding): - """Phase 1 placeholder; Phase 2 (see epic #929) wires in real lifecycle.""" + """Placeholder weights binding for the deprecated IntrinsicAdapter shims. + + All lifecycle verbs raise NotImplementedError; it exists only so the + shims can satisfy the Adapter protocol. + """ def prepare(self) -> None: - raise NotImplementedError( - "Phase 2 (see epic #929) — WeightsBinding not yet implemented" - ) + raise NotImplementedError("WeightsBinding not yet implemented") def activate(self) -> None: - raise NotImplementedError( - "Phase 2 (see epic #929) — WeightsBinding not yet implemented" - ) + raise NotImplementedError("WeightsBinding not yet implemented") def deactivate(self) -> None: - raise NotImplementedError( - "Phase 2 (see epic #929) — WeightsBinding not yet implemented" - ) + raise NotImplementedError("WeightsBinding not yet implemented") def release(self) -> None: - raise NotImplementedError( - "Phase 2 (see epic #929) — WeightsBinding not yet implemented" - ) + raise NotImplementedError("WeightsBinding not yet implemented") class IntrinsicAdapter(LocalHFAdapter, _AdapterCore): @@ -953,8 +949,7 @@ class EmbeddedIntrinsicAdapter(_AdapterCore): - `io_contract`: the real, declared contract for `intrinsic_name` (issue #1516); no longer a placeholder. - - `weights`: a real `EmbeddedBinding` (issue #1142); activation - runs through it. + - `weights`: a real `EmbeddedBinding`; activation runs through it. """ def __setattr__(self, name: str, value: object) -> None: diff --git a/test/backends/test_adapters/test_shims.py b/test/backends/test_adapters/test_shims.py index a8234d844b..1708d590f1 100644 --- a/test/backends/test_adapters/test_shims.py +++ b/test/backends/test_adapters/test_shims.py @@ -331,7 +331,7 @@ def test_adapter_scope_raises_for_a_shim_backed_adapter(): mock_backend = MagicMock(spec=AdapterMixin) adapter = _make_intrinsic_adapter("answerability") - with pytest.raises(NotImplementedError, match="Phase 2"): + with pytest.raises(NotImplementedError, match="WeightsBinding not yet implemented"): with AdapterMixin.adapter_scope(mock_backend, adapter): pytest.fail("body must not run when the shim's activate() raises") From d31ebde6bd57f106f2305d6a9f8366fbcdd43834 Mon Sep 17 00:00:00 2001 From: Nigel Jones Date: Thu, 20 Aug 2026 14:47:56 +0100 Subject: [PATCH 07/10] test(backends): pin remaining verified review residuals on the delta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test_embedded_binding.py: import from the public package (sibling style); pin the dispatched HookType in the phase-complete test (a payload-similar hook type would otherwise pass); pin the explicit chat_template_kwargs=None branch; use the module-level HookType import. - EmbeddedBinding.source docstring: state what is actually recorded (the backend's base_model_name, stamped by OpenAIBackend.add_adapter) instead of a future span attribute. - _ShimIOContract docstring: 'intrinsic adapter shims' — the class is used by both deprecated shims, not IntrinsicAdapter alone. - adapter_scope docstring: docs/dev/adapter_observability.md no longer exists; point at the metric schema's actual home (AdapterFunctionMetricsPlugin in mellea/telemetry/metrics_plugins.py). Assisted-by: opencode Signed-off-by: Nigel Jones --- mellea/backends/adapters/_core.py | 7 ++++--- .../test_adapters/test_embedded_binding.py | 21 ++++++++++++++++--- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/mellea/backends/adapters/_core.py b/mellea/backends/adapters/_core.py index 69aea08906..aa05a09366 100644 --- a/mellea/backends/adapters/_core.py +++ b/mellea/backends/adapters/_core.py @@ -722,9 +722,10 @@ class EmbeddedBinding: Attributes: binding_type (ClassVar[str]): `"embedded"`. source (str): Base model identifier this binding activates adapters - against (e.g. the Hugging Face repo id served by the backend). - Recorded for the future `mellea.adapter_function.source` span - attribute (#1466); not otherwise used by `apply_activation`. + against — the backend's `base_model_name` (e.g. `granite-4.1-3b` + for a backend built against `ibm-granite/granite-4.1-3b`). + Stamped by `OpenAIBackend.add_adapter` at registration; not + otherwise used by `apply_activation`. """ binding_type: ClassVar[str] = "embedded" diff --git a/test/backends/test_adapters/test_embedded_binding.py b/test/backends/test_adapters/test_embedded_binding.py index a95edcceb4..ccac55c61f 100644 --- a/test/backends/test_adapters/test_embedded_binding.py +++ b/test/backends/test_adapters/test_embedded_binding.py @@ -12,11 +12,12 @@ import pytest -from mellea.backends.adapters._core import ( +from mellea.backends.adapters import ( EmbeddedActivationRequest, EmbeddedBinding, Identity, ) +from mellea.plugins.types import HookType def _identity( @@ -62,6 +63,21 @@ async def test_apply_activation_preserves_existing_chat_template_kwargs(): assert ctk["adapter_name"] == "citations" +async def test_apply_activation_handles_explicit_none_chat_template_kwargs(): + # The `or {}` branch: an explicit None value (not a missing key) is + # normalised to a dict rather than crashing on assignment. + binding = EmbeddedBinding() + request = EmbeddedActivationRequest( + extra_body={"chat_template_kwargs": None}, api_params={} + ) + + await binding.apply_activation(request, _identity("answerability")) + + assert request.extra_body["chat_template_kwargs"]["adapter_name"] == ( + "answerability" + ) + + def test_no_weights_verbs_on_embedded_binding(): binding = EmbeddedBinding() for verb in ("prepare", "activate", "deactivate", "release"): @@ -113,6 +129,7 @@ async def test_apply_activation_fires_phase_complete_metric(): await binding.apply_activation(request, _identity("answerability")) mock_invoke.assert_awaited_once() + assert mock_invoke.call_args.args[0] is HookType.ADAPTER_FUNCTION_PHASE_COMPLETE payload = mock_invoke.call_args.args[1] assert payload.name == "answerability" assert payload.phase == "activate" @@ -140,6 +157,4 @@ async def test_apply_activation_does_not_fire_invocation_complete(): await binding.apply_activation(request, _identity("answerability")) fired_hook_types = [call.args[0] for call in mock_invoke.call_args_list] - from mellea.plugins.types import HookType - assert HookType.ADAPTER_FUNCTION_INVOCATION_COMPLETE not in fired_hook_types From d6538a8f6569182d5ff7e753a0a5a2c8957d9136 Mon Sep 17 00:00:00 2001 From: Nigel Jones Date: Tue, 25 Aug 2026 10:54:59 +0100 Subject: [PATCH 08/10] style(backends): simplify embedded binding guard Assisted-by: Codex Signed-off-by: Nigel Jones --- mellea/backends/openai.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/mellea/backends/openai.py b/mellea/backends/openai.py index 6cf113a5bc..1ae46e8791 100644 --- a/mellea/backends/openai.py +++ b/mellea/backends/openai.py @@ -783,16 +783,15 @@ async def _generate_from_intrinsic( # constructs one unconditionally — but the shim permits attribute # mutation, so a caller reassigning `.weights` must fail loudly here # rather than silently skip activation and send an unactivated request. - if isinstance(adapter.weights, EmbeddedBinding): - activation_request = EmbeddedActivationRequest( - extra_body=extra_body, api_params=api_params - ) - await adapter.weights.apply_activation(activation_request, adapter.identity) - else: + if not isinstance(adapter.weights, EmbeddedBinding): raise TypeError( f"EmbeddedIntrinsicAdapter.weights must be an EmbeddedBinding; " f"got {type(adapter.weights).__name__}. Activation cannot proceed." ) + activation_request = EmbeddedActivationRequest( + extra_body=extra_body, api_params=api_params + ) + await adapter.weights.apply_activation(activation_request, adapter.identity) # Collect tools if tool_calls is enabled. tools: dict[str, AbstractMelleaTool] = dict() From 4d6da3be28628fa0267c0e6a4083f41015d985b5 Mon Sep 17 00:00:00 2001 From: Nigel Jones Date: Tue, 25 Aug 2026 21:57:38 +0100 Subject: [PATCH 09/10] docs: resolve adapter scope merge conflict Assisted-by: Codex Signed-off-by: Nigel Jones --- mellea/backends/adapters/adapter.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/mellea/backends/adapters/adapter.py b/mellea/backends/adapters/adapter.py index a7cf7342d7..88ca4d25ec 100644 --- a/mellea/backends/adapters/adapter.py +++ b/mellea/backends/adapters/adapter.py @@ -753,6 +753,11 @@ def adapter_scope(self, adapter: "_AdapterCore | None"): # type: ignore[type-ar deactivates. Pre-existing, not specific to the intrinsic path this method now supports. + `AdapterFunctionMetricsPlugin` in + `mellea/telemetry/metrics_plugins.py` emits the adapter-function + metrics; their instruments and attributes are defined in + `mellea/telemetry/metrics.py`. + Args: adapter: The adapter to activate, or `None` (no-op). From 3f32923e9fb85ec2aedc154c3300e9c750435fb5 Mon Sep 17 00:00:00 2001 From: Nigel Jones Date: Wed, 26 Aug 2026 07:53:09 +0100 Subject: [PATCH 10/10] fix(backends): preserve embedded adapter selection Assisted-by: Codex Signed-off-by: Nigel Jones --- mellea/backends/adapters/_core.py | 7 +-- mellea/backends/adapters/adapter.py | 2 +- mellea/backends/huggingface.py | 21 ++++---- mellea/backends/openai.py | 49 +++++++++++------ test/backends/test_openai_intrinsics_unit.py | 57 ++++++++++++++++++++ 5 files changed, 105 insertions(+), 31 deletions(-) diff --git a/mellea/backends/adapters/_core.py b/mellea/backends/adapters/_core.py index aa05a09366..fb4dba3a98 100644 --- a/mellea/backends/adapters/_core.py +++ b/mellea/backends/adapters/_core.py @@ -687,9 +687,10 @@ class EmbeddedActivationRequest: Bundles the two dicts an OpenAI-compatible call site builds separately — `extra_body` and the top-level API call kwargs — so a binding can edit - both in one call. Both are mutated in place; the caller keeps its own - references and can keep layering other edits (tool wiring, thinking mode, - user overrides) on top after `apply_activation` returns. + both in one call. Both are mutated in place. The caller must merge its + other edits (tool wiring, thinking mode, user options) before calling + `apply_activation`, because the binding performs the final framework edit + and the selected adapter is authoritative. Attributes: extra_body (dict[str, Any]): The provider's `extra_body` payload. diff --git a/mellea/backends/adapters/adapter.py b/mellea/backends/adapters/adapter.py index 88ca4d25ec..a173aaaa57 100644 --- a/mellea/backends/adapters/adapter.py +++ b/mellea/backends/adapters/adapter.py @@ -434,7 +434,7 @@ class AdapterMixin(Backend, abc.ABC): Three verbs are universal across every adapter reality (LocalFile/PEFT, Embedded/Granite Switch, ServerMediated): `base_model_name`, - `add_adapter`, and `list_adapters`. The remaining seven verbs are + `add_adapter`, and `list_adapters`. The remaining five verbs are reality-specific — a concrete backend overrides only the verb(s) matching its own reality; the others keep raising `NotImplementedError`. diff --git a/mellea/backends/huggingface.py b/mellea/backends/huggingface.py index 3d0507b78d..e471cd423a 100644 --- a/mellea/backends/huggingface.py +++ b/mellea/backends/huggingface.py @@ -583,11 +583,11 @@ def _generate_with_adapter_lock(self, generate_func: Callable, *args, **kwargs): adapter is deactivated before the model call, and the model's active state is asserted before and after. Adapter-active generation goes elsewhere — `_generate_intrinsic_with_adapter_scope` for intrinsics - (routed through `adapter_scope`, with its lifecycle hooks), and, once - #1018 lands, Granite Switch's embedded activation through a binding - `apply_activation` verb (not yet implemented — that is #1142's work) — - so no current path activates through this method, which is why it - takes no adapter name. + (routed through `adapter_scope`, with its lifecycle hooks). Granite + Switch's `EmbeddedBinding.apply_activation()` is implemented for the + OpenAI backend; local Hugging Face integration remains pending (#1018). + No current path activates through this method, which is why it takes no + adapter name. Args: generate_func: The synchronous generation callable to invoke. @@ -664,15 +664,14 @@ def _generate_intrinsic_with_adapter_scope( Returns: Whatever `generate_func` returns. """ + weights = _IntrinsicPeftBinding( + self, adapter.qualified_name, adapter.intrinsic_metadata.revision + ) scope_adapter = _AdapterCore( - identity=adapter.identity, - io_contract=_UnusedIOContract(), - weights=_IntrinsicPeftBinding( - self, adapter.qualified_name, adapter.intrinsic_metadata.revision - ), + identity=adapter.identity, io_contract=_UnusedIOContract(), weights=weights ) with self._generation_lock: - scope_adapter.weights.prepare() + weights.prepare() with self.adapter_scope(scope_adapter): _assert_correct_adapters(adapter.qualified_name, self._model) out = generate_func(*args, **kwargs) diff --git a/mellea/backends/openai.py b/mellea/backends/openai.py index 1ae46e8791..9d536f996f 100644 --- a/mellea/backends/openai.py +++ b/mellea/backends/openai.py @@ -777,22 +777,6 @@ async def _generate_from_intrinsic( if rewriter.parameters: api_params.update(rewriter.parameters) - # Embedded adapters activate via control tokens in the chat template; - # the binding owns the request edit (issue #1142). `adapter.weights` is - # always an EmbeddedBinding here — EmbeddedIntrinsicAdapter.__init__ - # constructs one unconditionally — but the shim permits attribute - # mutation, so a caller reassigning `.weights` must fail loudly here - # rather than silently skip activation and send an unactivated request. - if not isinstance(adapter.weights, EmbeddedBinding): - raise TypeError( - f"EmbeddedIntrinsicAdapter.weights must be an EmbeddedBinding; " - f"got {type(adapter.weights).__name__}. Activation cannot proceed." - ) - activation_request = EmbeddedActivationRequest( - extra_body=extra_body, api_params=api_params - ) - await adapter.weights.apply_activation(activation_request, adapter.identity) - # Collect tools if tool_calls is enabled. tools: dict[str, AbstractMelleaTool] = dict() if tool_calls: @@ -809,6 +793,22 @@ async def _generate_from_intrinsic( model_options, is_chat_context=True ) user_extra_body = user_api_params.pop("extra_body", None) + if user_extra_body is not None: + protected_extra_body_keys = { + "messages", + "model", + "parallel_tool_calls", + "stream", + "stream_options", + "tool_choice", + "tools", + } + overridden_keys = protected_extra_body_keys.intersection(user_extra_body) + if overridden_keys: + raise ValueError( + "extra_body cannot override intrinsic request fields: " + + ", ".join(sorted(overridden_keys)) + ) api_params.update(user_api_params) # Map THINKING to the correct backend parameter(s). Two mechanisms: @@ -830,6 +830,23 @@ async def _generate_from_intrinsic( extra_body = self._merge_user_extra_body(extra_body, user_extra_body) + # Embedded adapters activate via control tokens in the chat template; + # the binding owns the final request edit so callers cannot override + # the adapter selected for this intrinsic. `adapter.weights` is always + # an EmbeddedBinding here — EmbeddedIntrinsicAdapter.__init__ + # constructs one unconditionally — but the shim permits attribute + # mutation, so a caller reassigning `.weights` must fail loudly here + # rather than silently skip activation and send an unactivated request. + if not isinstance(adapter.weights, EmbeddedBinding): + raise TypeError( + f"EmbeddedIntrinsicAdapter.weights must be an EmbeddedBinding; " + f"got {type(adapter.weights).__name__}. Activation cannot proceed." + ) + activation_request = EmbeddedActivationRequest( + extra_body=extra_body, api_params=api_params + ) + await adapter.weights.apply_activation(activation_request, adapter.identity) + # --- 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, diff --git a/test/backends/test_openai_intrinsics_unit.py b/test/backends/test_openai_intrinsics_unit.py index 3577a16dc2..35a0934dd9 100644 --- a/test/backends/test_openai_intrinsics_unit.py +++ b/test/backends/test_openai_intrinsics_unit.py @@ -543,6 +543,63 @@ async def test_user_extra_body_merges_into_intrinsic_extra_body(): assert call_kwargs["reasoning_effort"] == "medium" +async def test_user_extra_body_cannot_override_embedded_adapter(): + """The resolved embedded adapter overrides a caller-supplied adapter_name.""" + 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": { + "chat_template_kwargs": {"adapter_name": "caller-selected"} + } + }, + ) + await mot.avalue() + + extra_body = mock_create.call_args.kwargs["extra_body"] + assert extra_body["chat_template_kwargs"]["adapter_name"] == "answerability" + + +@pytest.mark.parametrize( + ("field", "value"), + [("model", "other-model"), ("messages", []), ("stream", True), ("tools", [])], +) +async def test_intrinsic_extra_body_rejects_protected_request_fields( + field: str, value: object +): + """Provider-specific extra_body values cannot override intrinsic invariants.""" + backend = _make_backend_with_adapter(_SIMPLE_CONFIG) + ctx = _make_context() + + with pytest.raises( + ValueError, + match=rf"extra_body cannot override intrinsic request fields: {field}", + ): + mot, _ = await mfuncs.aact( + Intrinsic("answerability"), + ctx, + backend, + strategy=None, + model_options={"extra_body": {field: value}}, + ) + await mot.avalue() + + 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)